sillyspec 3.20.2 → 3.20.4
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.
- package/.claude/skills/sillyspec-archive/SKILL.md +21 -21
- package/.claude/skills/sillyspec-auto/SKILL.md +83 -83
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +44 -44
- package/.claude/skills/sillyspec-commit/SKILL.md +106 -106
- package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
- package/.claude/skills/sillyspec-doctor/SKILL.md +31 -31
- package/.claude/skills/sillyspec-execute/SKILL.md +30 -30
- package/.claude/skills/sillyspec-explore/SKILL.md +109 -109
- package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
- package/.claude/skills/sillyspec-plan/SKILL.md +21 -21
- package/.claude/skills/sillyspec-propose/SKILL.md +21 -21
- package/.claude/skills/sillyspec-quick/SKILL.md +21 -21
- package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
- package/.claude/skills/sillyspec-scan/SKILL.md +21 -21
- package/.claude/skills/sillyspec-state/SKILL.md +54 -54
- package/.claude/skills/sillyspec-status/SKILL.md +21 -21
- package/.claude/skills/sillyspec-verify/SKILL.md +21 -21
- package/.claude/skills/sillyspec-workspace/SKILL.md +157 -157
- package/.husky/pre-push +13 -13
- package/CLAUDE.md +18 -18
- package/README.md +198 -188
- package/SKILL.md +90 -91
- package/bin/sillyspec.js +2 -2
- package/docs/brainstorm-plan-contract.md +64 -64
- package/docs/plan-execute-contract.md +123 -123
- package/docs/platform-scan-protocol.md +298 -298
- package/docs/revision-mode.md +115 -115
- package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -99
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -218
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -167
- package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -148
- package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +211 -193
- package/docs/sillyspec/file-lifecycle.md +125 -125
- package/docs/workflow-contract-regression.md +106 -106
- package/docs/worktree-isolation.md +252 -252
- package/package.json +40 -40
- package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
- package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
- package/packages/dashboard/dist/index.html +16 -16
- package/packages/dashboard/index.html +15 -15
- package/packages/dashboard/package-lock.json +2384 -2384
- package/packages/dashboard/package.json +25 -25
- package/packages/dashboard/server/executor.js +86 -86
- package/packages/dashboard/server/index.js +588 -588
- package/packages/dashboard/server/parser.js +526 -526
- package/packages/dashboard/server/watcher.js +344 -344
- package/packages/dashboard/src/App.vue +558 -558
- package/packages/dashboard/src/components/ActionBar.vue +93 -93
- package/packages/dashboard/src/components/CommandPalette.vue +96 -96
- package/packages/dashboard/src/components/DetailPanel.vue +137 -137
- package/packages/dashboard/src/components/LogStream.vue +65 -65
- package/packages/dashboard/src/components/PipelineStage.vue +95 -95
- package/packages/dashboard/src/components/PipelineView.vue +156 -156
- package/packages/dashboard/src/components/ProjectList.vue +210 -210
- package/packages/dashboard/src/components/StageBadge.vue +67 -67
- package/packages/dashboard/src/components/StepCard.vue +94 -94
- package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -48
- package/packages/dashboard/src/components/detail/GitDetail.vue +61 -61
- package/packages/dashboard/src/components/detail/TechDetail.vue +43 -43
- package/packages/dashboard/src/composables/useDashboard.js +170 -170
- package/packages/dashboard/src/composables/useKeyboard.js +119 -119
- package/packages/dashboard/src/composables/useWebSocket.js +129 -129
- package/packages/dashboard/src/main.js +8 -8
- package/packages/dashboard/src/style.css +132 -132
- package/packages/dashboard/vite.config.js +18 -18
- package/src/brainstorm-postcheck.js +158 -158
- package/src/change-list.js +52 -52
- package/src/change-risk-profile.js +352 -352
- package/src/classify-change.js +73 -73
- package/src/constants.js +70 -70
- package/src/contract-matrix.js +278 -278
- package/src/db.js +201 -201
- package/src/endpoint-extractor.js +315 -315
- package/src/hooks/claude-pre-tool-use.cjs +125 -125
- package/src/hooks/worktree-guard.js +653 -653
- package/src/index.js +922 -900
- package/src/init.js +431 -431
- package/src/knowledge-match.js +130 -130
- package/src/migrate.js +117 -117
- package/src/modules.js +482 -482
- package/src/progress.js +1734 -1734
- package/src/run.js +3465 -3358
- package/src/scan-postcheck.js +387 -383
- package/src/setup.js +398 -398
- package/src/stage-contract.js +700 -700
- package/src/stages/archive.js +160 -160
- package/src/stages/brainstorm-auto.js +229 -229
- package/src/stages/brainstorm.js +645 -645
- package/src/stages/doctor.js +365 -365
- package/src/stages/execute.js +625 -625
- package/src/stages/explore.js +34 -34
- package/src/stages/index.js +29 -29
- package/src/stages/knowledge.js +498 -498
- package/src/stages/plan-postcheck.js +511 -513
- package/src/stages/plan.js +582 -582
- package/src/stages/propose.js +174 -174
- package/src/stages/quick.js +82 -82
- package/src/stages/scan.js +558 -558
- package/src/stages/status.js +65 -65
- package/src/stages/verify.js +322 -322
- package/src/sync.js +497 -497
- package/src/task-review.js +346 -346
- package/src/workflow.js +785 -785
- package/src/worktree-apply.js +549 -549
- package/src/worktree-deps.js +185 -0
- package/src/worktree.js +982 -932
- package/templates/workflows/archive-impact.yaml +79 -79
- package/templates/workflows/scan-docs.yaml +132 -132
- package/test/brainstorm-plan-contract.test.mjs +273 -273
- package/test/check-syntax.mjs +26 -26
- package/test/contract-artifacts.test.mjs +323 -323
- package/test/decision-supersede.test.mjs +277 -277
- package/test/knowledge-match.test.mjs +231 -231
- package/test/plan-execute-contract.test.mjs +330 -330
- package/test/plan-optimization.test.mjs +572 -572
- package/test/platform-artifacts.test.mjs +166 -166
- package/test/platform-failure-samples.test.mjs +199 -199
- package/test/platform-recovery-chain.test.mjs +167 -167
- package/test/platform-recovery.test.mjs +136 -136
- package/test/platform-scan-p0.test.mjs +168 -168
- package/test/revision-v1.test.mjs +1145 -1145
- package/test/run-scan-project-parse.test.mjs +200 -200
- package/test/run-tests.mjs +48 -48
- package/test/scan-knowledge.test.mjs +175 -175
- package/test/scan-paths.test.mjs +68 -68
- package/test/scan-postcheck.test.mjs +197 -197
- package/test/spec-dir.test.mjs +206 -206
- package/test/stage-contract.test.mjs +299 -299
- package/test/stage-definitions.test.mjs +39 -39
- package/test/wait-gates.test.mjs +496 -496
- package/test/worktree-deps-provision.test.mjs +148 -0
- package/test/worktree-guard.test.mjs +71 -71
- package/test/worktree-native-overlay.test.mjs +188 -188
package/src/progress.js
CHANGED
|
@@ -1,1734 +1,1734 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SillySpec ProgressManager — 进度恢复管理
|
|
3
|
-
*
|
|
4
|
-
* 纯 Node.js,无外部依赖。支持多变更并行。
|
|
5
|
-
*
|
|
6
|
-
* 存储结构:
|
|
7
|
-
* .sillyspec/.runtime/sillyspec.db — SQLite 数据库(权威状态源)
|
|
8
|
-
* .sillyspec/.runtime/global.json — 全局状态缓存(项目名、活跃变更列表)
|
|
9
|
-
* .sillyspec/.runtime/gate-status.json — worktree-guard 门禁状态缓存
|
|
10
|
-
*
|
|
11
|
-
* 历史迁移:v1/v2 使用 progress.json 文件,v3 已全部迁移至 SQLite。
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
15
|
-
import { join, basename, dirname, resolve } from 'path';
|
|
16
|
-
import { DB } from './db.js';
|
|
17
|
-
|
|
18
|
-
// 默认规范目录名(相对于 cwd)
|
|
19
|
-
const SPEC_DIR_NAME = '.sillyspec';
|
|
20
|
-
const RUNTIME_SUBDIR = '.runtime';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* 向上查找含 .sillyspec 目录的祖先目录,类似 git 找 .git 的逻辑。
|
|
24
|
-
* 找到则返回 <祖先>/.sillyspec,否则 fallback 到 <cwd>/.sillyspec。
|
|
25
|
-
*/
|
|
26
|
-
export function resolveSpecDir(startDir) {
|
|
27
|
-
let dir = resolve(startDir);
|
|
28
|
-
while (true) {
|
|
29
|
-
const candidate = join(dir, SPEC_DIR_NAME);
|
|
30
|
-
if (existsSync(candidate)) return candidate;
|
|
31
|
-
const parent = dirname(dir);
|
|
32
|
-
if (parent === dir) break; // 到达根目录
|
|
33
|
-
dir = parent;
|
|
34
|
-
}
|
|
35
|
-
return join(resolve(startDir), SPEC_DIR_NAME);
|
|
36
|
-
}
|
|
37
|
-
const CHANGES_SUBDIR = 'changes';
|
|
38
|
-
const GLOBAL_FILE = 'global.json';
|
|
39
|
-
const CURRENT_VERSION = 3;
|
|
40
|
-
const VALID_STAGES = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore'];
|
|
41
|
-
const VALID_STATUSES = ['pending', 'in-progress', 'completed', 'failed', 'blocked', 'waiting', 'stale'];
|
|
42
|
-
|
|
43
|
-
// Stage statuses (superset of step statuses)
|
|
44
|
-
const VALID_STAGE_STATUSES = ['pending', 'in-progress', 'completed', 'failed', 'blocked', 'revising', 'stale'];
|
|
45
|
-
|
|
46
|
-
// Main flow stage order (for downstream cascade)
|
|
47
|
-
// 完整主流程顺序(含 scan),用于下游 cascade
|
|
48
|
-
const STAGE_ORDER = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive'];
|
|
49
|
-
// 主流程阶段(不含 scan/quick/explore 等辅助阶段)
|
|
50
|
-
const MAIN_FLOW_ORDER = STAGE_ORDER;
|
|
51
|
-
|
|
52
|
-
const STAGE_LABELS = {
|
|
53
|
-
brainstorm: '🧠 需求探索',
|
|
54
|
-
plan: '📐 实现计划',
|
|
55
|
-
execute: '⚡ 波次执行',
|
|
56
|
-
verify: '🔍 验证确认',
|
|
57
|
-
scan: '🔍 代码扫描',
|
|
58
|
-
quick: '⚡ 快速任务',
|
|
59
|
-
explore: '🧭 自由探索',
|
|
60
|
-
archive: '📦 归档变更',
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
function emptyStage() {
|
|
64
|
-
return { status: 'pending', steps: [], startedAt: null, completedAt: null };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function makeInitialProgress(project) {
|
|
68
|
-
const stages = {};
|
|
69
|
-
for (const s of VALID_STAGES) stages[s] = emptyStage();
|
|
70
|
-
return { _version: CURRENT_VERSION, project: project || '', currentStage: '', currentChange: null, stages, lastActive: null };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function makeInitialGlobal(project) {
|
|
74
|
-
return { _version: CURRENT_VERSION, project: project || '', activeChanges: [] };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ── ProgressManager ──
|
|
78
|
-
|
|
79
|
-
export class ProgressManager {
|
|
80
|
-
/**
|
|
81
|
-
* @param {object} [opts]
|
|
82
|
-
* @param {string} [opts.specDir] - 规范目录绝对路径(默认 cwd/.sillyspec)
|
|
83
|
-
*/
|
|
84
|
-
constructor(opts = {}) {
|
|
85
|
-
this._customSpecDir = opts.specDir || null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// ── 路径工具 ──
|
|
89
|
-
|
|
90
|
-
/** 获取 specDir(优先自定义,否则向上查找含 .sillyspec 的目录,fallback 到 cwd/.sillyspec) */
|
|
91
|
-
_getSpecDir(cwd) {
|
|
92
|
-
if (this._customSpecDir) return this._customSpecDir;
|
|
93
|
-
return resolveSpecDir(cwd);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
_runtimePath(cwd, ...parts) {
|
|
97
|
-
return join(this._getSpecDir(cwd), RUNTIME_SUBDIR, ...parts);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
_changePath(cwd, changeName, ...parts) {
|
|
101
|
-
return join(this._getSpecDir(cwd), CHANGES_SUBDIR, changeName, ...parts);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
_ensureRuntimeDir(cwd) {
|
|
105
|
-
const runtimeDir = this._runtimePath(cwd);
|
|
106
|
-
if (!existsSync(runtimeDir)) {
|
|
107
|
-
mkdirSync(runtimeDir, { recursive: true });
|
|
108
|
-
for (const d of ['artifacts', 'history', 'logs', 'templates']) {
|
|
109
|
-
mkdirSync(join(runtimeDir, d), { recursive: true });
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** 懒初始化 DB 连接,缓存在实例上 */
|
|
115
|
-
async _ensureDB(cwd) {
|
|
116
|
-
if (!this._db) {
|
|
117
|
-
this._db = new DB(this._runtimePath(cwd, 'sillyspec.db'));
|
|
118
|
-
await this._db.init();
|
|
119
|
-
}
|
|
120
|
-
return this._db;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
_ensureChangeDir(cwd, changeName) {
|
|
124
|
-
const dir = this._changePath(cwd, changeName);
|
|
125
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
126
|
-
return dir;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ── 全局状态 ──
|
|
130
|
-
|
|
131
|
-
async readGlobal(cwd) {
|
|
132
|
-
// SQL: SELECT FROM project + changes
|
|
133
|
-
const db = await this._ensureDB(cwd);
|
|
134
|
-
const sqlDb = db.getDb();
|
|
135
|
-
|
|
136
|
-
// 读取 project 行(id=1)
|
|
137
|
-
const rows = sqlDb.exec('SELECT name, schema_version FROM project WHERE id = 1');
|
|
138
|
-
if (!rows || rows.length === 0 || rows[0].values.length === 0) return null;
|
|
139
|
-
const [name, schemaVersion] = rows[0].values[0];
|
|
140
|
-
|
|
141
|
-
// 读取 active 变更列表
|
|
142
|
-
const changeRows = sqlDb.exec("SELECT name FROM changes WHERE status = 'active' ORDER BY name");
|
|
143
|
-
const activeChanges = changeRows && changeRows.length > 0
|
|
144
|
-
? changeRows[0].values.map(r => r[0])
|
|
145
|
-
: [];
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
_version: schemaVersion,
|
|
149
|
-
project: name,
|
|
150
|
-
activeChanges,
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async writeGlobal(cwd, data) {
|
|
155
|
-
// SQL: UPDATE project + UPSERT changes status
|
|
156
|
-
const db = await this._ensureDB(cwd);
|
|
157
|
-
db.transaction((sqlDb) => {
|
|
158
|
-
const now = new Date().toISOString();
|
|
159
|
-
|
|
160
|
-
// UPSERT project 行
|
|
161
|
-
sqlDb.run(`
|
|
162
|
-
INSERT INTO project (id, name, schema_version, created_at, updated_at)
|
|
163
|
-
VALUES (1, ?, ?, ?, ?)
|
|
164
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
165
|
-
name = excluded.name,
|
|
166
|
-
schema_version = excluded.schema_version,
|
|
167
|
-
updated_at = excluded.updated_at
|
|
168
|
-
`, [data.project || '', data._version || CURRENT_VERSION, now, now]);
|
|
169
|
-
|
|
170
|
-
// 同步 changes 表:确保 activeChanges 列表中的变更存在且为 active,
|
|
171
|
-
// 不在列表中的设为 archived
|
|
172
|
-
const activeChanges = data.activeChanges || [];
|
|
173
|
-
for (const cn of activeChanges) {
|
|
174
|
-
sqlDb.run(`
|
|
175
|
-
INSERT INTO changes (name, status, created_at, last_active)
|
|
176
|
-
VALUES (?, 'active', ?, ?)
|
|
177
|
-
ON CONFLICT(name) DO UPDATE SET status = 'active', last_active = excluded.last_active
|
|
178
|
-
`, [cn, now, now]);
|
|
179
|
-
}
|
|
180
|
-
if (activeChanges.length > 0) {
|
|
181
|
-
sqlDb.run(`
|
|
182
|
-
UPDATE changes SET status = 'archived'
|
|
183
|
-
WHERE status = 'active' AND name NOT IN (${activeChanges.map(() => '?').join(',')})
|
|
184
|
-
`, activeChanges);
|
|
185
|
-
} else {
|
|
186
|
-
// 没有活跃变更,将所有 active 归档
|
|
187
|
-
sqlDb.run("UPDATE changes SET status = 'archived' WHERE status = 'active'");
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ── 变更级别状态 ──
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* 读取指定变更的 progress(SQL 版)
|
|
196
|
-
* @param {string} cwd
|
|
197
|
-
* @param {string|null} changeName - 变更名,null 时尝试自动检测
|
|
198
|
-
* @returns {Promise<object|null>} 与 SQLite 查询结果一致的 JS 对象
|
|
199
|
-
*/
|
|
200
|
-
async read(cwd, changeName = null) {
|
|
201
|
-
// 自动检测变更名
|
|
202
|
-
if (!changeName) {
|
|
203
|
-
const changes = await this.listChanges(cwd);
|
|
204
|
-
if (changes.length === 1) {
|
|
205
|
-
changeName = changes[0];
|
|
206
|
-
} else {
|
|
207
|
-
// 多个或零个活跃变更,无法确定
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const db = await this._ensureDB(cwd);
|
|
213
|
-
const sqlDb = db.getDb();
|
|
214
|
-
|
|
215
|
-
// 1. 从 changes 表获取基本信息
|
|
216
|
-
const changeRows = sqlDb.exec('SELECT id, name, current_stage, no_worktree, last_active FROM changes WHERE name = ?', [changeName]);
|
|
217
|
-
if (!changeRows || changeRows.length === 0 || changeRows[0].values.length === 0) return null;
|
|
218
|
-
const [changeId, cName, currentStage, noWorktree, lastActive] = changeRows[0].values[0];
|
|
219
|
-
|
|
220
|
-
// 2. 从 stages 表获取所有阶段(含 revision 列)
|
|
221
|
-
const stageRows = sqlDb.exec('SELECT id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason FROM stages WHERE change_id = ? ORDER BY id', [changeId]);
|
|
222
|
-
const stageMap = {};
|
|
223
|
-
const stageIds = [];
|
|
224
|
-
if (stageRows && stageRows.length > 0) {
|
|
225
|
-
for (const [sId, stage, status, startedAt, completedAt, revision, reopenedFromStep, reopenedAt, staleReason] of stageRows[0].values) {
|
|
226
|
-
stageMap[stage] = { _dbId: sId, status, startedAt, completedAt,
|
|
227
|
-
...(revision ? { revision } : {}),
|
|
228
|
-
...(reopenedFromStep ? { reopenedFromStep } : {}),
|
|
229
|
-
...(reopenedAt ? { reopenedAt } : {}),
|
|
230
|
-
...(staleReason ? { staleReason } : {}),
|
|
231
|
-
};
|
|
232
|
-
stageIds.push(sId);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// 3. 从 steps 表获取所有步骤
|
|
237
|
-
let stepRows = null;
|
|
238
|
-
if (stageIds.length > 0) {
|
|
239
|
-
const placeholders = stageIds.map(() => '?').join(',');
|
|
240
|
-
stepRows = sqlDb.exec(
|
|
241
|
-
`SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`,
|
|
242
|
-
stageIds
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
// 按阶段分组步骤
|
|
246
|
-
const stepsByStage = {};
|
|
247
|
-
if (stepRows && stepRows.length > 0) {
|
|
248
|
-
for (const row of stepRows[0].values) {
|
|
249
|
-
const [stageId, name, status, output, completedAt, ordering, waitReason, waitOptions, waitAnswer, waitedAt, waitAnswersJson, waitRound, maxWaitRounds] = row;
|
|
250
|
-
if (!stepsByStage[stageId]) stepsByStage[stageId] = [];
|
|
251
|
-
let waitAnswers = null;
|
|
252
|
-
if (waitAnswersJson) {
|
|
253
|
-
try { waitAnswers = JSON.parse(waitAnswersJson); } catch {}
|
|
254
|
-
}
|
|
255
|
-
stepsByStage[stageId].push({
|
|
256
|
-
name, status, output, completedAt,
|
|
257
|
-
...(waitReason ? { waitReason } : {}),
|
|
258
|
-
...(waitOptions ? { waitOptions } : {}),
|
|
259
|
-
...(waitAnswer ? { waitAnswer } : {}),
|
|
260
|
-
...(waitedAt ? { waitedAt } : {}),
|
|
261
|
-
...(waitAnswers ? { waitAnswers } : {}),
|
|
262
|
-
...(waitRound != null ? { waitRound } : {}),
|
|
263
|
-
...(maxWaitRounds != null ? { maxWaitRounds } : {}),
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// 4. 从 batch_progress 表获取批量进度
|
|
269
|
-
const batchRows = sqlDb.exec('SELECT total, completed, failed, skipped FROM batch_progress WHERE change_id = ?', [changeId]);
|
|
270
|
-
let batchProgress = undefined;
|
|
271
|
-
if (batchRows && batchRows.length > 0 && batchRows[0].values.length > 0) {
|
|
272
|
-
const [total, completed, failed, skipped] = batchRows[0].values[0];
|
|
273
|
-
batchProgress = { total, completed, failed, skipped };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// 5. 获取项目名
|
|
277
|
-
const projectRows = sqlDb.exec('SELECT name FROM project WHERE id = 1');
|
|
278
|
-
const projectName = (projectRows && projectRows.length > 0 && projectRows[0].values.length > 0)
|
|
279
|
-
? projectRows[0].values[0][0]
|
|
280
|
-
: '';
|
|
281
|
-
|
|
282
|
-
// 6. 组装为兼容对象
|
|
283
|
-
const stages = {};
|
|
284
|
-
// 先填充所有 VALID_STAGES
|
|
285
|
-
for (const s of VALID_STAGES) {
|
|
286
|
-
stages[s] = emptyStage();
|
|
287
|
-
}
|
|
288
|
-
// 用 DB 数据覆盖
|
|
289
|
-
for (const [stage, info] of Object.entries(stageMap)) {
|
|
290
|
-
const steps = (stepsByStage[info._dbId] || []).map(s => ({
|
|
291
|
-
name: s.name,
|
|
292
|
-
status: s.status,
|
|
293
|
-
output: s.output,
|
|
294
|
-
completedAt: s.completedAt,
|
|
295
|
-
...(s.waitReason ? { waitReason: s.waitReason } : {}),
|
|
296
|
-
...(s.waitOptions ? { waitOptions: s.waitOptions } : {}),
|
|
297
|
-
...(s.waitAnswer ? { waitAnswer: s.waitAnswer } : {}),
|
|
298
|
-
...(s.waitedAt ? { waitedAt: s.waitedAt } : {}),
|
|
299
|
-
...(s.waitAnswers ? { waitAnswers: s.waitAnswers } : {}),
|
|
300
|
-
...(s.waitRound != null ? { waitRound: s.waitRound } : {}),
|
|
301
|
-
...(s.maxWaitRounds != null ? { maxWaitRounds: s.maxWaitRounds } : {}),
|
|
302
|
-
}));
|
|
303
|
-
stages[stage] = {
|
|
304
|
-
status: info.status,
|
|
305
|
-
steps,
|
|
306
|
-
startedAt: info.startedAt,
|
|
307
|
-
completedAt: info.completedAt,
|
|
308
|
-
// Revision v1 fields
|
|
309
|
-
...(info.revision ? { revision: info.revision } : {}),
|
|
310
|
-
...(info.reopenedFromStep ? { reopenedFromStep: info.reopenedFromStep } : {}),
|
|
311
|
-
...(info.reopenedAt ? { reopenedAt: info.reopenedAt } : {}),
|
|
312
|
-
...(info.staleReason ? { staleReason: info.staleReason } : {}),
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const result = {
|
|
317
|
-
_version: 3,
|
|
318
|
-
project: projectName,
|
|
319
|
-
currentChange: cName,
|
|
320
|
-
currentStage: currentStage || '',
|
|
321
|
-
lastActive: lastActive || null,
|
|
322
|
-
stages,
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
// noWorktree
|
|
326
|
-
if (noWorktree) result.noWorktree = true;
|
|
327
|
-
|
|
328
|
-
// batchProgress(仅在 DB 中有记录时才包含)
|
|
329
|
-
if (batchProgress) result.batchProgress = batchProgress;
|
|
330
|
-
|
|
331
|
-
return result;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* 写入指定变更的 progress
|
|
336
|
-
* @param {string} cwd
|
|
337
|
-
* @param {object} data
|
|
338
|
-
* @param {string|null} changeName - 从 data.currentChange 推导,或显式传入
|
|
339
|
-
*/
|
|
340
|
-
async _write(cwd, data, changeName = null) {
|
|
341
|
-
const cn = changeName || data.currentChange;
|
|
342
|
-
if (!cn) {
|
|
343
|
-
console.warn('⚠️ _write: 无变更名,跳过写入');
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const db = await this._ensureDB(cwd);
|
|
348
|
-
db.transaction((sqlDb) => {
|
|
349
|
-
// 1. 更新 changes 表
|
|
350
|
-
const now = new Date().toISOString();
|
|
351
|
-
const noWorktree = data.noWorktree ? 1 : 0;
|
|
352
|
-
sqlDb.run(
|
|
353
|
-
'UPDATE changes SET current_stage = ?, last_active = ?, no_worktree = ? WHERE name = ?',
|
|
354
|
-
[data.currentStage || '', now, noWorktree, cn]
|
|
355
|
-
);
|
|
356
|
-
|
|
357
|
-
// 2. 获取 change_id
|
|
358
|
-
const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [cn]);
|
|
359
|
-
if (!changeRow || changeRow.length === 0 || changeRow[0].values.length === 0) return;
|
|
360
|
-
const changeId = changeRow[0].values[0][0];
|
|
361
|
-
|
|
362
|
-
// 3. 遍历 stages,UPSERT stages 表和 steps 表
|
|
363
|
-
if (data.stages && typeof data.stages === 'object') {
|
|
364
|
-
for (const [stageName, stageData] of Object.entries(data.stages)) {
|
|
365
|
-
// UPSERT stages 行(含 revision 列)
|
|
366
|
-
sqlDb.run(
|
|
367
|
-
`INSERT INTO stages (change_id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason)
|
|
368
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
369
|
-
ON CONFLICT(change_id, stage) DO UPDATE SET
|
|
370
|
-
status = excluded.status,
|
|
371
|
-
started_at = excluded.started_at,
|
|
372
|
-
completed_at = excluded.completed_at,
|
|
373
|
-
revision = COALESCE(excluded.revision, stages.revision),
|
|
374
|
-
reopened_from_step = excluded.reopened_from_step,
|
|
375
|
-
reopened_at = excluded.reopened_at,
|
|
376
|
-
stale_reason = excluded.stale_reason`,
|
|
377
|
-
[changeId, stageName, stageData.status || 'pending', stageData.startedAt || null, stageData.completedAt || null,
|
|
378
|
-
stageData.revision || 0, stageData.reopenedFromStep || null, stageData.reopenedAt || null, stageData.staleReason || null]
|
|
379
|
-
);
|
|
380
|
-
|
|
381
|
-
// 获取 stage_id
|
|
382
|
-
const stageRow = sqlDb.exec('SELECT id FROM stages WHERE change_id = ? AND stage = ?', [changeId, stageName]);
|
|
383
|
-
if (!stageRow || stageRow.length === 0 || stageRow[0].values.length === 0) continue;
|
|
384
|
-
const stageId = stageRow[0].values[0][0];
|
|
385
|
-
|
|
386
|
-
// 收集 data 中的步骤名
|
|
387
|
-
const stepNames = new Set();
|
|
388
|
-
if (Array.isArray(stageData.steps)) {
|
|
389
|
-
for (let i = 0; i < stageData.steps.length; i++) {
|
|
390
|
-
const step = stageData.steps[i];
|
|
391
|
-
stepNames.add(step.name);
|
|
392
|
-
// UPSERT 步骤(先删再插,steps 表无 UNIQUE 约束)
|
|
393
|
-
sqlDb.run('DELETE FROM steps WHERE stage_id = ? AND name = ?', [stageId, step.name]);
|
|
394
|
-
sqlDb.run(
|
|
395
|
-
'INSERT INTO steps (stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
396
|
-
[stageId, step.name, step.status || 'pending', step.output || null, step.completedAt || null, i,
|
|
397
|
-
step.waitReason ?? null, step.waitOptions ?? null, step.waitAnswer ?? null, step.waitedAt ?? null,
|
|
398
|
-
Array.isArray(step.waitAnswers) ? JSON.stringify(step.waitAnswers) : null,
|
|
399
|
-
step.waitRound ?? null, step.maxWaitRounds ?? null]
|
|
400
|
-
);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// 删除 data 中不存在的多余步骤
|
|
405
|
-
if (stepNames.size > 0) {
|
|
406
|
-
const namePlaceholders = [...stepNames].map(() => '?').join(',');
|
|
407
|
-
sqlDb.run(
|
|
408
|
-
`DELETE FROM steps WHERE stage_id = ? AND name NOT IN (${namePlaceholders})`,
|
|
409
|
-
[stageId, ...stepNames]
|
|
410
|
-
);
|
|
411
|
-
} else {
|
|
412
|
-
// data 中没有步骤,清空该阶段所有步骤
|
|
413
|
-
sqlDb.run('DELETE FROM steps WHERE stage_id = ?', [stageId]);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// 4. UPSERT batch_progress
|
|
419
|
-
if (data.batchProgress && typeof data.batchProgress === 'object') {
|
|
420
|
-
sqlDb.run(
|
|
421
|
-
`INSERT INTO batch_progress (change_id, total, completed, failed, skipped)
|
|
422
|
-
VALUES (?, ?, ?, ?, ?)
|
|
423
|
-
ON CONFLICT(change_id) DO UPDATE SET
|
|
424
|
-
total = excluded.total,
|
|
425
|
-
completed = excluded.completed,
|
|
426
|
-
failed = excluded.failed,
|
|
427
|
-
skipped = excluded.skipped`,
|
|
428
|
-
[changeId, data.batchProgress.total || 0, data.batchProgress.completed || 0, data.batchProgress.failed || 0, data.batchProgress.skipped || 0]
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
await this._updateGateStatus(cwd);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// ── 变更管理 ──
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* 列出所有活跃变更名
|
|
440
|
-
* SQL: SELECT name FROM changes WHERE status = 'active'
|
|
441
|
-
*/
|
|
442
|
-
async listChanges(cwd) {
|
|
443
|
-
const db = await this._ensureDB(cwd);
|
|
444
|
-
const sqlDb = db.getDb();
|
|
445
|
-
const rows = sqlDb.exec("SELECT name FROM changes WHERE status = 'active' ORDER BY name");
|
|
446
|
-
if (!rows || rows.length === 0) return [];
|
|
447
|
-
return rows[0].values.map(r => r[0]);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* 注册变更到活跃列表
|
|
452
|
-
* SQL: INSERT OR IGNORE → 若已 archived 则 UPDATE status='active'
|
|
453
|
-
*/
|
|
454
|
-
async registerChange(cwd, changeName) {
|
|
455
|
-
if (!changeName) {
|
|
456
|
-
console.warn('⚠️ registerChange: changeName 为空,跳过');
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
const db = await this._ensureDB(cwd);
|
|
460
|
-
db.transaction((sqlDb) => {
|
|
461
|
-
const now = new Date().toISOString();
|
|
462
|
-
// 尝试插入新行
|
|
463
|
-
sqlDb.run(
|
|
464
|
-
`INSERT OR IGNORE INTO changes (name, created_at, last_active)
|
|
465
|
-
VALUES (?, ?, ?)`,
|
|
466
|
-
[changeName, now, now]
|
|
467
|
-
);
|
|
468
|
-
// 注意:不复活已归档的变更——归档是不可逆操作
|
|
469
|
-
// 如果变更已存在且为 archived,保持 archived 状态不变
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* 更新变更的隔离状态
|
|
475
|
-
* @param {string} cwd - 项目根目录
|
|
476
|
-
* @param {string} changeName - 变更名
|
|
477
|
-
* @param {{ status: string, mode?: string, reason?: string }} isolation
|
|
478
|
-
*/
|
|
479
|
-
async updateChangeIsolation(cwd, changeName, isolation) {
|
|
480
|
-
const db = await this._ensureDB(cwd);
|
|
481
|
-
const sqlDb = db.getDb();
|
|
482
|
-
try {
|
|
483
|
-
sqlDb.run(
|
|
484
|
-
`UPDATE changes SET isolation_status = ?, isolation_mode = ?, isolation_reason = ?, last_active = ? WHERE name = ?`,
|
|
485
|
-
[isolation.status, isolation.mode || null, isolation.reason || null, new Date().toISOString(), changeName]
|
|
486
|
-
);
|
|
487
|
-
db._save();
|
|
488
|
-
} catch (err) {
|
|
489
|
-
console.warn('⚠️ 更新 isolation 状态失败:', err.message);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* 读取变更的隔离状态
|
|
495
|
-
* @param {string} cwd - 项目根目录
|
|
496
|
-
* @param {string} changeName - 变更名
|
|
497
|
-
* @returns {{ status: string|null, mode: string|null, reason: string|null }|null}
|
|
498
|
-
*/
|
|
499
|
-
async readChangeIsolation(cwd, changeName) {
|
|
500
|
-
const db = await this._ensureDB(cwd);
|
|
501
|
-
const sqlDb = db.getDb();
|
|
502
|
-
try {
|
|
503
|
-
const rows = sqlDb.exec(
|
|
504
|
-
`SELECT isolation_status, isolation_mode, isolation_reason FROM changes WHERE name = ?`,
|
|
505
|
-
[changeName]
|
|
506
|
-
);
|
|
507
|
-
if (!rows || rows.length === 0 || rows[0].values.length === 0) return null;
|
|
508
|
-
const [status, mode, reason] = rows[0].values[0];
|
|
509
|
-
return { status: status || null, mode: mode || null, reason: reason || null };
|
|
510
|
-
} catch {
|
|
511
|
-
return null;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
async _updatePlatformLastSync(cwd, changeName) {
|
|
516
|
-
if (!changeName) return;
|
|
517
|
-
const db = await this._ensureDB(cwd);
|
|
518
|
-
db.transaction((sqlDb) => {
|
|
519
|
-
sqlDb.run(
|
|
520
|
-
'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1 WHERE name = ?',
|
|
521
|
-
[new Date().toISOString(), changeName]
|
|
522
|
-
);
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
async _updateApprovalStatus(cwd, changeName, status, reason = null) {
|
|
527
|
-
if (!changeName || !status) return;
|
|
528
|
-
const db = await this._ensureDB(cwd);
|
|
529
|
-
db.transaction((sqlDb) => {
|
|
530
|
-
const rows = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
|
|
531
|
-
if (!rows || rows.length === 0 || rows[0].values.length === 0) return;
|
|
532
|
-
const changeId = rows[0].values[0][0];
|
|
533
|
-
const now = new Date().toISOString();
|
|
534
|
-
sqlDb.run(
|
|
535
|
-
`INSERT INTO approvals (change_id, status, requested_at, approved_at, rejection_reason)
|
|
536
|
-
VALUES (?, ?, ?, ?, ?)
|
|
537
|
-
ON CONFLICT(change_id) DO UPDATE SET
|
|
538
|
-
status = excluded.status,
|
|
539
|
-
approved_at = excluded.approved_at,
|
|
540
|
-
rejection_reason = excluded.rejection_reason`,
|
|
541
|
-
[
|
|
542
|
-
changeId,
|
|
543
|
-
status,
|
|
544
|
-
now,
|
|
545
|
-
status === 'approved' ? now : null,
|
|
546
|
-
status === 'rejected' ? reason : null,
|
|
547
|
-
]
|
|
548
|
-
);
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* 重命名变更:同步更新 DB + 目录
|
|
554
|
-
* @param {string} cwd - 项目根目录
|
|
555
|
-
* @param {string} oldName - 旧变更名
|
|
556
|
-
* @param {string} newName - 新变更名
|
|
557
|
-
*/
|
|
558
|
-
async renameChange(cwd, oldName, newName) {
|
|
559
|
-
if (!oldName || !newName) {
|
|
560
|
-
console.warn('⚠️ renameChange: 旧名或新名为空,跳过');
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
if (oldName === newName) {
|
|
564
|
-
console.warn('⚠️ renameChange: 新旧名称相同,跳过');
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
const db = await this._ensureDB(cwd);
|
|
568
|
-
// 检查旧名是否存在
|
|
569
|
-
const existing = db.transaction((sqlDb) => {
|
|
570
|
-
const row = sqlDb.exec(`SELECT name, status FROM changes WHERE name = ?`, [oldName]);
|
|
571
|
-
if (!row || !row[0] || row[0].values.length === 0) return null;
|
|
572
|
-
return { name: row[0].values[0][0], status: row[0].values[0][1] };
|
|
573
|
-
});
|
|
574
|
-
if (!existing) {
|
|
575
|
-
console.error(`❌ 变更 ${oldName} 不存在`);
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
// 检查新名是否已存在
|
|
579
|
-
const conflict = db.transaction((sqlDb) => {
|
|
580
|
-
const row = sqlDb.exec(`SELECT name FROM changes WHERE name = ?`, [newName]);
|
|
581
|
-
return row && row[0] && row[0].values.length > 0;
|
|
582
|
-
});
|
|
583
|
-
if (conflict) {
|
|
584
|
-
console.error(`❌ 变更 ${newName} 已存在`);
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
// 重命名目录
|
|
588
|
-
const oldDir = this._changePath(cwd, oldName);
|
|
589
|
-
const newDir = this._changePath(cwd, newName);
|
|
590
|
-
if (existsSync(oldDir)) {
|
|
591
|
-
renameSync(oldDir, newDir);
|
|
592
|
-
} else {
|
|
593
|
-
mkdirSync(newDir, { recursive: true });
|
|
594
|
-
}
|
|
595
|
-
// 更新 DB
|
|
596
|
-
const now = new Date().toISOString();
|
|
597
|
-
db.transaction((sqlDb) => {
|
|
598
|
-
sqlDb.run(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`, [newName, now, oldName]);
|
|
599
|
-
});
|
|
600
|
-
console.log(`✅ 变更已重命名:${oldName} → ${newName}`);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* 从活跃列表移除变更(归档时调用,不物理删除)
|
|
605
|
-
* SQL: UPDATE changes SET status = 'archived'
|
|
606
|
-
*/
|
|
607
|
-
async unregisterChange(cwd, changeName) {
|
|
608
|
-
if (!changeName) {
|
|
609
|
-
console.warn('⚠️ unregisterChange: changeName 为空,跳过');
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
|
-
const db = await this._ensureDB(cwd);
|
|
613
|
-
db.transaction((sqlDb) => {
|
|
614
|
-
const now = new Date().toISOString();
|
|
615
|
-
sqlDb.run(
|
|
616
|
-
`UPDATE changes SET status = 'archived', last_active = ? WHERE name = ?`,
|
|
617
|
-
[now, changeName]
|
|
618
|
-
);
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// ── CLI 命令 ──
|
|
623
|
-
|
|
624
|
-
async init(cwd) {
|
|
625
|
-
this._ensureRuntimeDir(cwd);
|
|
626
|
-
|
|
627
|
-
// 初始化 DB(如不存在则创建文件 + 建表)
|
|
628
|
-
const db = await this._ensureDB(cwd);
|
|
629
|
-
db.transaction((sqlDb) => {
|
|
630
|
-
const now = new Date().toISOString();
|
|
631
|
-
const projectName = basename(cwd) || 'project';
|
|
632
|
-
|
|
633
|
-
// 检查 project id=1 是否已存在
|
|
634
|
-
const existing = sqlDb.exec('SELECT id FROM project WHERE id = 1');
|
|
635
|
-
if (!existing || existing.length === 0 || existing[0].values.length === 0) {
|
|
636
|
-
sqlDb.run(
|
|
637
|
-
`INSERT INTO project (id, name, schema_version, created_at, updated_at)
|
|
638
|
-
VALUES (1, ?, ?, ?, ?)`,
|
|
639
|
-
[projectName, CURRENT_VERSION, now, now]
|
|
640
|
-
);
|
|
641
|
-
console.log(`✅ 已创建全局状态文件(SQLite)`);
|
|
642
|
-
} else {
|
|
643
|
-
console.log(`ℹ️ 全局状态文件已存在,跳过`);
|
|
644
|
-
}
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
// 创建 user-inputs.md
|
|
648
|
-
const inputsPath = this._runtimePath(cwd, 'user-inputs.md');
|
|
649
|
-
if (!existsSync(inputsPath)) {
|
|
650
|
-
writeFileSync(inputsPath, '# 用户输入记录\n\n> 每步完成时由 AI 自动追加,记录用户所有原话。\n\n');
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
this._ensureGitignore(cwd);
|
|
654
|
-
return await this.readGlobal(cwd);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
/**
|
|
658
|
-
* 初始化指定变更的 progress
|
|
659
|
-
* SQL: INSERT changes + 批量 INSERT stages
|
|
660
|
-
*/
|
|
661
|
-
async initChange(cwd, changeName) {
|
|
662
|
-
if (!changeName) {
|
|
663
|
-
console.warn('⚠️ initChange: changeName 为空,跳过');
|
|
664
|
-
return null;
|
|
665
|
-
}
|
|
666
|
-
this._ensureChangeDir(cwd, changeName);
|
|
667
|
-
|
|
668
|
-
const db = await this._ensureDB(cwd);
|
|
669
|
-
db.transaction((sqlDb) => {
|
|
670
|
-
const now = new Date().toISOString();
|
|
671
|
-
|
|
672
|
-
// 检查变更是否已存在
|
|
673
|
-
const existing = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
|
|
674
|
-
if (!existing || existing.length === 0 || existing[0].values.length === 0) {
|
|
675
|
-
// 插入 changes 行
|
|
676
|
-
sqlDb.run(
|
|
677
|
-
`INSERT INTO changes (name, current_stage, status, created_at, last_active)
|
|
678
|
-
VALUES (?, 'scan', 'active', ?, ?)`,
|
|
679
|
-
[changeName, now, now]
|
|
680
|
-
);
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// 获取 change_id
|
|
684
|
-
const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
|
|
685
|
-
const changeId = changeRow[0].values[0][0];
|
|
686
|
-
|
|
687
|
-
// 批量插入 9 个阶段(INSERT OR IGNORE 跳过已存在的)
|
|
688
|
-
const allStages = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore'];
|
|
689
|
-
for (const stage of allStages) {
|
|
690
|
-
sqlDb.run(
|
|
691
|
-
`INSERT OR IGNORE INTO stages (change_id, stage, status)
|
|
692
|
-
VALUES (?, ?, 'pending')`,
|
|
693
|
-
[changeId, stage]
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
// 不再需要写文件:read() 已改为 SQL
|
|
699
|
-
return await this.read(cwd, changeName);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
async setStage(cwd, stage, changeName = null) {
|
|
703
|
-
if (!VALID_STAGES.includes(stage)) {
|
|
704
|
-
console.log(`❌ 未知阶段: ${stage},可选: ${VALID_STAGES.join(', ')}`);
|
|
705
|
-
return;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
const db = await this._ensureDB(cwd);
|
|
709
|
-
const now = new Date().toISOString();
|
|
710
|
-
|
|
711
|
-
// 获取变更名
|
|
712
|
-
let cn = changeName;
|
|
713
|
-
if (!cn) {
|
|
714
|
-
const changes = await this.listChanges(cwd);
|
|
715
|
-
if (changes.length === 1) cn = changes[0];
|
|
716
|
-
if (!cn) { console.log('❌ 无法确定当前变更,请指定 --change <name>'); return; }
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
db.transaction((sqlDb) => {
|
|
720
|
-
// 确保 change 存在
|
|
721
|
-
const changeRow = sqlDb.exec('SELECT id, current_stage FROM changes WHERE name = ?', [cn]);
|
|
722
|
-
if (!changeRow || changeRow.length === 0 || changeRow[0].values.length === 0) return;
|
|
723
|
-
|
|
724
|
-
const changeId = changeRow[0].values[0][0];
|
|
725
|
-
|
|
726
|
-
// UPDATE changes.current_stage + last_active
|
|
727
|
-
sqlDb.run('UPDATE changes SET current_stage = ?, last_active = ? WHERE name = ?', [stage, now, cn]);
|
|
728
|
-
|
|
729
|
-
// 确保 stages 行存在(INSERT OR IGNORE)
|
|
730
|
-
sqlDb.run(
|
|
731
|
-
'INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, "pending")',
|
|
732
|
-
[changeId, stage]
|
|
733
|
-
);
|
|
734
|
-
|
|
735
|
-
// UPDATE stages.status 为 in-progress(仅当仍为 pending 时)
|
|
736
|
-
sqlDb.run(
|
|
737
|
-
"UPDATE stages SET status = 'in-progress', started_at = ? WHERE change_id = ? AND stage = ? AND status = 'pending'",
|
|
738
|
-
[now, changeId, stage]
|
|
739
|
-
);
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
// read() 已改为 SQL,直接通过 SQL 查询即可,无需 _write
|
|
743
|
-
console.log(`✅ 当前阶段已设为: ${STAGE_LABELS[stage] || stage}`);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
async addStep(cwd, stage, stepName, changeName = null) {
|
|
747
|
-
if (!stepName) { console.log('❌ 请指定步骤名称'); return; }
|
|
748
|
-
|
|
749
|
-
const db = await this._ensureDB(cwd);
|
|
750
|
-
|
|
751
|
-
// 获取变更名
|
|
752
|
-
let cn = changeName;
|
|
753
|
-
if (!cn) {
|
|
754
|
-
const changes = await this.listChanges(cwd);
|
|
755
|
-
if (changes.length === 1) cn = changes[0];
|
|
756
|
-
if (!cn) { console.log('❌ 无法确定当前变更,请指定 --change <name>'); return; }
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// 查找 stage_id
|
|
760
|
-
const sqlDb = db.getDb();
|
|
761
|
-
const stageRow = sqlDb.exec(
|
|
762
|
-
'SELECT s.id FROM stages s JOIN changes c ON s.change_id = c.id WHERE c.name = ? AND s.stage = ?',
|
|
763
|
-
[cn, stage]
|
|
764
|
-
);
|
|
765
|
-
if (!stageRow || stageRow.length === 0 || stageRow[0].values.length === 0) {
|
|
766
|
-
// stages 行不存在,静默跳过
|
|
767
|
-
console.log(`ℹ️ 阶段 ${stage} 不存在`);
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
const stageId = stageRow[0].values[0][0];
|
|
771
|
-
|
|
772
|
-
// 重复步骤名检查
|
|
773
|
-
const dupRow = sqlDb.exec('SELECT id FROM steps WHERE stage_id = ? AND name = ?', [stageId, stepName]);
|
|
774
|
-
if (dupRow && dupRow.length > 0 && dupRow[0].values.length > 0) {
|
|
775
|
-
console.log(`ℹ️ 步骤 "${stepName}" 已存在于 ${stage}`);
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
// INSERT INTO steps(ordering 递增)
|
|
780
|
-
db.transaction((tDb) => {
|
|
781
|
-
tDb.run(
|
|
782
|
-
`INSERT INTO steps (stage_id, name, ordering, status)
|
|
783
|
-
VALUES (?, ?, (SELECT COALESCE(MAX(ordering), 0) + 1 FROM steps WHERE stage_id = ?), 'pending')`,
|
|
784
|
-
[stageId, stepName, stageId]
|
|
785
|
-
);
|
|
786
|
-
tDb.run('UPDATE changes SET last_active = ? WHERE name = ?', [new Date().toISOString(), cn]);
|
|
787
|
-
});
|
|
788
|
-
|
|
789
|
-
console.log(`✅ 已添加步骤: ${stage}/${stepName}`);
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
async updateStep(cwd, stage, stepName, options = {}, changeName = null) {
|
|
793
|
-
const { status, output } = options;
|
|
794
|
-
if (!stepName) { console.log('❌ 请指定步骤名称'); return; }
|
|
795
|
-
|
|
796
|
-
const db = await this._ensureDB(cwd);
|
|
797
|
-
|
|
798
|
-
// 获取变更名
|
|
799
|
-
let cn = changeName;
|
|
800
|
-
if (!cn) {
|
|
801
|
-
const changes = await this.listChanges(cwd);
|
|
802
|
-
if (changes.length === 1) cn = changes[0];
|
|
803
|
-
if (!cn) { console.log('❌ 无法确定当前变更,请指定 --change <name>'); return; }
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
// 状态校验
|
|
807
|
-
if (status && !VALID_STATUSES.includes(status)) {
|
|
808
|
-
console.log(`❌ 无效状态: ${status},可选: ${VALID_STATUSES.join(', ')}`);
|
|
809
|
-
return;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
// 查找 step_id:通过 changes → stages → steps JOIN 查询
|
|
813
|
-
const sqlDb = db.getDb();
|
|
814
|
-
const stepRow = sqlDb.exec(
|
|
815
|
-
`SELECT st.id, st.status FROM steps st
|
|
816
|
-
JOIN stages sg ON st.stage_id = sg.id
|
|
817
|
-
JOIN changes c ON sg.change_id = c.id
|
|
818
|
-
WHERE c.name = ? AND sg.stage = ? AND st.name = ?`,
|
|
819
|
-
[cn, stage, stepName]
|
|
820
|
-
);
|
|
821
|
-
if (!stepRow || stepRow.length === 0 || stepRow[0].values.length === 0) {
|
|
822
|
-
console.log(`❌ 步骤不存在: ${stage}/${stepName}`);
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
const stepId = stepRow[0].values[0][0];
|
|
826
|
-
|
|
827
|
-
// UPDATE steps
|
|
828
|
-
db.transaction((tDb) => {
|
|
829
|
-
const now = new Date().toISOString();
|
|
830
|
-
if (status) {
|
|
831
|
-
tDb.run('UPDATE steps SET status = ?, completed_at = ? WHERE id = ? AND name = ?', [status, now, stepId, stepName]);
|
|
832
|
-
}
|
|
833
|
-
if (output !== undefined) {
|
|
834
|
-
tDb.run('UPDATE steps SET output = ? WHERE id = ? AND name = ?', [output, stepId, stepName]);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// 自动完成检测:同 stage_id 下所有 steps 都 completed 时,标记 stage completed
|
|
838
|
-
if (status === 'completed') {
|
|
839
|
-
// 获取 stage_id
|
|
840
|
-
const stRow = tDb.exec('SELECT stage_id FROM steps WHERE id = ?', [stepId]);
|
|
841
|
-
if (stRow && stRow.length > 0 && stRow[0].values.length > 0) {
|
|
842
|
-
const stId = stRow[0].values[0][0];
|
|
843
|
-
const pendingRows = tDb.exec('SELECT COUNT(*) FROM steps WHERE stage_id = ? AND status != "completed"', [stId]);
|
|
844
|
-
if (pendingRows && pendingRows.length > 0 && pendingRows[0].values[0][0] === 0) {
|
|
845
|
-
tDb.run('UPDATE stages SET status = "completed", completed_at = ? WHERE id = ?', [now, stId]);
|
|
846
|
-
console.log(`✅ 阶段 ${stage} 所有步骤已完成,阶段已标记为 completed`);
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
tDb.run('UPDATE changes SET last_active = ? WHERE name = ?', [now, cn]);
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
console.log(`✅ 步骤已更新: ${stage}/${stepName} → ${status || '(仅更新 output)'}`);
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
async completeStage(cwd, stage, changeName = null) {
|
|
858
|
-
if (!VALID_STAGES.includes(stage)) {
|
|
859
|
-
console.log(`❌ 未知阶段: ${stage}`);
|
|
860
|
-
return;
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
const db = await this._ensureDB(cwd);
|
|
864
|
-
const now = new Date().toISOString();
|
|
865
|
-
|
|
866
|
-
// 获取变更名
|
|
867
|
-
let cn = changeName;
|
|
868
|
-
if (!cn) {
|
|
869
|
-
const changes = await this.listChanges(cwd);
|
|
870
|
-
if (changes.length === 1) cn = changes[0];
|
|
871
|
-
if (!cn) { console.log('❌ 无法确定当前变更,请指定 --change <name>'); return; }
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
db.transaction((sqlDb) => {
|
|
875
|
-
const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [cn]);
|
|
876
|
-
if (!changeRow || changeRow.length === 0 || changeRow[0].values.length === 0) return;
|
|
877
|
-
const changeId = changeRow[0].values[0][0];
|
|
878
|
-
|
|
879
|
-
// 确保 stages 行存在(阶段不存在时自动创建)
|
|
880
|
-
sqlDb.run(
|
|
881
|
-
'INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, "pending")',
|
|
882
|
-
[changeId, stage]
|
|
883
|
-
);
|
|
884
|
-
|
|
885
|
-
// UPDATE stages.status=completed + completed_at
|
|
886
|
-
sqlDb.run(
|
|
887
|
-
'UPDATE stages SET status = "completed", completed_at = ? WHERE change_id = ? AND stage = ?',
|
|
888
|
-
[now, changeId, stage]
|
|
889
|
-
);
|
|
890
|
-
|
|
891
|
-
// 将该阶段所有 pending 步骤标记为 completed
|
|
892
|
-
const stageRow = sqlDb.exec('SELECT id FROM stages WHERE change_id = ? AND stage = ?', [changeId, stage]);
|
|
893
|
-
if (stageRow && stageRow.length > 0 && stageRow[0].values.length > 0) {
|
|
894
|
-
const stageId = stageRow[0].values[0][0];
|
|
895
|
-
sqlDb.run(
|
|
896
|
-
'UPDATE steps SET status = "completed", completed_at = ? WHERE stage_id = ? AND status = "pending"',
|
|
897
|
-
[now, stageId]
|
|
898
|
-
);
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// UPDATE changes.last_active
|
|
902
|
-
sqlDb.run('UPDATE changes SET last_active = ? WHERE name = ?', [now, cn]);
|
|
903
|
-
});
|
|
904
|
-
|
|
905
|
-
// 写 history 文件(保持文件系统,不变)
|
|
906
|
-
const data = await this.read(cwd, cn);
|
|
907
|
-
if (data && data.stages && data.stages[stage]) {
|
|
908
|
-
const historyDir = this._runtimePath(cwd, 'history');
|
|
909
|
-
mkdirSync(historyDir, { recursive: true });
|
|
910
|
-
const ts = now.replace(/[:.TZ-]/g, '');
|
|
911
|
-
const stageData = data.stages[stage];
|
|
912
|
-
writeFileSync(
|
|
913
|
-
join(historyDir, `${cn}-${stage}-${ts}.json`),
|
|
914
|
-
JSON.stringify({ change: cn, stage, data: stageData, completedAt: now }, null, 2) + '\n'
|
|
915
|
-
);
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
console.log(`✅ 阶段 ${stage} 已标记为完成(不自动推进,下一步由你决定)`);
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
async show(cwd, changeName = null) {
|
|
922
|
-
// 如果指定了变更名,只显示该变更
|
|
923
|
-
if (changeName) {
|
|
924
|
-
return await this._showChange(cwd, changeName);
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
// 否则显示所有变更
|
|
928
|
-
const changes = await this.listChanges(cwd);
|
|
929
|
-
if (changes.length === 0) {
|
|
930
|
-
console.log('ℹ️ 没有活跃的变更');
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
if (changes.length === 1) {
|
|
935
|
-
return await this._showChange(cwd, changes[0]);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
// 多个变更:汇总显示
|
|
939
|
-
const global = await this.readGlobal(cwd);
|
|
940
|
-
console.log('');
|
|
941
|
-
console.log(' ═══════════════════════════════════════');
|
|
942
|
-
console.log(` 项目: ${(global?.project) || basename(cwd) || '(未命名)'}`);
|
|
943
|
-
console.log(` 活跃变更: ${changes.length} 个`);
|
|
944
|
-
console.log(' ═══════════════════════════════════════');
|
|
945
|
-
console.log('');
|
|
946
|
-
|
|
947
|
-
for (const cn of changes) {
|
|
948
|
-
const data = await this.read(cwd, cn);
|
|
949
|
-
if (!data) {
|
|
950
|
-
console.log(` 📂 ${cn} — (无法读取)`);
|
|
951
|
-
continue;
|
|
952
|
-
}
|
|
953
|
-
const currentStage = data.currentStage || '(无)';
|
|
954
|
-
const stageLabel = STAGE_LABELS[data.currentStage] || currentStage;
|
|
955
|
-
const lastActive = data.lastActive ? this._timeAgo(data.lastActive) : '未知';
|
|
956
|
-
|
|
957
|
-
console.log(` 📂 ${cn}`);
|
|
958
|
-
console.log(` 当前阶段: ${stageLabel} 最近活跃: ${lastActive}`);
|
|
959
|
-
console.log('');
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
console.log(` 💡 查看详情:sillyspec progress show --change <name>`);
|
|
963
|
-
console.log('');
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
async _showChange(cwd, changeName) {
|
|
967
|
-
const data = await this.read(cwd, changeName);
|
|
968
|
-
if (!data) {
|
|
969
|
-
console.log(`❌ 未找到变更 ${changeName}`);
|
|
970
|
-
return;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
console.log('');
|
|
974
|
-
console.log(' ═══════════════════════════════════════');
|
|
975
|
-
console.log(` 变更: ${changeName}`);
|
|
976
|
-
console.log(` 项目: ${data.project || '(未命名)'}`);
|
|
977
|
-
console.log(` 当前阶段: ${STAGE_LABELS[data.currentStage] || data.currentStage || '(无)'}`);
|
|
978
|
-
console.log(` 最近活跃: ${data.lastActive ? this._timeAgo(data.lastActive) : '未知'}`);
|
|
979
|
-
console.log(' ═══════════════════════════════════════');
|
|
980
|
-
console.log('');
|
|
981
|
-
|
|
982
|
-
const statusIcons = { pending: '⬜', 'in-progress': '🔵', completed: '✅', failed: '❌', blocked: '🚫', waiting: '⏸️', revising: '🔧', stale: '⚠️' };
|
|
983
|
-
|
|
984
|
-
for (const stage of VALID_STAGES) {
|
|
985
|
-
const stageData = data.stages[stage] || emptyStage();
|
|
986
|
-
const label = STAGE_LABELS[stage] || stage;
|
|
987
|
-
const icon = statusIcons[stageData.status] || '⬜';
|
|
988
|
-
const isCurrent = data.currentStage === stage ? ' ◀' : '';
|
|
989
|
-
|
|
990
|
-
console.log(` ${icon} ${label}${isCurrent}`);
|
|
991
|
-
|
|
992
|
-
// Show revision info
|
|
993
|
-
if (stageData.revision && stageData.revision > 0) {
|
|
994
|
-
console.log(` 📋 revision: ${stageData.revision}${stageData.reopenedFromStep ? `, from step: ${stageData.reopenedFromStep}` : ''}`);
|
|
995
|
-
}
|
|
996
|
-
if (stageData.staleReason) {
|
|
997
|
-
console.log(` ⚠️ stale: ${stageData.staleReason}`);
|
|
998
|
-
if (stage === 'archive') {
|
|
999
|
-
console.log(` 📁 已有归档文件仍保留在磁盘上,但不再可信。`);
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
if (stageData.steps && stageData.steps.length > 0) {
|
|
1004
|
-
for (const step of stageData.steps) {
|
|
1005
|
-
const si = statusIcons[step.status] || '○';
|
|
1006
|
-
const out = step.output ? ` — ${step.output.slice(0, 60)}` : '';
|
|
1007
|
-
const waitingTag = step.status === 'waiting' ? ' [WAITING]' : ''
|
|
1008
|
-
console.log(` ${si} ${step.name}${out}${waitingTag}`);
|
|
1009
|
-
if (step.status === 'waiting') {
|
|
1010
|
-
if (step.waitReason) console.log(` 原因:${step.waitReason}`);
|
|
1011
|
-
if (step.waitOptions) console.log(` 选项:${(() => { try { const p = JSON.parse(step.waitOptions); return Array.isArray(p) ? p.join(', ') : step.waitOptions; } catch { return step.waitOptions; }})()}`);
|
|
1012
|
-
if (step.waitedAt) console.log(` 等待时间:${step.waitedAt}`);
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
if (stageData.startedAt) {
|
|
1018
|
-
console.log(` 开始: ${new Date(stageData.startedAt).toLocaleString('zh-CN')}`);
|
|
1019
|
-
}
|
|
1020
|
-
if (stageData.completedAt) {
|
|
1021
|
-
console.log(` 完成: ${new Date(stageData.completedAt).toLocaleString('zh-CN')}`);
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// 批量进度
|
|
1026
|
-
if (data.batchProgress) {
|
|
1027
|
-
const batchLine = this._renderBatchProgress(data.batchProgress);
|
|
1028
|
-
if (batchLine) {
|
|
1029
|
-
console.log('');
|
|
1030
|
-
console.log(` ${batchLine}`);
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
// ── Next 建议 ──
|
|
1035
|
-
const suggestion = this._getNextSuggestion(data);
|
|
1036
|
-
if (suggestion) {
|
|
1037
|
-
console.log('');
|
|
1038
|
-
console.log(` 💡 ${suggestion.text}`);
|
|
1039
|
-
if (suggestion.command) console.log(` ${suggestion.command}`);
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
console.log('');
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
/**
|
|
1046
|
-
* 根据当前状态给出下一步建议
|
|
1047
|
-
* @param {object} data - progress data
|
|
1048
|
-
* @returns {{ text: string, command?: string }|null}
|
|
1049
|
-
*/
|
|
1050
|
-
_getNextSuggestion(data) {
|
|
1051
|
-
// 找到第一个 revising 阶段
|
|
1052
|
-
const revisingStage = STAGE_ORDER.find(s => data.stages[s]?.status === 'revising');
|
|
1053
|
-
if (revisingStage) {
|
|
1054
|
-
const sd = data.stages[revisingStage];
|
|
1055
|
-
return {
|
|
1056
|
-
text: `${STAGE_LABELS[revisingStage] || revisingStage} 正在修订中(revision ${sd.revision || 1}),请继续完成修订。`,
|
|
1057
|
-
command: `sillyspec run ${revisingStage}`,
|
|
1058
|
-
};
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
// 找到第一个 stale 阶段(上游已修,下游需要重建)
|
|
1062
|
-
const staleStage = STAGE_ORDER.find(s => data.stages[s]?.status === 'stale');
|
|
1063
|
-
if (staleStage) {
|
|
1064
|
-
const sd = data.stages[staleStage];
|
|
1065
|
-
return {
|
|
1066
|
-
text: `${STAGE_LABELS[staleStage] || staleStage} 已失效(${sd.staleReason || '上游修订'}),需要从第一步重建。`,
|
|
1067
|
-
command: `sillyspec run ${staleStage} --reopen --from-step 1`,
|
|
1068
|
-
};
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
// 找到第一个有 pending/waiting/failed 步骤的 in-progress 阶段
|
|
1072
|
-
for (const s of STAGE_ORDER) {
|
|
1073
|
-
const sd = data.stages[s];
|
|
1074
|
-
if (!sd) continue;
|
|
1075
|
-
if (sd.status === 'in-progress' && sd.steps) {
|
|
1076
|
-
const hasPending = sd.steps.some(st => ['pending', 'waiting', 'failed'].includes(st.status));
|
|
1077
|
-
if (hasPending) {
|
|
1078
|
-
return {
|
|
1079
|
-
text: `${STAGE_LABELS[s] || s} 进行中,继续执行下一步。`,
|
|
1080
|
-
command: `sillyspec run ${s}`,
|
|
1081
|
-
};
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
// 找到第一个 pending 主流程阶段
|
|
1087
|
-
for (const s of STAGE_ORDER) {
|
|
1088
|
-
const sd = data.stages[s];
|
|
1089
|
-
if (sd && sd.status === 'pending' && sd.steps && sd.steps.length > 0) {
|
|
1090
|
-
// 检查上游是否都 completed
|
|
1091
|
-
const idx = STAGE_ORDER.indexOf(s);
|
|
1092
|
-
const upstream = STAGE_ORDER.slice(0, idx);
|
|
1093
|
-
const upstreamOk = upstream.every(us =>
|
|
1094
|
-
data.stages[us]?.status === 'completed' || !data.stages[us] || data.stages[us].status === 'pending'
|
|
1095
|
-
);
|
|
1096
|
-
if (upstreamOk) {
|
|
1097
|
-
return {
|
|
1098
|
-
text: `可以开始 ${STAGE_LABELS[s] || s}。`,
|
|
1099
|
-
command: `sillyspec run ${s}`,
|
|
1100
|
-
};
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
return null;
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
async status(cwd, changeName = null) {
|
|
1109
|
-
await this.show(cwd, changeName);
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
/**
|
|
1113
|
-
* Revision v1 状态一致性检查
|
|
1114
|
-
* 只报告,不自动修复。
|
|
1115
|
-
* @param {string} cwd
|
|
1116
|
-
* @param {string|null} changeName
|
|
1117
|
-
* @returns {{ ok: boolean, issues: string[], warnings: string[] }}
|
|
1118
|
-
*/
|
|
1119
|
-
async checkConsistency(cwd, changeName = null) {
|
|
1120
|
-
const data = await this.read(cwd, changeName);
|
|
1121
|
-
if (!data) {
|
|
1122
|
-
return { ok: false, issues: ['无法读取进度数据'], warnings: [] };
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
const issues = [];
|
|
1126
|
-
const warnings = [];
|
|
1127
|
-
|
|
1128
|
-
for (const stageName of STAGE_ORDER) {
|
|
1129
|
-
const sd = data.stages[stageName];
|
|
1130
|
-
if (!sd) continue;
|
|
1131
|
-
|
|
1132
|
-
// a. completed stage 不能有 pending/stale steps
|
|
1133
|
-
if (sd.status === 'completed' && sd.steps) {
|
|
1134
|
-
const badSteps = sd.steps.filter(s => ['pending', 'stale', 'in-progress'].includes(s.status));
|
|
1135
|
-
for (const step of badSteps) {
|
|
1136
|
-
issues.push(`${stageName}/${step.name}: step 状态为 ${step.status},但 stage 状态为 completed`);
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
// b. revising stage 应有 revision > 0 或 reopenedFromStep
|
|
1141
|
-
if (sd.status === 'revising') {
|
|
1142
|
-
if (!sd.revision || sd.revision < 1) {
|
|
1143
|
-
issues.push(`${stageName}: 状态为 revising 但 revision 缺失或为 0`);
|
|
1144
|
-
}
|
|
1145
|
-
if (!sd.reopenedFromStep) {
|
|
1146
|
-
warnings.push(`${stageName}: 状态为 revising 但未记录 reopenedFromStep`);
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
// c. stale stage 应有 staleReason
|
|
1151
|
-
if (sd.status === 'stale') {
|
|
1152
|
-
if (!sd.staleReason) {
|
|
1153
|
-
warnings.push(`${stageName}: 状态为 stale 但缺少 staleReason`);
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
// d. 下游 completed 不能出现在上游 stale/revising 之后
|
|
1158
|
-
const stageIdx = STAGE_ORDER.indexOf(stageName);
|
|
1159
|
-
for (let i = 0; i < stageIdx; i++) {
|
|
1160
|
-
const upstream = STAGE_ORDER[i];
|
|
1161
|
-
const upData = data.stages[upstream];
|
|
1162
|
-
if (upData && (upData.status === 'stale' || upData.status === 'revising')) {
|
|
1163
|
-
if (sd.status === 'completed') {
|
|
1164
|
-
issues.push(`${stageName}: 状态为 completed,但上游 ${upstream} 状态为 ${upData.status}(下游不应在上游修订/失效时保持 completed)`);
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
// e. step stale 时 stage 不应是 completed
|
|
1170
|
-
if (sd.status === 'completed' && sd.steps) {
|
|
1171
|
-
const staleSteps = sd.steps.filter(s => s.status === 'stale');
|
|
1172
|
-
for (const step of staleSteps) {
|
|
1173
|
-
issues.push(`${stageName}/${step.name}: step 状态为 stale,但 stage 状态为 completed`);
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
// 输出报告
|
|
1179
|
-
console.log('');
|
|
1180
|
-
console.log(' ═══════════════════════════════════════');
|
|
1181
|
-
console.log(' 状态一致性检查');
|
|
1182
|
-
console.log(' ═══════════════════════════════════════');
|
|
1183
|
-
|
|
1184
|
-
if (issues.length === 0 && warnings.length === 0) {
|
|
1185
|
-
console.log(' ✅ 未发现一致性问题');
|
|
1186
|
-
} else {
|
|
1187
|
-
if (issues.length > 0) {
|
|
1188
|
-
console.log(`\n ❌ 问题 (${issues.length}):`);
|
|
1189
|
-
for (const issue of issues) console.log(` - ${issue}`);
|
|
1190
|
-
}
|
|
1191
|
-
if (warnings.length > 0) {
|
|
1192
|
-
console.log(`\n ⚠️ 警告 (${warnings.length}):`);
|
|
1193
|
-
for (const w of warnings) console.log(` - ${w}`);
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
console.log('');
|
|
1197
|
-
|
|
1198
|
-
return { ok: issues.length === 0, issues, warnings };
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
/**
|
|
1202
|
-
* Revision v1.2 状态修复
|
|
1203
|
-
* 默认 dry-run,--apply 才真正修改 DB。
|
|
1204
|
-
* 只修安全项,不碰产物文件、不 reset/reopen stage。
|
|
1205
|
-
*
|
|
1206
|
-
* @param {string} cwd
|
|
1207
|
-
* @param {object} opts
|
|
1208
|
-
* @param {boolean} [opts.apply=false]
|
|
1209
|
-
* @param {string|null} [opts.changeName]
|
|
1210
|
-
* @returns {{ fixable: object[], manual: string[], applied: object[] }}
|
|
1211
|
-
*/
|
|
1212
|
-
async repairConsistency(cwd, opts = {}) {
|
|
1213
|
-
const { apply = false, changeName = null } = opts;
|
|
1214
|
-
|
|
1215
|
-
const data = await this.read(cwd, changeName);
|
|
1216
|
-
if (!data) {
|
|
1217
|
-
console.log('❌ 无法读取进度数据');
|
|
1218
|
-
return { fixable: [], manual: ['无法读取进度数据'], applied: [] };
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
const fixable = []; // { stage, action, description, apply: (data) => void }
|
|
1222
|
-
const manual = []; // string
|
|
1223
|
-
|
|
1224
|
-
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
|
1225
|
-
|
|
1226
|
-
for (const stageName of STAGE_ORDER) {
|
|
1227
|
-
const sd = data.stages[stageName];
|
|
1228
|
-
if (!sd) continue;
|
|
1229
|
-
|
|
1230
|
-
// Fix a: stale stage 缺 staleReason → 补默认原因
|
|
1231
|
-
if (sd.status === 'stale' && !sd.staleReason) {
|
|
1232
|
-
const reason = stageName === 'archive'
|
|
1233
|
-
? 'upstream stage revised; existing archive artifacts are preserved but no longer trusted'
|
|
1234
|
-
: 'unknown upstream revision';
|
|
1235
|
-
fixable.push({
|
|
1236
|
-
stage: stageName,
|
|
1237
|
-
action: 'set_stale_reason',
|
|
1238
|
-
description: `${stageName}: stale 缺 staleReason → 补 "${reason}"`,
|
|
1239
|
-
apply: (d) => { d.stages[stageName].staleReason = reason; },
|
|
1240
|
-
});
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
// Fix b: 上游 stale/revising,下游仍 completed → cascade stale
|
|
1244
|
-
const stageIdx = STAGE_ORDER.indexOf(stageName);
|
|
1245
|
-
for (let i = 0; i < stageIdx; i++) {
|
|
1246
|
-
const upstream = STAGE_ORDER[i];
|
|
1247
|
-
const upData = data.stages[upstream];
|
|
1248
|
-
if (upData && (upData.status === 'stale' || upData.status === 'revising')) {
|
|
1249
|
-
if (sd.status === 'completed') {
|
|
1250
|
-
const upStatus = upData.status;
|
|
1251
|
-
const reason = `upstream ${upstream} is ${upStatus}`;
|
|
1252
|
-
fixable.push({
|
|
1253
|
-
stage: stageName,
|
|
1254
|
-
action: 'cascade_stale',
|
|
1255
|
-
description: `${stageName}: completed → stale(上游 ${upstream} 为 ${upStatus})`,
|
|
1256
|
-
apply: (d) => {
|
|
1257
|
-
d.stages[stageName].status = 'stale';
|
|
1258
|
-
d.stages[stageName].staleReason = reason;
|
|
1259
|
-
d.stages[stageName].completedAt = null;
|
|
1260
|
-
},
|
|
1261
|
-
});
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
// Fix c: archive stale 缺 staleReason(专用文案)
|
|
1267
|
-
if (stageName === 'archive' && sd.status === 'stale' && !sd.staleReason) {
|
|
1268
|
-
// 已在 Fix a 中处理,这里不重复
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
// Fix d: revising stage 缺 reopenedAt → 补当前时间
|
|
1272
|
-
if (sd.status === 'revising' && !sd.reopenedAt) {
|
|
1273
|
-
fixable.push({
|
|
1274
|
-
stage: stageName,
|
|
1275
|
-
action: 'set_reopened_at',
|
|
1276
|
-
description: `${stageName}: revising 缺 reopenedAt → 补当前时间`,
|
|
1277
|
-
apply: (d) => { d.stages[stageName].reopenedAt = now; },
|
|
1278
|
-
});
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
// Manual a: completed stage 里有 pending/stale/in-progress steps
|
|
1282
|
-
if (sd.status === 'completed' && sd.steps) {
|
|
1283
|
-
const badSteps = sd.steps.filter(s => ['pending', 'stale', 'in-progress'].includes(s.status));
|
|
1284
|
-
for (const step of badSteps) {
|
|
1285
|
-
manual.push(`${stageName}/${step.name}: step 状态为 ${step.status},但 stage 状态为 completed(需手动确认)`);
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
// Manual b: revising stage 缺 reopenedFromStep
|
|
1290
|
-
if (sd.status === 'revising' && !sd.reopenedFromStep) {
|
|
1291
|
-
manual.push(`${stageName}: revising 缺 reopenedFromStep(需手动确认修订起始步骤)`);
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
// Manual c: steps 为空但 stage completed
|
|
1295
|
-
if (sd.status === 'completed' && (!sd.steps || sd.steps.length === 0)) {
|
|
1296
|
-
manual.push(`${stageName}: completed 但 steps 为空(需手动确认)`);
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
// 输出报告
|
|
1301
|
-
console.log('');
|
|
1302
|
-
console.log(' ═══════════════════════════════════════');
|
|
1303
|
-
console.log(` 状态修复 ${apply ? '(--apply 模式)' : '(dry-run 模式)'}`);
|
|
1304
|
-
console.log(' ═══════════════════════════════════════');
|
|
1305
|
-
|
|
1306
|
-
if (fixable.length === 0 && manual.length === 0) {
|
|
1307
|
-
console.log(' ✅ 未发现问题,无需修复');
|
|
1308
|
-
console.log('');
|
|
1309
|
-
return { fixable: [], manual: [], applied: [] };
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
const applied = [];
|
|
1313
|
-
|
|
1314
|
-
if (fixable.length > 0) {
|
|
1315
|
-
console.log(`\n 🔧 可自动修复 (${fixable.length}):`);
|
|
1316
|
-
for (const item of fixable) {
|
|
1317
|
-
console.log(` - ${item.description}`);
|
|
1318
|
-
if (apply) {
|
|
1319
|
-
item.apply(data);
|
|
1320
|
-
applied.push({ stage: item.stage, action: item.action });
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
if (!apply) {
|
|
1324
|
-
console.log('\n 💡 使用 --apply 执行修复');
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
if (manual.length > 0) {
|
|
1329
|
-
console.log(`\n 👆 需手动处理 (${manual.length}):`);
|
|
1330
|
-
for (const m of manual) console.log(` - ${m}`);
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
if (apply && applied.length > 0) {
|
|
1334
|
-
data.lastActive = now;
|
|
1335
|
-
await this._write(cwd, data, changeName);
|
|
1336
|
-
console.log(`\n ✅ 已修复 ${applied.length} 项`);
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
console.log('');
|
|
1340
|
-
|
|
1341
|
-
return { fixable, manual, applied };
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
async validate(cwd, changeName = null) {
|
|
1345
|
-
const data = await this.read(cwd, changeName);
|
|
1346
|
-
if (!data) { console.log('❌ 无法读取进度数据'); return false; }
|
|
1347
|
-
|
|
1348
|
-
const errors = [];
|
|
1349
|
-
if (!data._version || !Number.isInteger(data._version) || data._version < 1) {
|
|
1350
|
-
errors.push(`_version 缺失或无效(期望正整数,实际为 ${JSON.stringify(data._version)})`);
|
|
1351
|
-
}
|
|
1352
|
-
if (!data.stages || typeof data.stages !== 'object') errors.push('缺少 stages');
|
|
1353
|
-
if (!VALID_STAGES.every(s => data.stages[s])) errors.push('缺少阶段定义');
|
|
1354
|
-
|
|
1355
|
-
if (errors.length === 0) { console.log('✅ 进度数据格式正确'); return true; }
|
|
1356
|
-
|
|
1357
|
-
console.log(`⚠️ 发现问题,尝试修复...`);
|
|
1358
|
-
let fixed = { ...data, stages: { ...data.stages } };
|
|
1359
|
-
let changed = false;
|
|
1360
|
-
if (!fixed.project) {
|
|
1361
|
-
fixed.project = basename(cwd);
|
|
1362
|
-
changed = true;
|
|
1363
|
-
}
|
|
1364
|
-
if (!fixed._version || !Number.isInteger(fixed._version) || fixed._version < 1) {
|
|
1365
|
-
fixed._version = CURRENT_VERSION;
|
|
1366
|
-
changed = true;
|
|
1367
|
-
}
|
|
1368
|
-
for (const s of VALID_STAGES) {
|
|
1369
|
-
if (!fixed.stages[s]) { fixed.stages[s] = emptyStage(); changed = true; }
|
|
1370
|
-
}
|
|
1371
|
-
if (changed) {
|
|
1372
|
-
await this._write(cwd, fixed);
|
|
1373
|
-
console.log('✅ 已修复');
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
return true;
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
/**
|
|
1380
|
-
* 重新打开已完成的阶段进入修订模式
|
|
1381
|
-
* - 不带 fromStep:只允许存在 pending/stale/waiting/failed 步骤时继续
|
|
1382
|
-
* - 带 fromStep:从该步骤起,当前及后续步骤标记 stale/pending
|
|
1383
|
-
* - 自动级联标记下游阶段为 stale
|
|
1384
|
-
*
|
|
1385
|
-
* @param {string} cwd
|
|
1386
|
-
* @param {string} stage - 要重开的阶段
|
|
1387
|
-
* @param {object} opts
|
|
1388
|
-
* @param {string|number} [opts.fromStep] - 步骤名或序号(1-based)
|
|
1389
|
-
* @param {string} [opts.changeName]
|
|
1390
|
-
* @returns {{ ok: boolean, error?: string }}
|
|
1391
|
-
*/
|
|
1392
|
-
async reopenStage(cwd, stage, opts = {}) {
|
|
1393
|
-
const { fromStep, changeName = null } = opts;
|
|
1394
|
-
|
|
1395
|
-
const data = await this.read(cwd, changeName);
|
|
1396
|
-
if (!data) return { ok: false, error: '无法读取进度数据' };
|
|
1397
|
-
|
|
1398
|
-
const stageData = data.stages[stage];
|
|
1399
|
-
if (!stageData) return { ok: false, error: `未知阶段: ${stage}` };
|
|
1400
|
-
|
|
1401
|
-
const steps = stageData.steps || [];
|
|
1402
|
-
|
|
1403
|
-
// 确定 fromStep 对应的 index
|
|
1404
|
-
let fromIdx = null;
|
|
1405
|
-
if (fromStep != null) {
|
|
1406
|
-
if (typeof fromStep === 'number' || /^\d+$/.test(String(fromStep))) {
|
|
1407
|
-
fromIdx = parseInt(String(fromStep), 10) - 1; // 1-based → 0-based
|
|
1408
|
-
if (fromIdx < 0 || fromIdx >= steps.length) {
|
|
1409
|
-
return { ok: false, error: `步骤序号超出范围: ${fromStep}(共 ${steps.length} 步)` };
|
|
1410
|
-
}
|
|
1411
|
-
} else {
|
|
1412
|
-
// 按名称匹配
|
|
1413
|
-
fromIdx = steps.findIndex(s => s.name === fromStep);
|
|
1414
|
-
if (fromIdx === -1) {
|
|
1415
|
-
return { ok: false, error: `步骤不存在: ${fromStep}` };
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
// 如果不带 fromStep,检查是否存在中断步骤
|
|
1421
|
-
if (fromIdx === null) {
|
|
1422
|
-
const hasInterrupted = steps.some(s =>
|
|
1423
|
-
['pending', 'stale', 'waiting', 'failed'].includes(s.status)
|
|
1424
|
-
);
|
|
1425
|
-
if (!hasInterrupted) {
|
|
1426
|
-
return { ok: false, error: `阶段 ${stage} 所有步骤均已完成,请使用 --from-step 指定从哪一步开始修订` };
|
|
1427
|
-
}
|
|
1428
|
-
// 找到第一个中断步骤
|
|
1429
|
-
fromIdx = steps.findIndex(s =>
|
|
1430
|
-
['pending', 'stale', 'waiting', 'failed'].includes(s.status)
|
|
1431
|
-
);
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
// 执行重开操作
|
|
1435
|
-
const newRevision = (stageData.revision || 0) + 1;
|
|
1436
|
-
const fromStepName = steps[fromIdx].name;
|
|
1437
|
-
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
|
1438
|
-
|
|
1439
|
-
// 更新步骤状态:fromStep 之前的保持 completed,fromStep 变 pending,之后的变 stale
|
|
1440
|
-
for (let i = 0; i < steps.length; i++) {
|
|
1441
|
-
if (i === fromIdx) {
|
|
1442
|
-
steps[i].status = 'pending';
|
|
1443
|
-
steps[i].completedAt = null;
|
|
1444
|
-
steps[i].output = null;
|
|
1445
|
-
} else if (i > fromIdx) {
|
|
1446
|
-
steps[i].status = 'stale';
|
|
1447
|
-
steps[i].completedAt = null;
|
|
1448
|
-
}
|
|
1449
|
-
// i < fromIdx: 保持原状(completed)
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
stageData.status = 'revising';
|
|
1453
|
-
stageData.completedAt = null;
|
|
1454
|
-
stageData.revision = newRevision;
|
|
1455
|
-
stageData.reopenedFromStep = `${fromIdx + 1}: ${fromStepName}`; // 存 "index: name" 格式
|
|
1456
|
-
stageData.reopenedAt = now;
|
|
1457
|
-
stageData.steps = steps;
|
|
1458
|
-
|
|
1459
|
-
data.lastActive = now;
|
|
1460
|
-
data.currentStage = stage;
|
|
1461
|
-
|
|
1462
|
-
await this._write(cwd, data, changeName);
|
|
1463
|
-
|
|
1464
|
-
// 级联标记下游阶段为 stale
|
|
1465
|
-
const downstreamStages = this._getDownstreamStages(stage);
|
|
1466
|
-
if (downstreamStages.length > 0) {
|
|
1467
|
-
const data2 = await this.read(cwd, changeName); // 重新读取以获取最新状态
|
|
1468
|
-
if (data2) {
|
|
1469
|
-
for (const ds of downstreamStages) {
|
|
1470
|
-
if (data2.stages[ds] && data2.stages[ds].status === 'completed') {
|
|
1471
|
-
data2.stages[ds].status = 'stale';
|
|
1472
|
-
data2.stages[ds].staleReason = `上游阶段 ${stage} 已修订 (revision ${newRevision})`;
|
|
1473
|
-
data2.stages[ds].completedAt = null;
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
await this._write(cwd, data2, changeName);
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
return { ok: true, revision: newRevision, fromStep: fromStepName };
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
/**
|
|
1484
|
-
* 获取指定阶段的下游主流程阶段列表
|
|
1485
|
-
* @param {string} stage
|
|
1486
|
-
* @returns {string[]}
|
|
1487
|
-
*/
|
|
1488
|
-
_getDownstreamStages(stage) {
|
|
1489
|
-
const idx = MAIN_FLOW_ORDER.indexOf(stage);
|
|
1490
|
-
if (idx === -1) return [];
|
|
1491
|
-
return MAIN_FLOW_ORDER.slice(idx + 1);
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
async reset(cwd, stage, changeName = null) {
|
|
1495
|
-
if (stage) {
|
|
1496
|
-
const data = await this.read(cwd, changeName);
|
|
1497
|
-
if (!data) { console.log('❌ 无法读取进度数据'); return; }
|
|
1498
|
-
if (!data.stages[stage]) { console.log(`❌ 未知阶段: ${stage}`); return; }
|
|
1499
|
-
data.stages[stage] = emptyStage();
|
|
1500
|
-
data.lastActive = new Date().toLocaleString('zh-CN',{hour12:false});
|
|
1501
|
-
await this._write(cwd, data);
|
|
1502
|
-
console.log(`✅ 已重置阶段: ${stage}`);
|
|
1503
|
-
} else {
|
|
1504
|
-
// 重置所有变更或指定变更
|
|
1505
|
-
if (changeName) {
|
|
1506
|
-
// SQL: 删除该变更的所有 stages 和 steps 数据
|
|
1507
|
-
const db = await this._ensureDB(cwd);
|
|
1508
|
-
db.transaction((sqlDb) => {
|
|
1509
|
-
const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
|
|
1510
|
-
if (changeRow && changeRow.length > 0 && changeRow[0].values.length > 0) {
|
|
1511
|
-
const changeId = changeRow[0].values[0][0];
|
|
1512
|
-
sqlDb.run('DELETE FROM steps WHERE stage_id IN (SELECT id FROM stages WHERE change_id = ?)', [changeId]);
|
|
1513
|
-
sqlDb.run('DELETE FROM stages WHERE change_id = ?', [changeId]);
|
|
1514
|
-
sqlDb.run('UPDATE stages SET status = "pending", started_at = NULL, completed_at = NULL WHERE change_id = ?', [changeId]);
|
|
1515
|
-
// 重新插入所有阶段
|
|
1516
|
-
for (const s of VALID_STAGES) {
|
|
1517
|
-
sqlDb.run('INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, "pending")', [changeId, s]);
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
});
|
|
1521
|
-
console.log(`✅ 已重置变更 ${changeName} 的进度`);
|
|
1522
|
-
} else {
|
|
1523
|
-
const changes = await this.listChanges(cwd);
|
|
1524
|
-
const db = await this._ensureDB(cwd);
|
|
1525
|
-
db.transaction((sqlDb) => {
|
|
1526
|
-
for (const cn of changes) {
|
|
1527
|
-
const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [cn]);
|
|
1528
|
-
if (changeRow && changeRow.length > 0 && changeRow[0].values.length > 0) {
|
|
1529
|
-
const changeId = changeRow[0].values[0][0];
|
|
1530
|
-
sqlDb.run('DELETE FROM steps WHERE stage_id IN (SELECT id FROM stages WHERE change_id = ?)', [changeId]);
|
|
1531
|
-
sqlDb.run('UPDATE stages SET status = "pending", started_at = NULL, completed_at = NULL WHERE change_id = ?', [changeId]);
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
});
|
|
1535
|
-
console.log('✅ 已重置所有变更的进度');
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
// ── 内部辅助 ──
|
|
1541
|
-
|
|
1542
|
-
async _readOrInit(cwd, changeName = null) {
|
|
1543
|
-
let data = await this.read(cwd, changeName);
|
|
1544
|
-
if (!data) {
|
|
1545
|
-
// 尝试自动检测变更名
|
|
1546
|
-
if (!changeName) {
|
|
1547
|
-
const changes = await this.listChanges(cwd);
|
|
1548
|
-
if (changes.length === 1) changeName = changes[0];
|
|
1549
|
-
}
|
|
1550
|
-
if (changeName) {
|
|
1551
|
-
// 确保变更在 DB 中已初始化
|
|
1552
|
-
const db = await this._ensureDB(cwd);
|
|
1553
|
-
db.transaction((sqlDb) => {
|
|
1554
|
-
const now = new Date().toISOString();
|
|
1555
|
-
sqlDb.run(
|
|
1556
|
-
'INSERT OR IGNORE INTO changes (name, current_stage, status, created_at, last_active) VALUES (?, "scan", "active", ?, ?)',
|
|
1557
|
-
[changeName, now, now]
|
|
1558
|
-
);
|
|
1559
|
-
const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
|
|
1560
|
-
if (changeRow && changeRow.length > 0 && changeRow[0].values.length > 0) {
|
|
1561
|
-
const changeId = changeRow[0].values[0][0];
|
|
1562
|
-
for (const s of VALID_STAGES) {
|
|
1563
|
-
sqlDb.run('INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, "pending")', [changeId, s]);
|
|
1564
|
-
}
|
|
1565
|
-
}
|
|
1566
|
-
});
|
|
1567
|
-
await this.registerChange(cwd, changeName);
|
|
1568
|
-
}
|
|
1569
|
-
if (!data) {
|
|
1570
|
-
data = await this.read(cwd, changeName);
|
|
1571
|
-
}
|
|
1572
|
-
if (!data) {
|
|
1573
|
-
console.log('❌ 无法确定当前变更,请指定 --change <name>');
|
|
1574
|
-
return null;
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
return data;
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
async _requireStage(cwd, stage, changeName = null) {
|
|
1581
|
-
if (!VALID_STAGES.includes(stage)) {
|
|
1582
|
-
console.log(`❌ 未知阶段: ${stage},可选: ${VALID_STAGES.join(', ')}`);
|
|
1583
|
-
return null;
|
|
1584
|
-
}
|
|
1585
|
-
const data = await this._readOrInit(cwd, changeName);
|
|
1586
|
-
if (!data) return null;
|
|
1587
|
-
if (!data.stages[stage]) data.stages[stage] = emptyStage();
|
|
1588
|
-
return data;
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
_timeAgo(dateStr) {
|
|
1592
|
-
if (!dateStr) return '未知';
|
|
1593
|
-
let ts = Date.parse(dateStr);
|
|
1594
|
-
if (isNaN(ts)) {
|
|
1595
|
-
const m = dateStr.match(/(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})[\s,]+(\d{1,2}):(\d{2})(?::(\d{2}))?/);
|
|
1596
|
-
if (m) ts = new Date(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +(m[6]||0)).getTime();
|
|
1597
|
-
}
|
|
1598
|
-
if (isNaN(ts)) return dateStr;
|
|
1599
|
-
const diff = Date.now() - ts;
|
|
1600
|
-
const minutes = Math.floor(diff / 60000);
|
|
1601
|
-
if (minutes < 1) return '刚刚';
|
|
1602
|
-
if (minutes < 60) return `${minutes} 分钟前`;
|
|
1603
|
-
const hours = Math.floor(minutes / 60);
|
|
1604
|
-
if (hours < 24) return `${hours} 小时前`;
|
|
1605
|
-
return `${Math.floor(hours / 24)} 天前`;
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
// ── 批量进度 ──
|
|
1609
|
-
|
|
1610
|
-
async updateBatchProgress(cwd, batchData, changeName = null) {
|
|
1611
|
-
const cn = changeName || null;
|
|
1612
|
-
|
|
1613
|
-
const db = await this._ensureDB(cwd);
|
|
1614
|
-
db.transaction((sqlDb) => {
|
|
1615
|
-
// 获取 change_id
|
|
1616
|
-
let changeId = null;
|
|
1617
|
-
if (cn) {
|
|
1618
|
-
const row = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [cn]);
|
|
1619
|
-
if (row && row.length > 0 && row[0].values.length > 0) changeId = row[0].values[0][0];
|
|
1620
|
-
}
|
|
1621
|
-
if (!changeId) {
|
|
1622
|
-
// 尝试从唯一活跃变更获取
|
|
1623
|
-
const rows = sqlDb.exec("SELECT id FROM changes WHERE status = 'active'");
|
|
1624
|
-
if (rows && rows.length > 0 && rows[0].values.length === 1) changeId = rows[0].values[0][0];
|
|
1625
|
-
}
|
|
1626
|
-
if (!changeId) return;
|
|
1627
|
-
|
|
1628
|
-
sqlDb.run(
|
|
1629
|
-
`INSERT INTO batch_progress (change_id, total, completed, failed, skipped)
|
|
1630
|
-
VALUES (?, ?, ?, ?, ?)
|
|
1631
|
-
ON CONFLICT(change_id) DO UPDATE SET
|
|
1632
|
-
total = excluded.total,
|
|
1633
|
-
completed = excluded.completed,
|
|
1634
|
-
failed = excluded.failed,
|
|
1635
|
-
skipped = excluded.skipped`,
|
|
1636
|
-
[changeId, batchData.total || 0, batchData.completed || 0, batchData.failed || 0, batchData.skipped || 0]
|
|
1637
|
-
);
|
|
1638
|
-
});
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
async readBatchProgress(cwd, changeName = null) {
|
|
1642
|
-
const data = await this.read(cwd, changeName);
|
|
1643
|
-
return data?.batchProgress || null;
|
|
1644
|
-
}
|
|
1645
|
-
|
|
1646
|
-
_renderBatchProgress(batchProgress) {
|
|
1647
|
-
if (!batchProgress || !batchProgress.total) return null;
|
|
1648
|
-
const { total, completed = 0, failed = 0, skipped = 0 } = batchProgress;
|
|
1649
|
-
const barLen = 20;
|
|
1650
|
-
const filled = Math.round((completed / total) * barLen);
|
|
1651
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barLen - filled);
|
|
1652
|
-
const parts = [];
|
|
1653
|
-
if (failed > 0) parts.push(`${failed} 失败`);
|
|
1654
|
-
if (skipped > 0) parts.push(`${skipped} 跳过`);
|
|
1655
|
-
const suffix = parts.length ? ` (${parts.join(', ')})` : '';
|
|
1656
|
-
return `📊 批量进度: ${bar} ${completed}/${total}${suffix}`;
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
/**
|
|
1660
|
-
* 更新 gate-status.json,供 worktree-guard hook 读取
|
|
1661
|
-
* 从 SQLite 查询所有处于 execute/quick 阶段的活跃变更,生成或删除 gate-status.json
|
|
1662
|
-
*/
|
|
1663
|
-
async _updateGateStatus(cwd) {
|
|
1664
|
-
const db = await this._ensureDB(cwd);
|
|
1665
|
-
const sqlDb = db.getDb();
|
|
1666
|
-
|
|
1667
|
-
// SQL 查询:所有处于 execute/quick 阶段的活跃变更
|
|
1668
|
-
const rows = sqlDb.exec(
|
|
1669
|
-
`SELECT name, current_stage, no_worktree FROM changes
|
|
1670
|
-
WHERE status = 'active' AND current_stage IN ('execute', 'quick')`
|
|
1671
|
-
);
|
|
1672
|
-
|
|
1673
|
-
const gatePath = this._runtimePath(cwd, 'gate-status.json');
|
|
1674
|
-
|
|
1675
|
-
if (!rows || rows.length === 0 || rows[0].values.length === 0) {
|
|
1676
|
-
// 无 execute/quick 阶段的活跃变更,删除 gate-status
|
|
1677
|
-
if (existsSync(gatePath)) {
|
|
1678
|
-
try { unlinkSync(gatePath); } catch {}
|
|
1679
|
-
}
|
|
1680
|
-
return;
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
let gateStage = null;
|
|
1684
|
-
let hasNoWorktree = false;
|
|
1685
|
-
const activeChanges = [];
|
|
1686
|
-
|
|
1687
|
-
for (const [name, stage, noWorktree] of rows[0].values) {
|
|
1688
|
-
if (!stage) continue;
|
|
1689
|
-
// 优先取 execute,其次 quick
|
|
1690
|
-
if (gateStage !== 'execute' || stage === 'execute') {
|
|
1691
|
-
gateStage = stage;
|
|
1692
|
-
}
|
|
1693
|
-
activeChanges.push(name);
|
|
1694
|
-
if (noWorktree === 1) hasNoWorktree = true;
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
if (!gateStage) {
|
|
1698
|
-
// current_stage 为 NULL 的边界情况,等同于无 execute/quick
|
|
1699
|
-
if (existsSync(gatePath)) {
|
|
1700
|
-
try { unlinkSync(gatePath); } catch {}
|
|
1701
|
-
}
|
|
1702
|
-
return;
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
try {
|
|
1706
|
-
this._ensureRuntimeDir(cwd);
|
|
1707
|
-
const gateData = {
|
|
1708
|
-
stage: gateStage,
|
|
1709
|
-
changes: activeChanges,
|
|
1710
|
-
updatedAt: new Date().toISOString(),
|
|
1711
|
-
...(hasNoWorktree ? { noWorktree: true } : {}),
|
|
1712
|
-
};
|
|
1713
|
-
const tmpPath = gatePath + '.tmp';
|
|
1714
|
-
writeFileSync(tmpPath, JSON.stringify(gateData, null, 2) + '\n');
|
|
1715
|
-
renameSync(tmpPath, gatePath);
|
|
1716
|
-
} catch (err) {
|
|
1717
|
-
console.warn('⚠️ 写入 gate-status.json 失败:', err.message);
|
|
1718
|
-
}
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
_ensureGitignore(cwd) {
|
|
1722
|
-
// 外部 specDir 不需要修改项目 .gitignore
|
|
1723
|
-
if (this._customSpecDir) return;
|
|
1724
|
-
const gitignorePath = join(cwd, '.gitignore');
|
|
1725
|
-
const rule = '.sillyspec/.runtime/';
|
|
1726
|
-
if (existsSync(gitignorePath)) {
|
|
1727
|
-
const content = readFileSync(gitignorePath, 'utf8');
|
|
1728
|
-
if (content.includes(rule)) return;
|
|
1729
|
-
writeFileSync(gitignorePath, content.trimEnd() + '\n' + rule + '\n');
|
|
1730
|
-
} else {
|
|
1731
|
-
writeFileSync(gitignorePath, rule + '\n');
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* SillySpec ProgressManager — 进度恢复管理
|
|
3
|
+
*
|
|
4
|
+
* 纯 Node.js,无外部依赖。支持多变更并行。
|
|
5
|
+
*
|
|
6
|
+
* 存储结构:
|
|
7
|
+
* .sillyspec/.runtime/sillyspec.db — SQLite 数据库(权威状态源)
|
|
8
|
+
* .sillyspec/.runtime/global.json — 全局状态缓存(项目名、活跃变更列表)
|
|
9
|
+
* .sillyspec/.runtime/gate-status.json — worktree-guard 门禁状态缓存
|
|
10
|
+
*
|
|
11
|
+
* 历史迁移:v1/v2 使用 progress.json 文件,v3 已全部迁移至 SQLite。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
15
|
+
import { join, basename, dirname, resolve } from 'path';
|
|
16
|
+
import { DB } from './db.js';
|
|
17
|
+
|
|
18
|
+
// 默认规范目录名(相对于 cwd)
|
|
19
|
+
const SPEC_DIR_NAME = '.sillyspec';
|
|
20
|
+
const RUNTIME_SUBDIR = '.runtime';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 向上查找含 .sillyspec 目录的祖先目录,类似 git 找 .git 的逻辑。
|
|
24
|
+
* 找到则返回 <祖先>/.sillyspec,否则 fallback 到 <cwd>/.sillyspec。
|
|
25
|
+
*/
|
|
26
|
+
export function resolveSpecDir(startDir) {
|
|
27
|
+
let dir = resolve(startDir);
|
|
28
|
+
while (true) {
|
|
29
|
+
const candidate = join(dir, SPEC_DIR_NAME);
|
|
30
|
+
if (existsSync(candidate)) return candidate;
|
|
31
|
+
const parent = dirname(dir);
|
|
32
|
+
if (parent === dir) break; // 到达根目录
|
|
33
|
+
dir = parent;
|
|
34
|
+
}
|
|
35
|
+
return join(resolve(startDir), SPEC_DIR_NAME);
|
|
36
|
+
}
|
|
37
|
+
const CHANGES_SUBDIR = 'changes';
|
|
38
|
+
const GLOBAL_FILE = 'global.json';
|
|
39
|
+
const CURRENT_VERSION = 3;
|
|
40
|
+
const VALID_STAGES = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore'];
|
|
41
|
+
const VALID_STATUSES = ['pending', 'in-progress', 'completed', 'failed', 'blocked', 'waiting', 'stale'];
|
|
42
|
+
|
|
43
|
+
// Stage statuses (superset of step statuses)
|
|
44
|
+
const VALID_STAGE_STATUSES = ['pending', 'in-progress', 'completed', 'failed', 'blocked', 'revising', 'stale'];
|
|
45
|
+
|
|
46
|
+
// Main flow stage order (for downstream cascade)
|
|
47
|
+
// 完整主流程顺序(含 scan),用于下游 cascade
|
|
48
|
+
const STAGE_ORDER = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive'];
|
|
49
|
+
// 主流程阶段(不含 scan/quick/explore 等辅助阶段)
|
|
50
|
+
const MAIN_FLOW_ORDER = STAGE_ORDER;
|
|
51
|
+
|
|
52
|
+
const STAGE_LABELS = {
|
|
53
|
+
brainstorm: '🧠 需求探索',
|
|
54
|
+
plan: '📐 实现计划',
|
|
55
|
+
execute: '⚡ 波次执行',
|
|
56
|
+
verify: '🔍 验证确认',
|
|
57
|
+
scan: '🔍 代码扫描',
|
|
58
|
+
quick: '⚡ 快速任务',
|
|
59
|
+
explore: '🧭 自由探索',
|
|
60
|
+
archive: '📦 归档变更',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function emptyStage() {
|
|
64
|
+
return { status: 'pending', steps: [], startedAt: null, completedAt: null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeInitialProgress(project) {
|
|
68
|
+
const stages = {};
|
|
69
|
+
for (const s of VALID_STAGES) stages[s] = emptyStage();
|
|
70
|
+
return { _version: CURRENT_VERSION, project: project || '', currentStage: '', currentChange: null, stages, lastActive: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeInitialGlobal(project) {
|
|
74
|
+
return { _version: CURRENT_VERSION, project: project || '', activeChanges: [] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── ProgressManager ──
|
|
78
|
+
|
|
79
|
+
export class ProgressManager {
|
|
80
|
+
/**
|
|
81
|
+
* @param {object} [opts]
|
|
82
|
+
* @param {string} [opts.specDir] - 规范目录绝对路径(默认 cwd/.sillyspec)
|
|
83
|
+
*/
|
|
84
|
+
constructor(opts = {}) {
|
|
85
|
+
this._customSpecDir = opts.specDir || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── 路径工具 ──
|
|
89
|
+
|
|
90
|
+
/** 获取 specDir(优先自定义,否则向上查找含 .sillyspec 的目录,fallback 到 cwd/.sillyspec) */
|
|
91
|
+
_getSpecDir(cwd) {
|
|
92
|
+
if (this._customSpecDir) return this._customSpecDir;
|
|
93
|
+
return resolveSpecDir(cwd);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_runtimePath(cwd, ...parts) {
|
|
97
|
+
return join(this._getSpecDir(cwd), RUNTIME_SUBDIR, ...parts);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_changePath(cwd, changeName, ...parts) {
|
|
101
|
+
return join(this._getSpecDir(cwd), CHANGES_SUBDIR, changeName, ...parts);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_ensureRuntimeDir(cwd) {
|
|
105
|
+
const runtimeDir = this._runtimePath(cwd);
|
|
106
|
+
if (!existsSync(runtimeDir)) {
|
|
107
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
108
|
+
for (const d of ['artifacts', 'history', 'logs', 'templates']) {
|
|
109
|
+
mkdirSync(join(runtimeDir, d), { recursive: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 懒初始化 DB 连接,缓存在实例上 */
|
|
115
|
+
async _ensureDB(cwd) {
|
|
116
|
+
if (!this._db) {
|
|
117
|
+
this._db = new DB(this._runtimePath(cwd, 'sillyspec.db'));
|
|
118
|
+
await this._db.init();
|
|
119
|
+
}
|
|
120
|
+
return this._db;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_ensureChangeDir(cwd, changeName) {
|
|
124
|
+
const dir = this._changePath(cwd, changeName);
|
|
125
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
126
|
+
return dir;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── 全局状态 ──
|
|
130
|
+
|
|
131
|
+
async readGlobal(cwd) {
|
|
132
|
+
// SQL: SELECT FROM project + changes
|
|
133
|
+
const db = await this._ensureDB(cwd);
|
|
134
|
+
const sqlDb = db.getDb();
|
|
135
|
+
|
|
136
|
+
// 读取 project 行(id=1)
|
|
137
|
+
const rows = sqlDb.exec('SELECT name, schema_version FROM project WHERE id = 1');
|
|
138
|
+
if (!rows || rows.length === 0 || rows[0].values.length === 0) return null;
|
|
139
|
+
const [name, schemaVersion] = rows[0].values[0];
|
|
140
|
+
|
|
141
|
+
// 读取 active 变更列表
|
|
142
|
+
const changeRows = sqlDb.exec("SELECT name FROM changes WHERE status = 'active' ORDER BY name");
|
|
143
|
+
const activeChanges = changeRows && changeRows.length > 0
|
|
144
|
+
? changeRows[0].values.map(r => r[0])
|
|
145
|
+
: [];
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
_version: schemaVersion,
|
|
149
|
+
project: name,
|
|
150
|
+
activeChanges,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async writeGlobal(cwd, data) {
|
|
155
|
+
// SQL: UPDATE project + UPSERT changes status
|
|
156
|
+
const db = await this._ensureDB(cwd);
|
|
157
|
+
db.transaction((sqlDb) => {
|
|
158
|
+
const now = new Date().toISOString();
|
|
159
|
+
|
|
160
|
+
// UPSERT project 行
|
|
161
|
+
sqlDb.run(`
|
|
162
|
+
INSERT INTO project (id, name, schema_version, created_at, updated_at)
|
|
163
|
+
VALUES (1, ?, ?, ?, ?)
|
|
164
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
165
|
+
name = excluded.name,
|
|
166
|
+
schema_version = excluded.schema_version,
|
|
167
|
+
updated_at = excluded.updated_at
|
|
168
|
+
`, [data.project || '', data._version || CURRENT_VERSION, now, now]);
|
|
169
|
+
|
|
170
|
+
// 同步 changes 表:确保 activeChanges 列表中的变更存在且为 active,
|
|
171
|
+
// 不在列表中的设为 archived
|
|
172
|
+
const activeChanges = data.activeChanges || [];
|
|
173
|
+
for (const cn of activeChanges) {
|
|
174
|
+
sqlDb.run(`
|
|
175
|
+
INSERT INTO changes (name, status, created_at, last_active)
|
|
176
|
+
VALUES (?, 'active', ?, ?)
|
|
177
|
+
ON CONFLICT(name) DO UPDATE SET status = 'active', last_active = excluded.last_active
|
|
178
|
+
`, [cn, now, now]);
|
|
179
|
+
}
|
|
180
|
+
if (activeChanges.length > 0) {
|
|
181
|
+
sqlDb.run(`
|
|
182
|
+
UPDATE changes SET status = 'archived'
|
|
183
|
+
WHERE status = 'active' AND name NOT IN (${activeChanges.map(() => '?').join(',')})
|
|
184
|
+
`, activeChanges);
|
|
185
|
+
} else {
|
|
186
|
+
// 没有活跃变更,将所有 active 归档
|
|
187
|
+
sqlDb.run("UPDATE changes SET status = 'archived' WHERE status = 'active'");
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── 变更级别状态 ──
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 读取指定变更的 progress(SQL 版)
|
|
196
|
+
* @param {string} cwd
|
|
197
|
+
* @param {string|null} changeName - 变更名,null 时尝试自动检测
|
|
198
|
+
* @returns {Promise<object|null>} 与 SQLite 查询结果一致的 JS 对象
|
|
199
|
+
*/
|
|
200
|
+
async read(cwd, changeName = null) {
|
|
201
|
+
// 自动检测变更名
|
|
202
|
+
if (!changeName) {
|
|
203
|
+
const changes = await this.listChanges(cwd);
|
|
204
|
+
if (changes.length === 1) {
|
|
205
|
+
changeName = changes[0];
|
|
206
|
+
} else {
|
|
207
|
+
// 多个或零个活跃变更,无法确定
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const db = await this._ensureDB(cwd);
|
|
213
|
+
const sqlDb = db.getDb();
|
|
214
|
+
|
|
215
|
+
// 1. 从 changes 表获取基本信息
|
|
216
|
+
const changeRows = sqlDb.exec('SELECT id, name, current_stage, no_worktree, last_active FROM changes WHERE name = ?', [changeName]);
|
|
217
|
+
if (!changeRows || changeRows.length === 0 || changeRows[0].values.length === 0) return null;
|
|
218
|
+
const [changeId, cName, currentStage, noWorktree, lastActive] = changeRows[0].values[0];
|
|
219
|
+
|
|
220
|
+
// 2. 从 stages 表获取所有阶段(含 revision 列)
|
|
221
|
+
const stageRows = sqlDb.exec('SELECT id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason FROM stages WHERE change_id = ? ORDER BY id', [changeId]);
|
|
222
|
+
const stageMap = {};
|
|
223
|
+
const stageIds = [];
|
|
224
|
+
if (stageRows && stageRows.length > 0) {
|
|
225
|
+
for (const [sId, stage, status, startedAt, completedAt, revision, reopenedFromStep, reopenedAt, staleReason] of stageRows[0].values) {
|
|
226
|
+
stageMap[stage] = { _dbId: sId, status, startedAt, completedAt,
|
|
227
|
+
...(revision ? { revision } : {}),
|
|
228
|
+
...(reopenedFromStep ? { reopenedFromStep } : {}),
|
|
229
|
+
...(reopenedAt ? { reopenedAt } : {}),
|
|
230
|
+
...(staleReason ? { staleReason } : {}),
|
|
231
|
+
};
|
|
232
|
+
stageIds.push(sId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 3. 从 steps 表获取所有步骤
|
|
237
|
+
let stepRows = null;
|
|
238
|
+
if (stageIds.length > 0) {
|
|
239
|
+
const placeholders = stageIds.map(() => '?').join(',');
|
|
240
|
+
stepRows = sqlDb.exec(
|
|
241
|
+
`SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`,
|
|
242
|
+
stageIds
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
// 按阶段分组步骤
|
|
246
|
+
const stepsByStage = {};
|
|
247
|
+
if (stepRows && stepRows.length > 0) {
|
|
248
|
+
for (const row of stepRows[0].values) {
|
|
249
|
+
const [stageId, name, status, output, completedAt, ordering, waitReason, waitOptions, waitAnswer, waitedAt, waitAnswersJson, waitRound, maxWaitRounds] = row;
|
|
250
|
+
if (!stepsByStage[stageId]) stepsByStage[stageId] = [];
|
|
251
|
+
let waitAnswers = null;
|
|
252
|
+
if (waitAnswersJson) {
|
|
253
|
+
try { waitAnswers = JSON.parse(waitAnswersJson); } catch {}
|
|
254
|
+
}
|
|
255
|
+
stepsByStage[stageId].push({
|
|
256
|
+
name, status, output, completedAt,
|
|
257
|
+
...(waitReason ? { waitReason } : {}),
|
|
258
|
+
...(waitOptions ? { waitOptions } : {}),
|
|
259
|
+
...(waitAnswer ? { waitAnswer } : {}),
|
|
260
|
+
...(waitedAt ? { waitedAt } : {}),
|
|
261
|
+
...(waitAnswers ? { waitAnswers } : {}),
|
|
262
|
+
...(waitRound != null ? { waitRound } : {}),
|
|
263
|
+
...(maxWaitRounds != null ? { maxWaitRounds } : {}),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 4. 从 batch_progress 表获取批量进度
|
|
269
|
+
const batchRows = sqlDb.exec('SELECT total, completed, failed, skipped FROM batch_progress WHERE change_id = ?', [changeId]);
|
|
270
|
+
let batchProgress = undefined;
|
|
271
|
+
if (batchRows && batchRows.length > 0 && batchRows[0].values.length > 0) {
|
|
272
|
+
const [total, completed, failed, skipped] = batchRows[0].values[0];
|
|
273
|
+
batchProgress = { total, completed, failed, skipped };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 5. 获取项目名
|
|
277
|
+
const projectRows = sqlDb.exec('SELECT name FROM project WHERE id = 1');
|
|
278
|
+
const projectName = (projectRows && projectRows.length > 0 && projectRows[0].values.length > 0)
|
|
279
|
+
? projectRows[0].values[0][0]
|
|
280
|
+
: '';
|
|
281
|
+
|
|
282
|
+
// 6. 组装为兼容对象
|
|
283
|
+
const stages = {};
|
|
284
|
+
// 先填充所有 VALID_STAGES
|
|
285
|
+
for (const s of VALID_STAGES) {
|
|
286
|
+
stages[s] = emptyStage();
|
|
287
|
+
}
|
|
288
|
+
// 用 DB 数据覆盖
|
|
289
|
+
for (const [stage, info] of Object.entries(stageMap)) {
|
|
290
|
+
const steps = (stepsByStage[info._dbId] || []).map(s => ({
|
|
291
|
+
name: s.name,
|
|
292
|
+
status: s.status,
|
|
293
|
+
output: s.output,
|
|
294
|
+
completedAt: s.completedAt,
|
|
295
|
+
...(s.waitReason ? { waitReason: s.waitReason } : {}),
|
|
296
|
+
...(s.waitOptions ? { waitOptions: s.waitOptions } : {}),
|
|
297
|
+
...(s.waitAnswer ? { waitAnswer: s.waitAnswer } : {}),
|
|
298
|
+
...(s.waitedAt ? { waitedAt: s.waitedAt } : {}),
|
|
299
|
+
...(s.waitAnswers ? { waitAnswers: s.waitAnswers } : {}),
|
|
300
|
+
...(s.waitRound != null ? { waitRound: s.waitRound } : {}),
|
|
301
|
+
...(s.maxWaitRounds != null ? { maxWaitRounds: s.maxWaitRounds } : {}),
|
|
302
|
+
}));
|
|
303
|
+
stages[stage] = {
|
|
304
|
+
status: info.status,
|
|
305
|
+
steps,
|
|
306
|
+
startedAt: info.startedAt,
|
|
307
|
+
completedAt: info.completedAt,
|
|
308
|
+
// Revision v1 fields
|
|
309
|
+
...(info.revision ? { revision: info.revision } : {}),
|
|
310
|
+
...(info.reopenedFromStep ? { reopenedFromStep: info.reopenedFromStep } : {}),
|
|
311
|
+
...(info.reopenedAt ? { reopenedAt: info.reopenedAt } : {}),
|
|
312
|
+
...(info.staleReason ? { staleReason: info.staleReason } : {}),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const result = {
|
|
317
|
+
_version: 3,
|
|
318
|
+
project: projectName,
|
|
319
|
+
currentChange: cName,
|
|
320
|
+
currentStage: currentStage || '',
|
|
321
|
+
lastActive: lastActive || null,
|
|
322
|
+
stages,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// noWorktree
|
|
326
|
+
if (noWorktree) result.noWorktree = true;
|
|
327
|
+
|
|
328
|
+
// batchProgress(仅在 DB 中有记录时才包含)
|
|
329
|
+
if (batchProgress) result.batchProgress = batchProgress;
|
|
330
|
+
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* 写入指定变更的 progress
|
|
336
|
+
* @param {string} cwd
|
|
337
|
+
* @param {object} data
|
|
338
|
+
* @param {string|null} changeName - 从 data.currentChange 推导,或显式传入
|
|
339
|
+
*/
|
|
340
|
+
async _write(cwd, data, changeName = null) {
|
|
341
|
+
const cn = changeName || data.currentChange;
|
|
342
|
+
if (!cn) {
|
|
343
|
+
console.warn('⚠️ _write: 无变更名,跳过写入');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const db = await this._ensureDB(cwd);
|
|
348
|
+
db.transaction((sqlDb) => {
|
|
349
|
+
// 1. 更新 changes 表
|
|
350
|
+
const now = new Date().toISOString();
|
|
351
|
+
const noWorktree = data.noWorktree ? 1 : 0;
|
|
352
|
+
sqlDb.run(
|
|
353
|
+
'UPDATE changes SET current_stage = ?, last_active = ?, no_worktree = ? WHERE name = ?',
|
|
354
|
+
[data.currentStage || '', now, noWorktree, cn]
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// 2. 获取 change_id
|
|
358
|
+
const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [cn]);
|
|
359
|
+
if (!changeRow || changeRow.length === 0 || changeRow[0].values.length === 0) return;
|
|
360
|
+
const changeId = changeRow[0].values[0][0];
|
|
361
|
+
|
|
362
|
+
// 3. 遍历 stages,UPSERT stages 表和 steps 表
|
|
363
|
+
if (data.stages && typeof data.stages === 'object') {
|
|
364
|
+
for (const [stageName, stageData] of Object.entries(data.stages)) {
|
|
365
|
+
// UPSERT stages 行(含 revision 列)
|
|
366
|
+
sqlDb.run(
|
|
367
|
+
`INSERT INTO stages (change_id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason)
|
|
368
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
369
|
+
ON CONFLICT(change_id, stage) DO UPDATE SET
|
|
370
|
+
status = excluded.status,
|
|
371
|
+
started_at = excluded.started_at,
|
|
372
|
+
completed_at = excluded.completed_at,
|
|
373
|
+
revision = COALESCE(excluded.revision, stages.revision),
|
|
374
|
+
reopened_from_step = excluded.reopened_from_step,
|
|
375
|
+
reopened_at = excluded.reopened_at,
|
|
376
|
+
stale_reason = excluded.stale_reason`,
|
|
377
|
+
[changeId, stageName, stageData.status || 'pending', stageData.startedAt || null, stageData.completedAt || null,
|
|
378
|
+
stageData.revision || 0, stageData.reopenedFromStep || null, stageData.reopenedAt || null, stageData.staleReason || null]
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// 获取 stage_id
|
|
382
|
+
const stageRow = sqlDb.exec('SELECT id FROM stages WHERE change_id = ? AND stage = ?', [changeId, stageName]);
|
|
383
|
+
if (!stageRow || stageRow.length === 0 || stageRow[0].values.length === 0) continue;
|
|
384
|
+
const stageId = stageRow[0].values[0][0];
|
|
385
|
+
|
|
386
|
+
// 收集 data 中的步骤名
|
|
387
|
+
const stepNames = new Set();
|
|
388
|
+
if (Array.isArray(stageData.steps)) {
|
|
389
|
+
for (let i = 0; i < stageData.steps.length; i++) {
|
|
390
|
+
const step = stageData.steps[i];
|
|
391
|
+
stepNames.add(step.name);
|
|
392
|
+
// UPSERT 步骤(先删再插,steps 表无 UNIQUE 约束)
|
|
393
|
+
sqlDb.run('DELETE FROM steps WHERE stage_id = ? AND name = ?', [stageId, step.name]);
|
|
394
|
+
sqlDb.run(
|
|
395
|
+
'INSERT INTO steps (stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
396
|
+
[stageId, step.name, step.status || 'pending', step.output || null, step.completedAt || null, i,
|
|
397
|
+
step.waitReason ?? null, step.waitOptions ?? null, step.waitAnswer ?? null, step.waitedAt ?? null,
|
|
398
|
+
Array.isArray(step.waitAnswers) ? JSON.stringify(step.waitAnswers) : null,
|
|
399
|
+
step.waitRound ?? null, step.maxWaitRounds ?? null]
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 删除 data 中不存在的多余步骤
|
|
405
|
+
if (stepNames.size > 0) {
|
|
406
|
+
const namePlaceholders = [...stepNames].map(() => '?').join(',');
|
|
407
|
+
sqlDb.run(
|
|
408
|
+
`DELETE FROM steps WHERE stage_id = ? AND name NOT IN (${namePlaceholders})`,
|
|
409
|
+
[stageId, ...stepNames]
|
|
410
|
+
);
|
|
411
|
+
} else {
|
|
412
|
+
// data 中没有步骤,清空该阶段所有步骤
|
|
413
|
+
sqlDb.run('DELETE FROM steps WHERE stage_id = ?', [stageId]);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// 4. UPSERT batch_progress
|
|
419
|
+
if (data.batchProgress && typeof data.batchProgress === 'object') {
|
|
420
|
+
sqlDb.run(
|
|
421
|
+
`INSERT INTO batch_progress (change_id, total, completed, failed, skipped)
|
|
422
|
+
VALUES (?, ?, ?, ?, ?)
|
|
423
|
+
ON CONFLICT(change_id) DO UPDATE SET
|
|
424
|
+
total = excluded.total,
|
|
425
|
+
completed = excluded.completed,
|
|
426
|
+
failed = excluded.failed,
|
|
427
|
+
skipped = excluded.skipped`,
|
|
428
|
+
[changeId, data.batchProgress.total || 0, data.batchProgress.completed || 0, data.batchProgress.failed || 0, data.batchProgress.skipped || 0]
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
await this._updateGateStatus(cwd);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── 变更管理 ──
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 列出所有活跃变更名
|
|
440
|
+
* SQL: SELECT name FROM changes WHERE status = 'active'
|
|
441
|
+
*/
|
|
442
|
+
async listChanges(cwd) {
|
|
443
|
+
const db = await this._ensureDB(cwd);
|
|
444
|
+
const sqlDb = db.getDb();
|
|
445
|
+
const rows = sqlDb.exec("SELECT name FROM changes WHERE status = 'active' ORDER BY name");
|
|
446
|
+
if (!rows || rows.length === 0) return [];
|
|
447
|
+
return rows[0].values.map(r => r[0]);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* 注册变更到活跃列表
|
|
452
|
+
* SQL: INSERT OR IGNORE → 若已 archived 则 UPDATE status='active'
|
|
453
|
+
*/
|
|
454
|
+
async registerChange(cwd, changeName) {
|
|
455
|
+
if (!changeName) {
|
|
456
|
+
console.warn('⚠️ registerChange: changeName 为空,跳过');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const db = await this._ensureDB(cwd);
|
|
460
|
+
db.transaction((sqlDb) => {
|
|
461
|
+
const now = new Date().toISOString();
|
|
462
|
+
// 尝试插入新行
|
|
463
|
+
sqlDb.run(
|
|
464
|
+
`INSERT OR IGNORE INTO changes (name, created_at, last_active)
|
|
465
|
+
VALUES (?, ?, ?)`,
|
|
466
|
+
[changeName, now, now]
|
|
467
|
+
);
|
|
468
|
+
// 注意:不复活已归档的变更——归档是不可逆操作
|
|
469
|
+
// 如果变更已存在且为 archived,保持 archived 状态不变
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* 更新变更的隔离状态
|
|
475
|
+
* @param {string} cwd - 项目根目录
|
|
476
|
+
* @param {string} changeName - 变更名
|
|
477
|
+
* @param {{ status: string, mode?: string, reason?: string }} isolation
|
|
478
|
+
*/
|
|
479
|
+
async updateChangeIsolation(cwd, changeName, isolation) {
|
|
480
|
+
const db = await this._ensureDB(cwd);
|
|
481
|
+
const sqlDb = db.getDb();
|
|
482
|
+
try {
|
|
483
|
+
sqlDb.run(
|
|
484
|
+
`UPDATE changes SET isolation_status = ?, isolation_mode = ?, isolation_reason = ?, last_active = ? WHERE name = ?`,
|
|
485
|
+
[isolation.status, isolation.mode || null, isolation.reason || null, new Date().toISOString(), changeName]
|
|
486
|
+
);
|
|
487
|
+
db._save();
|
|
488
|
+
} catch (err) {
|
|
489
|
+
console.warn('⚠️ 更新 isolation 状态失败:', err.message);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 读取变更的隔离状态
|
|
495
|
+
* @param {string} cwd - 项目根目录
|
|
496
|
+
* @param {string} changeName - 变更名
|
|
497
|
+
* @returns {{ status: string|null, mode: string|null, reason: string|null }|null}
|
|
498
|
+
*/
|
|
499
|
+
async readChangeIsolation(cwd, changeName) {
|
|
500
|
+
const db = await this._ensureDB(cwd);
|
|
501
|
+
const sqlDb = db.getDb();
|
|
502
|
+
try {
|
|
503
|
+
const rows = sqlDb.exec(
|
|
504
|
+
`SELECT isolation_status, isolation_mode, isolation_reason FROM changes WHERE name = ?`,
|
|
505
|
+
[changeName]
|
|
506
|
+
);
|
|
507
|
+
if (!rows || rows.length === 0 || rows[0].values.length === 0) return null;
|
|
508
|
+
const [status, mode, reason] = rows[0].values[0];
|
|
509
|
+
return { status: status || null, mode: mode || null, reason: reason || null };
|
|
510
|
+
} catch {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async _updatePlatformLastSync(cwd, changeName) {
|
|
516
|
+
if (!changeName) return;
|
|
517
|
+
const db = await this._ensureDB(cwd);
|
|
518
|
+
db.transaction((sqlDb) => {
|
|
519
|
+
sqlDb.run(
|
|
520
|
+
'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1 WHERE name = ?',
|
|
521
|
+
[new Date().toISOString(), changeName]
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async _updateApprovalStatus(cwd, changeName, status, reason = null) {
|
|
527
|
+
if (!changeName || !status) return;
|
|
528
|
+
const db = await this._ensureDB(cwd);
|
|
529
|
+
db.transaction((sqlDb) => {
|
|
530
|
+
const rows = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
|
|
531
|
+
if (!rows || rows.length === 0 || rows[0].values.length === 0) return;
|
|
532
|
+
const changeId = rows[0].values[0][0];
|
|
533
|
+
const now = new Date().toISOString();
|
|
534
|
+
sqlDb.run(
|
|
535
|
+
`INSERT INTO approvals (change_id, status, requested_at, approved_at, rejection_reason)
|
|
536
|
+
VALUES (?, ?, ?, ?, ?)
|
|
537
|
+
ON CONFLICT(change_id) DO UPDATE SET
|
|
538
|
+
status = excluded.status,
|
|
539
|
+
approved_at = excluded.approved_at,
|
|
540
|
+
rejection_reason = excluded.rejection_reason`,
|
|
541
|
+
[
|
|
542
|
+
changeId,
|
|
543
|
+
status,
|
|
544
|
+
now,
|
|
545
|
+
status === 'approved' ? now : null,
|
|
546
|
+
status === 'rejected' ? reason : null,
|
|
547
|
+
]
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* 重命名变更:同步更新 DB + 目录
|
|
554
|
+
* @param {string} cwd - 项目根目录
|
|
555
|
+
* @param {string} oldName - 旧变更名
|
|
556
|
+
* @param {string} newName - 新变更名
|
|
557
|
+
*/
|
|
558
|
+
async renameChange(cwd, oldName, newName) {
|
|
559
|
+
if (!oldName || !newName) {
|
|
560
|
+
console.warn('⚠️ renameChange: 旧名或新名为空,跳过');
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (oldName === newName) {
|
|
564
|
+
console.warn('⚠️ renameChange: 新旧名称相同,跳过');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const db = await this._ensureDB(cwd);
|
|
568
|
+
// 检查旧名是否存在
|
|
569
|
+
const existing = db.transaction((sqlDb) => {
|
|
570
|
+
const row = sqlDb.exec(`SELECT name, status FROM changes WHERE name = ?`, [oldName]);
|
|
571
|
+
if (!row || !row[0] || row[0].values.length === 0) return null;
|
|
572
|
+
return { name: row[0].values[0][0], status: row[0].values[0][1] };
|
|
573
|
+
});
|
|
574
|
+
if (!existing) {
|
|
575
|
+
console.error(`❌ 变更 ${oldName} 不存在`);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// 检查新名是否已存在
|
|
579
|
+
const conflict = db.transaction((sqlDb) => {
|
|
580
|
+
const row = sqlDb.exec(`SELECT name FROM changes WHERE name = ?`, [newName]);
|
|
581
|
+
return row && row[0] && row[0].values.length > 0;
|
|
582
|
+
});
|
|
583
|
+
if (conflict) {
|
|
584
|
+
console.error(`❌ 变更 ${newName} 已存在`);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
// 重命名目录
|
|
588
|
+
const oldDir = this._changePath(cwd, oldName);
|
|
589
|
+
const newDir = this._changePath(cwd, newName);
|
|
590
|
+
if (existsSync(oldDir)) {
|
|
591
|
+
renameSync(oldDir, newDir);
|
|
592
|
+
} else {
|
|
593
|
+
mkdirSync(newDir, { recursive: true });
|
|
594
|
+
}
|
|
595
|
+
// 更新 DB
|
|
596
|
+
const now = new Date().toISOString();
|
|
597
|
+
db.transaction((sqlDb) => {
|
|
598
|
+
sqlDb.run(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`, [newName, now, oldName]);
|
|
599
|
+
});
|
|
600
|
+
console.log(`✅ 变更已重命名:${oldName} → ${newName}`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* 从活跃列表移除变更(归档时调用,不物理删除)
|
|
605
|
+
* SQL: UPDATE changes SET status = 'archived'
|
|
606
|
+
*/
|
|
607
|
+
async unregisterChange(cwd, changeName) {
|
|
608
|
+
if (!changeName) {
|
|
609
|
+
console.warn('⚠️ unregisterChange: changeName 为空,跳过');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const db = await this._ensureDB(cwd);
|
|
613
|
+
db.transaction((sqlDb) => {
|
|
614
|
+
const now = new Date().toISOString();
|
|
615
|
+
sqlDb.run(
|
|
616
|
+
`UPDATE changes SET status = 'archived', last_active = ? WHERE name = ?`,
|
|
617
|
+
[now, changeName]
|
|
618
|
+
);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ── CLI 命令 ──
|
|
623
|
+
|
|
624
|
+
async init(cwd) {
|
|
625
|
+
this._ensureRuntimeDir(cwd);
|
|
626
|
+
|
|
627
|
+
// 初始化 DB(如不存在则创建文件 + 建表)
|
|
628
|
+
const db = await this._ensureDB(cwd);
|
|
629
|
+
db.transaction((sqlDb) => {
|
|
630
|
+
const now = new Date().toISOString();
|
|
631
|
+
const projectName = basename(cwd) || 'project';
|
|
632
|
+
|
|
633
|
+
// 检查 project id=1 是否已存在
|
|
634
|
+
const existing = sqlDb.exec('SELECT id FROM project WHERE id = 1');
|
|
635
|
+
if (!existing || existing.length === 0 || existing[0].values.length === 0) {
|
|
636
|
+
sqlDb.run(
|
|
637
|
+
`INSERT INTO project (id, name, schema_version, created_at, updated_at)
|
|
638
|
+
VALUES (1, ?, ?, ?, ?)`,
|
|
639
|
+
[projectName, CURRENT_VERSION, now, now]
|
|
640
|
+
);
|
|
641
|
+
console.log(`✅ 已创建全局状态文件(SQLite)`);
|
|
642
|
+
} else {
|
|
643
|
+
console.log(`ℹ️ 全局状态文件已存在,跳过`);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// 创建 user-inputs.md
|
|
648
|
+
const inputsPath = this._runtimePath(cwd, 'user-inputs.md');
|
|
649
|
+
if (!existsSync(inputsPath)) {
|
|
650
|
+
writeFileSync(inputsPath, '# 用户输入记录\n\n> 每步完成时由 AI 自动追加,记录用户所有原话。\n\n');
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
this._ensureGitignore(cwd);
|
|
654
|
+
return await this.readGlobal(cwd);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* 初始化指定变更的 progress
|
|
659
|
+
* SQL: INSERT changes + 批量 INSERT stages
|
|
660
|
+
*/
|
|
661
|
+
async initChange(cwd, changeName) {
|
|
662
|
+
if (!changeName) {
|
|
663
|
+
console.warn('⚠️ initChange: changeName 为空,跳过');
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
this._ensureChangeDir(cwd, changeName);
|
|
667
|
+
|
|
668
|
+
const db = await this._ensureDB(cwd);
|
|
669
|
+
db.transaction((sqlDb) => {
|
|
670
|
+
const now = new Date().toISOString();
|
|
671
|
+
|
|
672
|
+
// 检查变更是否已存在
|
|
673
|
+
const existing = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
|
|
674
|
+
if (!existing || existing.length === 0 || existing[0].values.length === 0) {
|
|
675
|
+
// 插入 changes 行
|
|
676
|
+
sqlDb.run(
|
|
677
|
+
`INSERT INTO changes (name, current_stage, status, created_at, last_active)
|
|
678
|
+
VALUES (?, 'scan', 'active', ?, ?)`,
|
|
679
|
+
[changeName, now, now]
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// 获取 change_id
|
|
684
|
+
const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
|
|
685
|
+
const changeId = changeRow[0].values[0][0];
|
|
686
|
+
|
|
687
|
+
// 批量插入 9 个阶段(INSERT OR IGNORE 跳过已存在的)
|
|
688
|
+
const allStages = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore'];
|
|
689
|
+
for (const stage of allStages) {
|
|
690
|
+
sqlDb.run(
|
|
691
|
+
`INSERT OR IGNORE INTO stages (change_id, stage, status)
|
|
692
|
+
VALUES (?, ?, 'pending')`,
|
|
693
|
+
[changeId, stage]
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// 不再需要写文件:read() 已改为 SQL
|
|
699
|
+
return await this.read(cwd, changeName);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async setStage(cwd, stage, changeName = null) {
|
|
703
|
+
if (!VALID_STAGES.includes(stage)) {
|
|
704
|
+
console.log(`❌ 未知阶段: ${stage},可选: ${VALID_STAGES.join(', ')}`);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const db = await this._ensureDB(cwd);
|
|
709
|
+
const now = new Date().toISOString();
|
|
710
|
+
|
|
711
|
+
// 获取变更名
|
|
712
|
+
let cn = changeName;
|
|
713
|
+
if (!cn) {
|
|
714
|
+
const changes = await this.listChanges(cwd);
|
|
715
|
+
if (changes.length === 1) cn = changes[0];
|
|
716
|
+
if (!cn) { console.log('❌ 无法确定当前变更,请指定 --change <name>'); return; }
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
db.transaction((sqlDb) => {
|
|
720
|
+
// 确保 change 存在
|
|
721
|
+
const changeRow = sqlDb.exec('SELECT id, current_stage FROM changes WHERE name = ?', [cn]);
|
|
722
|
+
if (!changeRow || changeRow.length === 0 || changeRow[0].values.length === 0) return;
|
|
723
|
+
|
|
724
|
+
const changeId = changeRow[0].values[0][0];
|
|
725
|
+
|
|
726
|
+
// UPDATE changes.current_stage + last_active
|
|
727
|
+
sqlDb.run('UPDATE changes SET current_stage = ?, last_active = ? WHERE name = ?', [stage, now, cn]);
|
|
728
|
+
|
|
729
|
+
// 确保 stages 行存在(INSERT OR IGNORE)
|
|
730
|
+
sqlDb.run(
|
|
731
|
+
'INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, "pending")',
|
|
732
|
+
[changeId, stage]
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
// UPDATE stages.status 为 in-progress(仅当仍为 pending 时)
|
|
736
|
+
sqlDb.run(
|
|
737
|
+
"UPDATE stages SET status = 'in-progress', started_at = ? WHERE change_id = ? AND stage = ? AND status = 'pending'",
|
|
738
|
+
[now, changeId, stage]
|
|
739
|
+
);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
// read() 已改为 SQL,直接通过 SQL 查询即可,无需 _write
|
|
743
|
+
console.log(`✅ 当前阶段已设为: ${STAGE_LABELS[stage] || stage}`);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async addStep(cwd, stage, stepName, changeName = null) {
|
|
747
|
+
if (!stepName) { console.log('❌ 请指定步骤名称'); return; }
|
|
748
|
+
|
|
749
|
+
const db = await this._ensureDB(cwd);
|
|
750
|
+
|
|
751
|
+
// 获取变更名
|
|
752
|
+
let cn = changeName;
|
|
753
|
+
if (!cn) {
|
|
754
|
+
const changes = await this.listChanges(cwd);
|
|
755
|
+
if (changes.length === 1) cn = changes[0];
|
|
756
|
+
if (!cn) { console.log('❌ 无法确定当前变更,请指定 --change <name>'); return; }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// 查找 stage_id
|
|
760
|
+
const sqlDb = db.getDb();
|
|
761
|
+
const stageRow = sqlDb.exec(
|
|
762
|
+
'SELECT s.id FROM stages s JOIN changes c ON s.change_id = c.id WHERE c.name = ? AND s.stage = ?',
|
|
763
|
+
[cn, stage]
|
|
764
|
+
);
|
|
765
|
+
if (!stageRow || stageRow.length === 0 || stageRow[0].values.length === 0) {
|
|
766
|
+
// stages 行不存在,静默跳过
|
|
767
|
+
console.log(`ℹ️ 阶段 ${stage} 不存在`);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const stageId = stageRow[0].values[0][0];
|
|
771
|
+
|
|
772
|
+
// 重复步骤名检查
|
|
773
|
+
const dupRow = sqlDb.exec('SELECT id FROM steps WHERE stage_id = ? AND name = ?', [stageId, stepName]);
|
|
774
|
+
if (dupRow && dupRow.length > 0 && dupRow[0].values.length > 0) {
|
|
775
|
+
console.log(`ℹ️ 步骤 "${stepName}" 已存在于 ${stage}`);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// INSERT INTO steps(ordering 递增)
|
|
780
|
+
db.transaction((tDb) => {
|
|
781
|
+
tDb.run(
|
|
782
|
+
`INSERT INTO steps (stage_id, name, ordering, status)
|
|
783
|
+
VALUES (?, ?, (SELECT COALESCE(MAX(ordering), 0) + 1 FROM steps WHERE stage_id = ?), 'pending')`,
|
|
784
|
+
[stageId, stepName, stageId]
|
|
785
|
+
);
|
|
786
|
+
tDb.run('UPDATE changes SET last_active = ? WHERE name = ?', [new Date().toISOString(), cn]);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
console.log(`✅ 已添加步骤: ${stage}/${stepName}`);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async updateStep(cwd, stage, stepName, options = {}, changeName = null) {
|
|
793
|
+
const { status, output } = options;
|
|
794
|
+
if (!stepName) { console.log('❌ 请指定步骤名称'); return; }
|
|
795
|
+
|
|
796
|
+
const db = await this._ensureDB(cwd);
|
|
797
|
+
|
|
798
|
+
// 获取变更名
|
|
799
|
+
let cn = changeName;
|
|
800
|
+
if (!cn) {
|
|
801
|
+
const changes = await this.listChanges(cwd);
|
|
802
|
+
if (changes.length === 1) cn = changes[0];
|
|
803
|
+
if (!cn) { console.log('❌ 无法确定当前变更,请指定 --change <name>'); return; }
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// 状态校验
|
|
807
|
+
if (status && !VALID_STATUSES.includes(status)) {
|
|
808
|
+
console.log(`❌ 无效状态: ${status},可选: ${VALID_STATUSES.join(', ')}`);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// 查找 step_id:通过 changes → stages → steps JOIN 查询
|
|
813
|
+
const sqlDb = db.getDb();
|
|
814
|
+
const stepRow = sqlDb.exec(
|
|
815
|
+
`SELECT st.id, st.status FROM steps st
|
|
816
|
+
JOIN stages sg ON st.stage_id = sg.id
|
|
817
|
+
JOIN changes c ON sg.change_id = c.id
|
|
818
|
+
WHERE c.name = ? AND sg.stage = ? AND st.name = ?`,
|
|
819
|
+
[cn, stage, stepName]
|
|
820
|
+
);
|
|
821
|
+
if (!stepRow || stepRow.length === 0 || stepRow[0].values.length === 0) {
|
|
822
|
+
console.log(`❌ 步骤不存在: ${stage}/${stepName}`);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const stepId = stepRow[0].values[0][0];
|
|
826
|
+
|
|
827
|
+
// UPDATE steps
|
|
828
|
+
db.transaction((tDb) => {
|
|
829
|
+
const now = new Date().toISOString();
|
|
830
|
+
if (status) {
|
|
831
|
+
tDb.run('UPDATE steps SET status = ?, completed_at = ? WHERE id = ? AND name = ?', [status, now, stepId, stepName]);
|
|
832
|
+
}
|
|
833
|
+
if (output !== undefined) {
|
|
834
|
+
tDb.run('UPDATE steps SET output = ? WHERE id = ? AND name = ?', [output, stepId, stepName]);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// 自动完成检测:同 stage_id 下所有 steps 都 completed 时,标记 stage completed
|
|
838
|
+
if (status === 'completed') {
|
|
839
|
+
// 获取 stage_id
|
|
840
|
+
const stRow = tDb.exec('SELECT stage_id FROM steps WHERE id = ?', [stepId]);
|
|
841
|
+
if (stRow && stRow.length > 0 && stRow[0].values.length > 0) {
|
|
842
|
+
const stId = stRow[0].values[0][0];
|
|
843
|
+
const pendingRows = tDb.exec('SELECT COUNT(*) FROM steps WHERE stage_id = ? AND status != "completed"', [stId]);
|
|
844
|
+
if (pendingRows && pendingRows.length > 0 && pendingRows[0].values[0][0] === 0) {
|
|
845
|
+
tDb.run('UPDATE stages SET status = "completed", completed_at = ? WHERE id = ?', [now, stId]);
|
|
846
|
+
console.log(`✅ 阶段 ${stage} 所有步骤已完成,阶段已标记为 completed`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
tDb.run('UPDATE changes SET last_active = ? WHERE name = ?', [now, cn]);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
console.log(`✅ 步骤已更新: ${stage}/${stepName} → ${status || '(仅更新 output)'}`);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async completeStage(cwd, stage, changeName = null) {
|
|
858
|
+
if (!VALID_STAGES.includes(stage)) {
|
|
859
|
+
console.log(`❌ 未知阶段: ${stage}`);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const db = await this._ensureDB(cwd);
|
|
864
|
+
const now = new Date().toISOString();
|
|
865
|
+
|
|
866
|
+
// 获取变更名
|
|
867
|
+
let cn = changeName;
|
|
868
|
+
if (!cn) {
|
|
869
|
+
const changes = await this.listChanges(cwd);
|
|
870
|
+
if (changes.length === 1) cn = changes[0];
|
|
871
|
+
if (!cn) { console.log('❌ 无法确定当前变更,请指定 --change <name>'); return; }
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
db.transaction((sqlDb) => {
|
|
875
|
+
const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [cn]);
|
|
876
|
+
if (!changeRow || changeRow.length === 0 || changeRow[0].values.length === 0) return;
|
|
877
|
+
const changeId = changeRow[0].values[0][0];
|
|
878
|
+
|
|
879
|
+
// 确保 stages 行存在(阶段不存在时自动创建)
|
|
880
|
+
sqlDb.run(
|
|
881
|
+
'INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, "pending")',
|
|
882
|
+
[changeId, stage]
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
// UPDATE stages.status=completed + completed_at
|
|
886
|
+
sqlDb.run(
|
|
887
|
+
'UPDATE stages SET status = "completed", completed_at = ? WHERE change_id = ? AND stage = ?',
|
|
888
|
+
[now, changeId, stage]
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
// 将该阶段所有 pending 步骤标记为 completed
|
|
892
|
+
const stageRow = sqlDb.exec('SELECT id FROM stages WHERE change_id = ? AND stage = ?', [changeId, stage]);
|
|
893
|
+
if (stageRow && stageRow.length > 0 && stageRow[0].values.length > 0) {
|
|
894
|
+
const stageId = stageRow[0].values[0][0];
|
|
895
|
+
sqlDb.run(
|
|
896
|
+
'UPDATE steps SET status = "completed", completed_at = ? WHERE stage_id = ? AND status = "pending"',
|
|
897
|
+
[now, stageId]
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// UPDATE changes.last_active
|
|
902
|
+
sqlDb.run('UPDATE changes SET last_active = ? WHERE name = ?', [now, cn]);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
// 写 history 文件(保持文件系统,不变)
|
|
906
|
+
const data = await this.read(cwd, cn);
|
|
907
|
+
if (data && data.stages && data.stages[stage]) {
|
|
908
|
+
const historyDir = this._runtimePath(cwd, 'history');
|
|
909
|
+
mkdirSync(historyDir, { recursive: true });
|
|
910
|
+
const ts = now.replace(/[:.TZ-]/g, '');
|
|
911
|
+
const stageData = data.stages[stage];
|
|
912
|
+
writeFileSync(
|
|
913
|
+
join(historyDir, `${cn}-${stage}-${ts}.json`),
|
|
914
|
+
JSON.stringify({ change: cn, stage, data: stageData, completedAt: now }, null, 2) + '\n'
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
console.log(`✅ 阶段 ${stage} 已标记为完成(不自动推进,下一步由你决定)`);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async show(cwd, changeName = null) {
|
|
922
|
+
// 如果指定了变更名,只显示该变更
|
|
923
|
+
if (changeName) {
|
|
924
|
+
return await this._showChange(cwd, changeName);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// 否则显示所有变更
|
|
928
|
+
const changes = await this.listChanges(cwd);
|
|
929
|
+
if (changes.length === 0) {
|
|
930
|
+
console.log('ℹ️ 没有活跃的变更');
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (changes.length === 1) {
|
|
935
|
+
return await this._showChange(cwd, changes[0]);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// 多个变更:汇总显示
|
|
939
|
+
const global = await this.readGlobal(cwd);
|
|
940
|
+
console.log('');
|
|
941
|
+
console.log(' ═══════════════════════════════════════');
|
|
942
|
+
console.log(` 项目: ${(global?.project) || basename(cwd) || '(未命名)'}`);
|
|
943
|
+
console.log(` 活跃变更: ${changes.length} 个`);
|
|
944
|
+
console.log(' ═══════════════════════════════════════');
|
|
945
|
+
console.log('');
|
|
946
|
+
|
|
947
|
+
for (const cn of changes) {
|
|
948
|
+
const data = await this.read(cwd, cn);
|
|
949
|
+
if (!data) {
|
|
950
|
+
console.log(` 📂 ${cn} — (无法读取)`);
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
const currentStage = data.currentStage || '(无)';
|
|
954
|
+
const stageLabel = STAGE_LABELS[data.currentStage] || currentStage;
|
|
955
|
+
const lastActive = data.lastActive ? this._timeAgo(data.lastActive) : '未知';
|
|
956
|
+
|
|
957
|
+
console.log(` 📂 ${cn}`);
|
|
958
|
+
console.log(` 当前阶段: ${stageLabel} 最近活跃: ${lastActive}`);
|
|
959
|
+
console.log('');
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
console.log(` 💡 查看详情:sillyspec progress show --change <name>`);
|
|
963
|
+
console.log('');
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
async _showChange(cwd, changeName) {
|
|
967
|
+
const data = await this.read(cwd, changeName);
|
|
968
|
+
if (!data) {
|
|
969
|
+
console.log(`❌ 未找到变更 ${changeName}`);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
console.log('');
|
|
974
|
+
console.log(' ═══════════════════════════════════════');
|
|
975
|
+
console.log(` 变更: ${changeName}`);
|
|
976
|
+
console.log(` 项目: ${data.project || '(未命名)'}`);
|
|
977
|
+
console.log(` 当前阶段: ${STAGE_LABELS[data.currentStage] || data.currentStage || '(无)'}`);
|
|
978
|
+
console.log(` 最近活跃: ${data.lastActive ? this._timeAgo(data.lastActive) : '未知'}`);
|
|
979
|
+
console.log(' ═══════════════════════════════════════');
|
|
980
|
+
console.log('');
|
|
981
|
+
|
|
982
|
+
const statusIcons = { pending: '⬜', 'in-progress': '🔵', completed: '✅', failed: '❌', blocked: '🚫', waiting: '⏸️', revising: '🔧', stale: '⚠️' };
|
|
983
|
+
|
|
984
|
+
for (const stage of VALID_STAGES) {
|
|
985
|
+
const stageData = data.stages[stage] || emptyStage();
|
|
986
|
+
const label = STAGE_LABELS[stage] || stage;
|
|
987
|
+
const icon = statusIcons[stageData.status] || '⬜';
|
|
988
|
+
const isCurrent = data.currentStage === stage ? ' ◀' : '';
|
|
989
|
+
|
|
990
|
+
console.log(` ${icon} ${label}${isCurrent}`);
|
|
991
|
+
|
|
992
|
+
// Show revision info
|
|
993
|
+
if (stageData.revision && stageData.revision > 0) {
|
|
994
|
+
console.log(` 📋 revision: ${stageData.revision}${stageData.reopenedFromStep ? `, from step: ${stageData.reopenedFromStep}` : ''}`);
|
|
995
|
+
}
|
|
996
|
+
if (stageData.staleReason) {
|
|
997
|
+
console.log(` ⚠️ stale: ${stageData.staleReason}`);
|
|
998
|
+
if (stage === 'archive') {
|
|
999
|
+
console.log(` 📁 已有归档文件仍保留在磁盘上,但不再可信。`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (stageData.steps && stageData.steps.length > 0) {
|
|
1004
|
+
for (const step of stageData.steps) {
|
|
1005
|
+
const si = statusIcons[step.status] || '○';
|
|
1006
|
+
const out = step.output ? ` — ${step.output.slice(0, 60)}` : '';
|
|
1007
|
+
const waitingTag = step.status === 'waiting' ? ' [WAITING]' : ''
|
|
1008
|
+
console.log(` ${si} ${step.name}${out}${waitingTag}`);
|
|
1009
|
+
if (step.status === 'waiting') {
|
|
1010
|
+
if (step.waitReason) console.log(` 原因:${step.waitReason}`);
|
|
1011
|
+
if (step.waitOptions) console.log(` 选项:${(() => { try { const p = JSON.parse(step.waitOptions); return Array.isArray(p) ? p.join(', ') : step.waitOptions; } catch { return step.waitOptions; }})()}`);
|
|
1012
|
+
if (step.waitedAt) console.log(` 等待时间:${step.waitedAt}`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (stageData.startedAt) {
|
|
1018
|
+
console.log(` 开始: ${new Date(stageData.startedAt).toLocaleString('zh-CN')}`);
|
|
1019
|
+
}
|
|
1020
|
+
if (stageData.completedAt) {
|
|
1021
|
+
console.log(` 完成: ${new Date(stageData.completedAt).toLocaleString('zh-CN')}`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// 批量进度
|
|
1026
|
+
if (data.batchProgress) {
|
|
1027
|
+
const batchLine = this._renderBatchProgress(data.batchProgress);
|
|
1028
|
+
if (batchLine) {
|
|
1029
|
+
console.log('');
|
|
1030
|
+
console.log(` ${batchLine}`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ── Next 建议 ──
|
|
1035
|
+
const suggestion = this._getNextSuggestion(data);
|
|
1036
|
+
if (suggestion) {
|
|
1037
|
+
console.log('');
|
|
1038
|
+
console.log(` 💡 ${suggestion.text}`);
|
|
1039
|
+
if (suggestion.command) console.log(` ${suggestion.command}`);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
console.log('');
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* 根据当前状态给出下一步建议
|
|
1047
|
+
* @param {object} data - progress data
|
|
1048
|
+
* @returns {{ text: string, command?: string }|null}
|
|
1049
|
+
*/
|
|
1050
|
+
_getNextSuggestion(data) {
|
|
1051
|
+
// 找到第一个 revising 阶段
|
|
1052
|
+
const revisingStage = STAGE_ORDER.find(s => data.stages[s]?.status === 'revising');
|
|
1053
|
+
if (revisingStage) {
|
|
1054
|
+
const sd = data.stages[revisingStage];
|
|
1055
|
+
return {
|
|
1056
|
+
text: `${STAGE_LABELS[revisingStage] || revisingStage} 正在修订中(revision ${sd.revision || 1}),请继续完成修订。`,
|
|
1057
|
+
command: `sillyspec run ${revisingStage}`,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// 找到第一个 stale 阶段(上游已修,下游需要重建)
|
|
1062
|
+
const staleStage = STAGE_ORDER.find(s => data.stages[s]?.status === 'stale');
|
|
1063
|
+
if (staleStage) {
|
|
1064
|
+
const sd = data.stages[staleStage];
|
|
1065
|
+
return {
|
|
1066
|
+
text: `${STAGE_LABELS[staleStage] || staleStage} 已失效(${sd.staleReason || '上游修订'}),需要从第一步重建。`,
|
|
1067
|
+
command: `sillyspec run ${staleStage} --reopen --from-step 1`,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// 找到第一个有 pending/waiting/failed 步骤的 in-progress 阶段
|
|
1072
|
+
for (const s of STAGE_ORDER) {
|
|
1073
|
+
const sd = data.stages[s];
|
|
1074
|
+
if (!sd) continue;
|
|
1075
|
+
if (sd.status === 'in-progress' && sd.steps) {
|
|
1076
|
+
const hasPending = sd.steps.some(st => ['pending', 'waiting', 'failed'].includes(st.status));
|
|
1077
|
+
if (hasPending) {
|
|
1078
|
+
return {
|
|
1079
|
+
text: `${STAGE_LABELS[s] || s} 进行中,继续执行下一步。`,
|
|
1080
|
+
command: `sillyspec run ${s}`,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// 找到第一个 pending 主流程阶段
|
|
1087
|
+
for (const s of STAGE_ORDER) {
|
|
1088
|
+
const sd = data.stages[s];
|
|
1089
|
+
if (sd && sd.status === 'pending' && sd.steps && sd.steps.length > 0) {
|
|
1090
|
+
// 检查上游是否都 completed
|
|
1091
|
+
const idx = STAGE_ORDER.indexOf(s);
|
|
1092
|
+
const upstream = STAGE_ORDER.slice(0, idx);
|
|
1093
|
+
const upstreamOk = upstream.every(us =>
|
|
1094
|
+
data.stages[us]?.status === 'completed' || !data.stages[us] || data.stages[us].status === 'pending'
|
|
1095
|
+
);
|
|
1096
|
+
if (upstreamOk) {
|
|
1097
|
+
return {
|
|
1098
|
+
text: `可以开始 ${STAGE_LABELS[s] || s}。`,
|
|
1099
|
+
command: `sillyspec run ${s}`,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async status(cwd, changeName = null) {
|
|
1109
|
+
await this.show(cwd, changeName);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Revision v1 状态一致性检查
|
|
1114
|
+
* 只报告,不自动修复。
|
|
1115
|
+
* @param {string} cwd
|
|
1116
|
+
* @param {string|null} changeName
|
|
1117
|
+
* @returns {{ ok: boolean, issues: string[], warnings: string[] }}
|
|
1118
|
+
*/
|
|
1119
|
+
async checkConsistency(cwd, changeName = null) {
|
|
1120
|
+
const data = await this.read(cwd, changeName);
|
|
1121
|
+
if (!data) {
|
|
1122
|
+
return { ok: false, issues: ['无法读取进度数据'], warnings: [] };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const issues = [];
|
|
1126
|
+
const warnings = [];
|
|
1127
|
+
|
|
1128
|
+
for (const stageName of STAGE_ORDER) {
|
|
1129
|
+
const sd = data.stages[stageName];
|
|
1130
|
+
if (!sd) continue;
|
|
1131
|
+
|
|
1132
|
+
// a. completed stage 不能有 pending/stale steps
|
|
1133
|
+
if (sd.status === 'completed' && sd.steps) {
|
|
1134
|
+
const badSteps = sd.steps.filter(s => ['pending', 'stale', 'in-progress'].includes(s.status));
|
|
1135
|
+
for (const step of badSteps) {
|
|
1136
|
+
issues.push(`${stageName}/${step.name}: step 状态为 ${step.status},但 stage 状态为 completed`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// b. revising stage 应有 revision > 0 或 reopenedFromStep
|
|
1141
|
+
if (sd.status === 'revising') {
|
|
1142
|
+
if (!sd.revision || sd.revision < 1) {
|
|
1143
|
+
issues.push(`${stageName}: 状态为 revising 但 revision 缺失或为 0`);
|
|
1144
|
+
}
|
|
1145
|
+
if (!sd.reopenedFromStep) {
|
|
1146
|
+
warnings.push(`${stageName}: 状态为 revising 但未记录 reopenedFromStep`);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// c. stale stage 应有 staleReason
|
|
1151
|
+
if (sd.status === 'stale') {
|
|
1152
|
+
if (!sd.staleReason) {
|
|
1153
|
+
warnings.push(`${stageName}: 状态为 stale 但缺少 staleReason`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// d. 下游 completed 不能出现在上游 stale/revising 之后
|
|
1158
|
+
const stageIdx = STAGE_ORDER.indexOf(stageName);
|
|
1159
|
+
for (let i = 0; i < stageIdx; i++) {
|
|
1160
|
+
const upstream = STAGE_ORDER[i];
|
|
1161
|
+
const upData = data.stages[upstream];
|
|
1162
|
+
if (upData && (upData.status === 'stale' || upData.status === 'revising')) {
|
|
1163
|
+
if (sd.status === 'completed') {
|
|
1164
|
+
issues.push(`${stageName}: 状态为 completed,但上游 ${upstream} 状态为 ${upData.status}(下游不应在上游修订/失效时保持 completed)`);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// e. step stale 时 stage 不应是 completed
|
|
1170
|
+
if (sd.status === 'completed' && sd.steps) {
|
|
1171
|
+
const staleSteps = sd.steps.filter(s => s.status === 'stale');
|
|
1172
|
+
for (const step of staleSteps) {
|
|
1173
|
+
issues.push(`${stageName}/${step.name}: step 状态为 stale,但 stage 状态为 completed`);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// 输出报告
|
|
1179
|
+
console.log('');
|
|
1180
|
+
console.log(' ═══════════════════════════════════════');
|
|
1181
|
+
console.log(' 状态一致性检查');
|
|
1182
|
+
console.log(' ═══════════════════════════════════════');
|
|
1183
|
+
|
|
1184
|
+
if (issues.length === 0 && warnings.length === 0) {
|
|
1185
|
+
console.log(' ✅ 未发现一致性问题');
|
|
1186
|
+
} else {
|
|
1187
|
+
if (issues.length > 0) {
|
|
1188
|
+
console.log(`\n ❌ 问题 (${issues.length}):`);
|
|
1189
|
+
for (const issue of issues) console.log(` - ${issue}`);
|
|
1190
|
+
}
|
|
1191
|
+
if (warnings.length > 0) {
|
|
1192
|
+
console.log(`\n ⚠️ 警告 (${warnings.length}):`);
|
|
1193
|
+
for (const w of warnings) console.log(` - ${w}`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
console.log('');
|
|
1197
|
+
|
|
1198
|
+
return { ok: issues.length === 0, issues, warnings };
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Revision v1.2 状态修复
|
|
1203
|
+
* 默认 dry-run,--apply 才真正修改 DB。
|
|
1204
|
+
* 只修安全项,不碰产物文件、不 reset/reopen stage。
|
|
1205
|
+
*
|
|
1206
|
+
* @param {string} cwd
|
|
1207
|
+
* @param {object} opts
|
|
1208
|
+
* @param {boolean} [opts.apply=false]
|
|
1209
|
+
* @param {string|null} [opts.changeName]
|
|
1210
|
+
* @returns {{ fixable: object[], manual: string[], applied: object[] }}
|
|
1211
|
+
*/
|
|
1212
|
+
async repairConsistency(cwd, opts = {}) {
|
|
1213
|
+
const { apply = false, changeName = null } = opts;
|
|
1214
|
+
|
|
1215
|
+
const data = await this.read(cwd, changeName);
|
|
1216
|
+
if (!data) {
|
|
1217
|
+
console.log('❌ 无法读取进度数据');
|
|
1218
|
+
return { fixable: [], manual: ['无法读取进度数据'], applied: [] };
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const fixable = []; // { stage, action, description, apply: (data) => void }
|
|
1222
|
+
const manual = []; // string
|
|
1223
|
+
|
|
1224
|
+
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
|
1225
|
+
|
|
1226
|
+
for (const stageName of STAGE_ORDER) {
|
|
1227
|
+
const sd = data.stages[stageName];
|
|
1228
|
+
if (!sd) continue;
|
|
1229
|
+
|
|
1230
|
+
// Fix a: stale stage 缺 staleReason → 补默认原因
|
|
1231
|
+
if (sd.status === 'stale' && !sd.staleReason) {
|
|
1232
|
+
const reason = stageName === 'archive'
|
|
1233
|
+
? 'upstream stage revised; existing archive artifacts are preserved but no longer trusted'
|
|
1234
|
+
: 'unknown upstream revision';
|
|
1235
|
+
fixable.push({
|
|
1236
|
+
stage: stageName,
|
|
1237
|
+
action: 'set_stale_reason',
|
|
1238
|
+
description: `${stageName}: stale 缺 staleReason → 补 "${reason}"`,
|
|
1239
|
+
apply: (d) => { d.stages[stageName].staleReason = reason; },
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Fix b: 上游 stale/revising,下游仍 completed → cascade stale
|
|
1244
|
+
const stageIdx = STAGE_ORDER.indexOf(stageName);
|
|
1245
|
+
for (let i = 0; i < stageIdx; i++) {
|
|
1246
|
+
const upstream = STAGE_ORDER[i];
|
|
1247
|
+
const upData = data.stages[upstream];
|
|
1248
|
+
if (upData && (upData.status === 'stale' || upData.status === 'revising')) {
|
|
1249
|
+
if (sd.status === 'completed') {
|
|
1250
|
+
const upStatus = upData.status;
|
|
1251
|
+
const reason = `upstream ${upstream} is ${upStatus}`;
|
|
1252
|
+
fixable.push({
|
|
1253
|
+
stage: stageName,
|
|
1254
|
+
action: 'cascade_stale',
|
|
1255
|
+
description: `${stageName}: completed → stale(上游 ${upstream} 为 ${upStatus})`,
|
|
1256
|
+
apply: (d) => {
|
|
1257
|
+
d.stages[stageName].status = 'stale';
|
|
1258
|
+
d.stages[stageName].staleReason = reason;
|
|
1259
|
+
d.stages[stageName].completedAt = null;
|
|
1260
|
+
},
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Fix c: archive stale 缺 staleReason(专用文案)
|
|
1267
|
+
if (stageName === 'archive' && sd.status === 'stale' && !sd.staleReason) {
|
|
1268
|
+
// 已在 Fix a 中处理,这里不重复
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Fix d: revising stage 缺 reopenedAt → 补当前时间
|
|
1272
|
+
if (sd.status === 'revising' && !sd.reopenedAt) {
|
|
1273
|
+
fixable.push({
|
|
1274
|
+
stage: stageName,
|
|
1275
|
+
action: 'set_reopened_at',
|
|
1276
|
+
description: `${stageName}: revising 缺 reopenedAt → 补当前时间`,
|
|
1277
|
+
apply: (d) => { d.stages[stageName].reopenedAt = now; },
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// Manual a: completed stage 里有 pending/stale/in-progress steps
|
|
1282
|
+
if (sd.status === 'completed' && sd.steps) {
|
|
1283
|
+
const badSteps = sd.steps.filter(s => ['pending', 'stale', 'in-progress'].includes(s.status));
|
|
1284
|
+
for (const step of badSteps) {
|
|
1285
|
+
manual.push(`${stageName}/${step.name}: step 状态为 ${step.status},但 stage 状态为 completed(需手动确认)`);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Manual b: revising stage 缺 reopenedFromStep
|
|
1290
|
+
if (sd.status === 'revising' && !sd.reopenedFromStep) {
|
|
1291
|
+
manual.push(`${stageName}: revising 缺 reopenedFromStep(需手动确认修订起始步骤)`);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Manual c: steps 为空但 stage completed
|
|
1295
|
+
if (sd.status === 'completed' && (!sd.steps || sd.steps.length === 0)) {
|
|
1296
|
+
manual.push(`${stageName}: completed 但 steps 为空(需手动确认)`);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// 输出报告
|
|
1301
|
+
console.log('');
|
|
1302
|
+
console.log(' ═══════════════════════════════════════');
|
|
1303
|
+
console.log(` 状态修复 ${apply ? '(--apply 模式)' : '(dry-run 模式)'}`);
|
|
1304
|
+
console.log(' ═══════════════════════════════════════');
|
|
1305
|
+
|
|
1306
|
+
if (fixable.length === 0 && manual.length === 0) {
|
|
1307
|
+
console.log(' ✅ 未发现问题,无需修复');
|
|
1308
|
+
console.log('');
|
|
1309
|
+
return { fixable: [], manual: [], applied: [] };
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const applied = [];
|
|
1313
|
+
|
|
1314
|
+
if (fixable.length > 0) {
|
|
1315
|
+
console.log(`\n 🔧 可自动修复 (${fixable.length}):`);
|
|
1316
|
+
for (const item of fixable) {
|
|
1317
|
+
console.log(` - ${item.description}`);
|
|
1318
|
+
if (apply) {
|
|
1319
|
+
item.apply(data);
|
|
1320
|
+
applied.push({ stage: item.stage, action: item.action });
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
if (!apply) {
|
|
1324
|
+
console.log('\n 💡 使用 --apply 执行修复');
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (manual.length > 0) {
|
|
1329
|
+
console.log(`\n 👆 需手动处理 (${manual.length}):`);
|
|
1330
|
+
for (const m of manual) console.log(` - ${m}`);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (apply && applied.length > 0) {
|
|
1334
|
+
data.lastActive = now;
|
|
1335
|
+
await this._write(cwd, data, changeName);
|
|
1336
|
+
console.log(`\n ✅ 已修复 ${applied.length} 项`);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
console.log('');
|
|
1340
|
+
|
|
1341
|
+
return { fixable, manual, applied };
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
async validate(cwd, changeName = null) {
|
|
1345
|
+
const data = await this.read(cwd, changeName);
|
|
1346
|
+
if (!data) { console.log('❌ 无法读取进度数据'); return false; }
|
|
1347
|
+
|
|
1348
|
+
const errors = [];
|
|
1349
|
+
if (!data._version || !Number.isInteger(data._version) || data._version < 1) {
|
|
1350
|
+
errors.push(`_version 缺失或无效(期望正整数,实际为 ${JSON.stringify(data._version)})`);
|
|
1351
|
+
}
|
|
1352
|
+
if (!data.stages || typeof data.stages !== 'object') errors.push('缺少 stages');
|
|
1353
|
+
if (!VALID_STAGES.every(s => data.stages[s])) errors.push('缺少阶段定义');
|
|
1354
|
+
|
|
1355
|
+
if (errors.length === 0) { console.log('✅ 进度数据格式正确'); return true; }
|
|
1356
|
+
|
|
1357
|
+
console.log(`⚠️ 发现问题,尝试修复...`);
|
|
1358
|
+
let fixed = { ...data, stages: { ...data.stages } };
|
|
1359
|
+
let changed = false;
|
|
1360
|
+
if (!fixed.project) {
|
|
1361
|
+
fixed.project = basename(cwd);
|
|
1362
|
+
changed = true;
|
|
1363
|
+
}
|
|
1364
|
+
if (!fixed._version || !Number.isInteger(fixed._version) || fixed._version < 1) {
|
|
1365
|
+
fixed._version = CURRENT_VERSION;
|
|
1366
|
+
changed = true;
|
|
1367
|
+
}
|
|
1368
|
+
for (const s of VALID_STAGES) {
|
|
1369
|
+
if (!fixed.stages[s]) { fixed.stages[s] = emptyStage(); changed = true; }
|
|
1370
|
+
}
|
|
1371
|
+
if (changed) {
|
|
1372
|
+
await this._write(cwd, fixed);
|
|
1373
|
+
console.log('✅ 已修复');
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* 重新打开已完成的阶段进入修订模式
|
|
1381
|
+
* - 不带 fromStep:只允许存在 pending/stale/waiting/failed 步骤时继续
|
|
1382
|
+
* - 带 fromStep:从该步骤起,当前及后续步骤标记 stale/pending
|
|
1383
|
+
* - 自动级联标记下游阶段为 stale
|
|
1384
|
+
*
|
|
1385
|
+
* @param {string} cwd
|
|
1386
|
+
* @param {string} stage - 要重开的阶段
|
|
1387
|
+
* @param {object} opts
|
|
1388
|
+
* @param {string|number} [opts.fromStep] - 步骤名或序号(1-based)
|
|
1389
|
+
* @param {string} [opts.changeName]
|
|
1390
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
1391
|
+
*/
|
|
1392
|
+
async reopenStage(cwd, stage, opts = {}) {
|
|
1393
|
+
const { fromStep, changeName = null } = opts;
|
|
1394
|
+
|
|
1395
|
+
const data = await this.read(cwd, changeName);
|
|
1396
|
+
if (!data) return { ok: false, error: '无法读取进度数据' };
|
|
1397
|
+
|
|
1398
|
+
const stageData = data.stages[stage];
|
|
1399
|
+
if (!stageData) return { ok: false, error: `未知阶段: ${stage}` };
|
|
1400
|
+
|
|
1401
|
+
const steps = stageData.steps || [];
|
|
1402
|
+
|
|
1403
|
+
// 确定 fromStep 对应的 index
|
|
1404
|
+
let fromIdx = null;
|
|
1405
|
+
if (fromStep != null) {
|
|
1406
|
+
if (typeof fromStep === 'number' || /^\d+$/.test(String(fromStep))) {
|
|
1407
|
+
fromIdx = parseInt(String(fromStep), 10) - 1; // 1-based → 0-based
|
|
1408
|
+
if (fromIdx < 0 || fromIdx >= steps.length) {
|
|
1409
|
+
return { ok: false, error: `步骤序号超出范围: ${fromStep}(共 ${steps.length} 步)` };
|
|
1410
|
+
}
|
|
1411
|
+
} else {
|
|
1412
|
+
// 按名称匹配
|
|
1413
|
+
fromIdx = steps.findIndex(s => s.name === fromStep);
|
|
1414
|
+
if (fromIdx === -1) {
|
|
1415
|
+
return { ok: false, error: `步骤不存在: ${fromStep}` };
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// 如果不带 fromStep,检查是否存在中断步骤
|
|
1421
|
+
if (fromIdx === null) {
|
|
1422
|
+
const hasInterrupted = steps.some(s =>
|
|
1423
|
+
['pending', 'stale', 'waiting', 'failed'].includes(s.status)
|
|
1424
|
+
);
|
|
1425
|
+
if (!hasInterrupted) {
|
|
1426
|
+
return { ok: false, error: `阶段 ${stage} 所有步骤均已完成,请使用 --from-step 指定从哪一步开始修订` };
|
|
1427
|
+
}
|
|
1428
|
+
// 找到第一个中断步骤
|
|
1429
|
+
fromIdx = steps.findIndex(s =>
|
|
1430
|
+
['pending', 'stale', 'waiting', 'failed'].includes(s.status)
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// 执行重开操作
|
|
1435
|
+
const newRevision = (stageData.revision || 0) + 1;
|
|
1436
|
+
const fromStepName = steps[fromIdx].name;
|
|
1437
|
+
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
|
1438
|
+
|
|
1439
|
+
// 更新步骤状态:fromStep 之前的保持 completed,fromStep 变 pending,之后的变 stale
|
|
1440
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1441
|
+
if (i === fromIdx) {
|
|
1442
|
+
steps[i].status = 'pending';
|
|
1443
|
+
steps[i].completedAt = null;
|
|
1444
|
+
steps[i].output = null;
|
|
1445
|
+
} else if (i > fromIdx) {
|
|
1446
|
+
steps[i].status = 'stale';
|
|
1447
|
+
steps[i].completedAt = null;
|
|
1448
|
+
}
|
|
1449
|
+
// i < fromIdx: 保持原状(completed)
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
stageData.status = 'revising';
|
|
1453
|
+
stageData.completedAt = null;
|
|
1454
|
+
stageData.revision = newRevision;
|
|
1455
|
+
stageData.reopenedFromStep = `${fromIdx + 1}: ${fromStepName}`; // 存 "index: name" 格式
|
|
1456
|
+
stageData.reopenedAt = now;
|
|
1457
|
+
stageData.steps = steps;
|
|
1458
|
+
|
|
1459
|
+
data.lastActive = now;
|
|
1460
|
+
data.currentStage = stage;
|
|
1461
|
+
|
|
1462
|
+
await this._write(cwd, data, changeName);
|
|
1463
|
+
|
|
1464
|
+
// 级联标记下游阶段为 stale
|
|
1465
|
+
const downstreamStages = this._getDownstreamStages(stage);
|
|
1466
|
+
if (downstreamStages.length > 0) {
|
|
1467
|
+
const data2 = await this.read(cwd, changeName); // 重新读取以获取最新状态
|
|
1468
|
+
if (data2) {
|
|
1469
|
+
for (const ds of downstreamStages) {
|
|
1470
|
+
if (data2.stages[ds] && data2.stages[ds].status === 'completed') {
|
|
1471
|
+
data2.stages[ds].status = 'stale';
|
|
1472
|
+
data2.stages[ds].staleReason = `上游阶段 ${stage} 已修订 (revision ${newRevision})`;
|
|
1473
|
+
data2.stages[ds].completedAt = null;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
await this._write(cwd, data2, changeName);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
return { ok: true, revision: newRevision, fromStep: fromStepName };
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* 获取指定阶段的下游主流程阶段列表
|
|
1485
|
+
* @param {string} stage
|
|
1486
|
+
* @returns {string[]}
|
|
1487
|
+
*/
|
|
1488
|
+
_getDownstreamStages(stage) {
|
|
1489
|
+
const idx = MAIN_FLOW_ORDER.indexOf(stage);
|
|
1490
|
+
if (idx === -1) return [];
|
|
1491
|
+
return MAIN_FLOW_ORDER.slice(idx + 1);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
async reset(cwd, stage, changeName = null) {
|
|
1495
|
+
if (stage) {
|
|
1496
|
+
const data = await this.read(cwd, changeName);
|
|
1497
|
+
if (!data) { console.log('❌ 无法读取进度数据'); return; }
|
|
1498
|
+
if (!data.stages[stage]) { console.log(`❌ 未知阶段: ${stage}`); return; }
|
|
1499
|
+
data.stages[stage] = emptyStage();
|
|
1500
|
+
data.lastActive = new Date().toLocaleString('zh-CN',{hour12:false});
|
|
1501
|
+
await this._write(cwd, data);
|
|
1502
|
+
console.log(`✅ 已重置阶段: ${stage}`);
|
|
1503
|
+
} else {
|
|
1504
|
+
// 重置所有变更或指定变更
|
|
1505
|
+
if (changeName) {
|
|
1506
|
+
// SQL: 删除该变更的所有 stages 和 steps 数据
|
|
1507
|
+
const db = await this._ensureDB(cwd);
|
|
1508
|
+
db.transaction((sqlDb) => {
|
|
1509
|
+
const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
|
|
1510
|
+
if (changeRow && changeRow.length > 0 && changeRow[0].values.length > 0) {
|
|
1511
|
+
const changeId = changeRow[0].values[0][0];
|
|
1512
|
+
sqlDb.run('DELETE FROM steps WHERE stage_id IN (SELECT id FROM stages WHERE change_id = ?)', [changeId]);
|
|
1513
|
+
sqlDb.run('DELETE FROM stages WHERE change_id = ?', [changeId]);
|
|
1514
|
+
sqlDb.run('UPDATE stages SET status = "pending", started_at = NULL, completed_at = NULL WHERE change_id = ?', [changeId]);
|
|
1515
|
+
// 重新插入所有阶段
|
|
1516
|
+
for (const s of VALID_STAGES) {
|
|
1517
|
+
sqlDb.run('INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, "pending")', [changeId, s]);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
console.log(`✅ 已重置变更 ${changeName} 的进度`);
|
|
1522
|
+
} else {
|
|
1523
|
+
const changes = await this.listChanges(cwd);
|
|
1524
|
+
const db = await this._ensureDB(cwd);
|
|
1525
|
+
db.transaction((sqlDb) => {
|
|
1526
|
+
for (const cn of changes) {
|
|
1527
|
+
const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [cn]);
|
|
1528
|
+
if (changeRow && changeRow.length > 0 && changeRow[0].values.length > 0) {
|
|
1529
|
+
const changeId = changeRow[0].values[0][0];
|
|
1530
|
+
sqlDb.run('DELETE FROM steps WHERE stage_id IN (SELECT id FROM stages WHERE change_id = ?)', [changeId]);
|
|
1531
|
+
sqlDb.run('UPDATE stages SET status = "pending", started_at = NULL, completed_at = NULL WHERE change_id = ?', [changeId]);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
console.log('✅ 已重置所有变更的进度');
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// ── 内部辅助 ──
|
|
1541
|
+
|
|
1542
|
+
async _readOrInit(cwd, changeName = null) {
|
|
1543
|
+
let data = await this.read(cwd, changeName);
|
|
1544
|
+
if (!data) {
|
|
1545
|
+
// 尝试自动检测变更名
|
|
1546
|
+
if (!changeName) {
|
|
1547
|
+
const changes = await this.listChanges(cwd);
|
|
1548
|
+
if (changes.length === 1) changeName = changes[0];
|
|
1549
|
+
}
|
|
1550
|
+
if (changeName) {
|
|
1551
|
+
// 确保变更在 DB 中已初始化
|
|
1552
|
+
const db = await this._ensureDB(cwd);
|
|
1553
|
+
db.transaction((sqlDb) => {
|
|
1554
|
+
const now = new Date().toISOString();
|
|
1555
|
+
sqlDb.run(
|
|
1556
|
+
'INSERT OR IGNORE INTO changes (name, current_stage, status, created_at, last_active) VALUES (?, "scan", "active", ?, ?)',
|
|
1557
|
+
[changeName, now, now]
|
|
1558
|
+
);
|
|
1559
|
+
const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
|
|
1560
|
+
if (changeRow && changeRow.length > 0 && changeRow[0].values.length > 0) {
|
|
1561
|
+
const changeId = changeRow[0].values[0][0];
|
|
1562
|
+
for (const s of VALID_STAGES) {
|
|
1563
|
+
sqlDb.run('INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, "pending")', [changeId, s]);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
await this.registerChange(cwd, changeName);
|
|
1568
|
+
}
|
|
1569
|
+
if (!data) {
|
|
1570
|
+
data = await this.read(cwd, changeName);
|
|
1571
|
+
}
|
|
1572
|
+
if (!data) {
|
|
1573
|
+
console.log('❌ 无法确定当前变更,请指定 --change <name>');
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
return data;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
async _requireStage(cwd, stage, changeName = null) {
|
|
1581
|
+
if (!VALID_STAGES.includes(stage)) {
|
|
1582
|
+
console.log(`❌ 未知阶段: ${stage},可选: ${VALID_STAGES.join(', ')}`);
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
const data = await this._readOrInit(cwd, changeName);
|
|
1586
|
+
if (!data) return null;
|
|
1587
|
+
if (!data.stages[stage]) data.stages[stage] = emptyStage();
|
|
1588
|
+
return data;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
_timeAgo(dateStr) {
|
|
1592
|
+
if (!dateStr) return '未知';
|
|
1593
|
+
let ts = Date.parse(dateStr);
|
|
1594
|
+
if (isNaN(ts)) {
|
|
1595
|
+
const m = dateStr.match(/(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})[\s,]+(\d{1,2}):(\d{2})(?::(\d{2}))?/);
|
|
1596
|
+
if (m) ts = new Date(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +(m[6]||0)).getTime();
|
|
1597
|
+
}
|
|
1598
|
+
if (isNaN(ts)) return dateStr;
|
|
1599
|
+
const diff = Date.now() - ts;
|
|
1600
|
+
const minutes = Math.floor(diff / 60000);
|
|
1601
|
+
if (minutes < 1) return '刚刚';
|
|
1602
|
+
if (minutes < 60) return `${minutes} 分钟前`;
|
|
1603
|
+
const hours = Math.floor(minutes / 60);
|
|
1604
|
+
if (hours < 24) return `${hours} 小时前`;
|
|
1605
|
+
return `${Math.floor(hours / 24)} 天前`;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// ── 批量进度 ──
|
|
1609
|
+
|
|
1610
|
+
async updateBatchProgress(cwd, batchData, changeName = null) {
|
|
1611
|
+
const cn = changeName || null;
|
|
1612
|
+
|
|
1613
|
+
const db = await this._ensureDB(cwd);
|
|
1614
|
+
db.transaction((sqlDb) => {
|
|
1615
|
+
// 获取 change_id
|
|
1616
|
+
let changeId = null;
|
|
1617
|
+
if (cn) {
|
|
1618
|
+
const row = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [cn]);
|
|
1619
|
+
if (row && row.length > 0 && row[0].values.length > 0) changeId = row[0].values[0][0];
|
|
1620
|
+
}
|
|
1621
|
+
if (!changeId) {
|
|
1622
|
+
// 尝试从唯一活跃变更获取
|
|
1623
|
+
const rows = sqlDb.exec("SELECT id FROM changes WHERE status = 'active'");
|
|
1624
|
+
if (rows && rows.length > 0 && rows[0].values.length === 1) changeId = rows[0].values[0][0];
|
|
1625
|
+
}
|
|
1626
|
+
if (!changeId) return;
|
|
1627
|
+
|
|
1628
|
+
sqlDb.run(
|
|
1629
|
+
`INSERT INTO batch_progress (change_id, total, completed, failed, skipped)
|
|
1630
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1631
|
+
ON CONFLICT(change_id) DO UPDATE SET
|
|
1632
|
+
total = excluded.total,
|
|
1633
|
+
completed = excluded.completed,
|
|
1634
|
+
failed = excluded.failed,
|
|
1635
|
+
skipped = excluded.skipped`,
|
|
1636
|
+
[changeId, batchData.total || 0, batchData.completed || 0, batchData.failed || 0, batchData.skipped || 0]
|
|
1637
|
+
);
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
async readBatchProgress(cwd, changeName = null) {
|
|
1642
|
+
const data = await this.read(cwd, changeName);
|
|
1643
|
+
return data?.batchProgress || null;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
_renderBatchProgress(batchProgress) {
|
|
1647
|
+
if (!batchProgress || !batchProgress.total) return null;
|
|
1648
|
+
const { total, completed = 0, failed = 0, skipped = 0 } = batchProgress;
|
|
1649
|
+
const barLen = 20;
|
|
1650
|
+
const filled = Math.round((completed / total) * barLen);
|
|
1651
|
+
const bar = '█'.repeat(filled) + '░'.repeat(barLen - filled);
|
|
1652
|
+
const parts = [];
|
|
1653
|
+
if (failed > 0) parts.push(`${failed} 失败`);
|
|
1654
|
+
if (skipped > 0) parts.push(`${skipped} 跳过`);
|
|
1655
|
+
const suffix = parts.length ? ` (${parts.join(', ')})` : '';
|
|
1656
|
+
return `📊 批量进度: ${bar} ${completed}/${total}${suffix}`;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* 更新 gate-status.json,供 worktree-guard hook 读取
|
|
1661
|
+
* 从 SQLite 查询所有处于 execute/quick 阶段的活跃变更,生成或删除 gate-status.json
|
|
1662
|
+
*/
|
|
1663
|
+
async _updateGateStatus(cwd) {
|
|
1664
|
+
const db = await this._ensureDB(cwd);
|
|
1665
|
+
const sqlDb = db.getDb();
|
|
1666
|
+
|
|
1667
|
+
// SQL 查询:所有处于 execute/quick 阶段的活跃变更
|
|
1668
|
+
const rows = sqlDb.exec(
|
|
1669
|
+
`SELECT name, current_stage, no_worktree FROM changes
|
|
1670
|
+
WHERE status = 'active' AND current_stage IN ('execute', 'quick')`
|
|
1671
|
+
);
|
|
1672
|
+
|
|
1673
|
+
const gatePath = this._runtimePath(cwd, 'gate-status.json');
|
|
1674
|
+
|
|
1675
|
+
if (!rows || rows.length === 0 || rows[0].values.length === 0) {
|
|
1676
|
+
// 无 execute/quick 阶段的活跃变更,删除 gate-status
|
|
1677
|
+
if (existsSync(gatePath)) {
|
|
1678
|
+
try { unlinkSync(gatePath); } catch {}
|
|
1679
|
+
}
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
let gateStage = null;
|
|
1684
|
+
let hasNoWorktree = false;
|
|
1685
|
+
const activeChanges = [];
|
|
1686
|
+
|
|
1687
|
+
for (const [name, stage, noWorktree] of rows[0].values) {
|
|
1688
|
+
if (!stage) continue;
|
|
1689
|
+
// 优先取 execute,其次 quick
|
|
1690
|
+
if (gateStage !== 'execute' || stage === 'execute') {
|
|
1691
|
+
gateStage = stage;
|
|
1692
|
+
}
|
|
1693
|
+
activeChanges.push(name);
|
|
1694
|
+
if (noWorktree === 1) hasNoWorktree = true;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
if (!gateStage) {
|
|
1698
|
+
// current_stage 为 NULL 的边界情况,等同于无 execute/quick
|
|
1699
|
+
if (existsSync(gatePath)) {
|
|
1700
|
+
try { unlinkSync(gatePath); } catch {}
|
|
1701
|
+
}
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
try {
|
|
1706
|
+
this._ensureRuntimeDir(cwd);
|
|
1707
|
+
const gateData = {
|
|
1708
|
+
stage: gateStage,
|
|
1709
|
+
changes: activeChanges,
|
|
1710
|
+
updatedAt: new Date().toISOString(),
|
|
1711
|
+
...(hasNoWorktree ? { noWorktree: true } : {}),
|
|
1712
|
+
};
|
|
1713
|
+
const tmpPath = gatePath + '.tmp';
|
|
1714
|
+
writeFileSync(tmpPath, JSON.stringify(gateData, null, 2) + '\n');
|
|
1715
|
+
renameSync(tmpPath, gatePath);
|
|
1716
|
+
} catch (err) {
|
|
1717
|
+
console.warn('⚠️ 写入 gate-status.json 失败:', err.message);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
_ensureGitignore(cwd) {
|
|
1722
|
+
// 外部 specDir 不需要修改项目 .gitignore
|
|
1723
|
+
if (this._customSpecDir) return;
|
|
1724
|
+
const gitignorePath = join(cwd, '.gitignore');
|
|
1725
|
+
const rule = '.sillyspec/.runtime/';
|
|
1726
|
+
if (existsSync(gitignorePath)) {
|
|
1727
|
+
const content = readFileSync(gitignorePath, 'utf8');
|
|
1728
|
+
if (content.includes(rule)) return;
|
|
1729
|
+
writeFileSync(gitignorePath, content.trimEnd() + '\n' + rule + '\n');
|
|
1730
|
+
} else {
|
|
1731
|
+
writeFileSync(gitignorePath, rule + '\n');
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|