sillyspec 3.18.2 → 3.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/brainstorm-plan-contract.md +64 -0
- package/docs/plan-execute-contract.md +123 -0
- package/docs/revision-mode.md +115 -0
- package/docs/sillyspec/file-lifecycle.md +13 -4
- package/docs/workflow-contract-regression.md +106 -0
- package/package.json +1 -1
- package/packages/dashboard/dist/assets/{index-DpLHK4jv.js → index-Bq_Z2hne.js} +568 -568
- package/packages/dashboard/dist/assets/{index-BcM2J-hv.css → index-O2W5RV4z.css} +1 -1
- package/packages/dashboard/dist/index.html +16 -16
- package/packages/dashboard/src/components/PipelineStage.vue +22 -2
- package/packages/dashboard/src/components/PipelineView.vue +10 -2
- package/packages/dashboard/src/components/StageBadge.vue +17 -3
- package/packages/dashboard/src/components/StepCard.vue +7 -2
- package/src/change-risk-profile.js +167 -0
- package/src/db.js +6 -0
- package/src/index.js +17 -1
- package/src/knowledge-match.js +130 -0
- package/src/progress.js +464 -11
- package/src/run.js +200 -3
- package/src/scan-postcheck.js +34 -2
- package/src/stage-contract.js +86 -6
- package/src/stages/brainstorm.js +23 -0
- package/src/stages/execute.js +110 -2
- package/src/stages/plan.js +82 -0
- package/src/stages/scan.js +40 -0
- package/src/stages/verify.js +38 -2
- package/test/brainstorm-plan-contract.test.mjs +273 -0
- package/test/knowledge-match.test.mjs +231 -0
- package/test/plan-execute-contract.test.mjs +330 -0
- package/test/platform-failure-samples.test.mjs +4 -0
- package/test/revision-v1.test.mjs +1145 -0
- package/test/scan-knowledge.test.mjs +175 -0
- package/test/scan-postcheck.test.mjs +3 -0
- package/test/spec-dir.test.mjs +8 -3
- package/test/stage-definitions.test.mjs +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knowledge-match.js — knowledge 关键词匹配引擎
|
|
3
|
+
* 从 INDEX.md 解析知识条目,按任务上下文匹配并生成 hit report
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 从 INDEX.md 解析所有知识条目
|
|
11
|
+
* @param {string} indexDir - knowledge 目录路径
|
|
12
|
+
* @returns {{ category: string, keywords: string[], file: string, anchor: string, display: string, line: string }[]}
|
|
13
|
+
*/
|
|
14
|
+
export function parseKnowledgeIndex(indexDir) {
|
|
15
|
+
const indexPath = join(indexDir, 'INDEX.md')
|
|
16
|
+
if (!existsSync(indexPath)) return []
|
|
17
|
+
|
|
18
|
+
const content = readFileSync(indexPath, 'utf8')
|
|
19
|
+
const entries = []
|
|
20
|
+
let currentCategory = ''
|
|
21
|
+
|
|
22
|
+
for (const line of content.split('\n')) {
|
|
23
|
+
// 匹配 ## Category 行
|
|
24
|
+
const catMatch = line.match(/^##\s+(.+)/)
|
|
25
|
+
if (catMatch) {
|
|
26
|
+
currentCategory = catMatch[1].trim()
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 匹配条目行:- 关键词1|关键词2 → [显示名](文件名#锚点)
|
|
31
|
+
const entryMatch = line.match(/^-\s+(.+?)\s*→\s*\[(.+?)\]\(([^#)]+)(?:#([^)]+))?\)/)
|
|
32
|
+
if (entryMatch) {
|
|
33
|
+
const keywords = entryMatch[1].split('|').map(k => k.trim()).filter(Boolean)
|
|
34
|
+
const display = entryMatch[2].trim()
|
|
35
|
+
const file = entryMatch[3].trim()
|
|
36
|
+
const anchor = entryMatch[4] ? entryMatch[4].trim() : ''
|
|
37
|
+
entries.push({ category: currentCategory, keywords, file, anchor, display, line })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return entries
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function escapeRegex(s) {
|
|
45
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 单个关键词是否命中上下文。
|
|
50
|
+
* - 过短关键词(<2 字符)视为噪音,不参与匹配
|
|
51
|
+
* - 非 ASCII(中文等)用子串匹配
|
|
52
|
+
* - ASCII 关键词用词边界匹配,避免子串误命中(如 "DB" 不命中 "dashboard")
|
|
53
|
+
*/
|
|
54
|
+
function keywordMatchesContext(keyword, contextLower) {
|
|
55
|
+
const kw = keyword.toLowerCase().trim()
|
|
56
|
+
if (kw.length < 2) return false
|
|
57
|
+
if (/[^\x00-\x7f]/.test(kw)) return contextLower.includes(kw)
|
|
58
|
+
return new RegExp(`(^|[^a-z0-9])${escapeRegex(kw)}([^a-z0-9]|$)`).test(contextLower)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 用任务上下文匹配知识条目
|
|
63
|
+
* @param {string} indexDir - knowledge 目录路径
|
|
64
|
+
* @param {string} taskContext - 任务上下文(task 名称 + 描述,用于关键词匹配)
|
|
65
|
+
* @returns {{ matched: boolean, entries: Array, report: string, json: object }}
|
|
66
|
+
*/
|
|
67
|
+
export function matchKnowledge(indexDir, taskContext) {
|
|
68
|
+
const indexPath = join(indexDir, 'INDEX.md')
|
|
69
|
+
|
|
70
|
+
// INDEX.md 不存在
|
|
71
|
+
if (!existsSync(indexPath)) {
|
|
72
|
+
return {
|
|
73
|
+
matched: false,
|
|
74
|
+
entries: [],
|
|
75
|
+
report: 'Status: no matches (INDEX.md not found)',
|
|
76
|
+
json: { matched: false, entry_count: 0, entries: [] }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const allEntries = parseKnowledgeIndex(indexDir)
|
|
81
|
+
if (allEntries.length === 0 || !taskContext) {
|
|
82
|
+
return {
|
|
83
|
+
matched: false,
|
|
84
|
+
entries: [],
|
|
85
|
+
report: 'Status: no matches',
|
|
86
|
+
json: { matched: false, entry_count: 0, entries: [] }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const contextLower = taskContext.toLowerCase()
|
|
91
|
+
const matched = allEntries.filter(entry => {
|
|
92
|
+
return entry.keywords.some(kw => keywordMatchesContext(kw, contextLower))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
if (matched.length === 0) {
|
|
96
|
+
return {
|
|
97
|
+
matched: false,
|
|
98
|
+
entries: [],
|
|
99
|
+
report: 'Status: no matches',
|
|
100
|
+
json: { matched: false, entry_count: 0, entries: [] }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sources = matched.map(e => {
|
|
105
|
+
const base = e.anchor ? `${e.file}#${e.anchor}` : e.file
|
|
106
|
+
return ` - ${base}`
|
|
107
|
+
}).join('\n')
|
|
108
|
+
|
|
109
|
+
const report = [
|
|
110
|
+
'Knowledge Context',
|
|
111
|
+
'─────────────────',
|
|
112
|
+
`Status: matched`,
|
|
113
|
+
`Entries: ${matched.length}`,
|
|
114
|
+
'Sources:',
|
|
115
|
+
sources
|
|
116
|
+
].join('\n')
|
|
117
|
+
|
|
118
|
+
const json = {
|
|
119
|
+
matched: true,
|
|
120
|
+
entry_count: matched.length,
|
|
121
|
+
entries: matched.map(e => ({
|
|
122
|
+
file: e.file,
|
|
123
|
+
anchor: e.anchor,
|
|
124
|
+
keywords: e.keywords,
|
|
125
|
+
category: e.category
|
|
126
|
+
}))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { matched: true, entries: matched, report, json }
|
|
130
|
+
}
|
package/src/progress.js
CHANGED
|
@@ -38,7 +38,16 @@ const CHANGES_SUBDIR = 'changes';
|
|
|
38
38
|
const GLOBAL_FILE = 'global.json';
|
|
39
39
|
const CURRENT_VERSION = 3;
|
|
40
40
|
const VALID_STAGES = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore'];
|
|
41
|
-
const VALID_STATUSES = ['pending', 'in-progress', 'completed', 'failed', 'blocked', 'waiting'];
|
|
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;
|
|
42
51
|
|
|
43
52
|
const STAGE_LABELS = {
|
|
44
53
|
brainstorm: '🧠 需求探索',
|
|
@@ -208,13 +217,18 @@ export class ProgressManager {
|
|
|
208
217
|
if (!changeRows || changeRows.length === 0 || changeRows[0].values.length === 0) return null;
|
|
209
218
|
const [changeId, cName, currentStage, noWorktree, lastActive] = changeRows[0].values[0];
|
|
210
219
|
|
|
211
|
-
// 2. 从 stages
|
|
212
|
-
const stageRows = sqlDb.exec('SELECT id, stage, status, started_at, completed_at FROM stages WHERE change_id = ? ORDER BY id', [changeId]);
|
|
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]);
|
|
213
222
|
const stageMap = {};
|
|
214
223
|
const stageIds = [];
|
|
215
224
|
if (stageRows && stageRows.length > 0) {
|
|
216
|
-
for (const [sId, stage, status, startedAt, completedAt] of stageRows[0].values) {
|
|
217
|
-
stageMap[stage] = { _dbId: sId, status, startedAt, completedAt
|
|
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
|
+
};
|
|
218
232
|
stageIds.push(sId);
|
|
219
233
|
}
|
|
220
234
|
}
|
|
@@ -291,6 +305,11 @@ export class ProgressManager {
|
|
|
291
305
|
steps,
|
|
292
306
|
startedAt: info.startedAt,
|
|
293
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 } : {}),
|
|
294
313
|
};
|
|
295
314
|
}
|
|
296
315
|
|
|
@@ -343,15 +362,20 @@ export class ProgressManager {
|
|
|
343
362
|
// 3. 遍历 stages,UPSERT stages 表和 steps 表
|
|
344
363
|
if (data.stages && typeof data.stages === 'object') {
|
|
345
364
|
for (const [stageName, stageData] of Object.entries(data.stages)) {
|
|
346
|
-
// UPSERT stages
|
|
365
|
+
// UPSERT stages 行(含 revision 列)
|
|
347
366
|
sqlDb.run(
|
|
348
|
-
`INSERT INTO stages (change_id, stage, status, started_at, completed_at)
|
|
349
|
-
VALUES (?, ?, ?, ?, ?)
|
|
367
|
+
`INSERT INTO stages (change_id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason)
|
|
368
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
350
369
|
ON CONFLICT(change_id, stage) DO UPDATE SET
|
|
351
370
|
status = excluded.status,
|
|
352
371
|
started_at = excluded.started_at,
|
|
353
|
-
completed_at = excluded.completed_at
|
|
354
|
-
|
|
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]
|
|
355
379
|
);
|
|
356
380
|
|
|
357
381
|
// 获取 stage_id
|
|
@@ -955,7 +979,7 @@ export class ProgressManager {
|
|
|
955
979
|
console.log(' ═══════════════════════════════════════');
|
|
956
980
|
console.log('');
|
|
957
981
|
|
|
958
|
-
const statusIcons = { pending: '⬜', 'in-progress': '🔵', completed: '✅', failed: '❌', blocked: '🚫', waiting: '⏸️' };
|
|
982
|
+
const statusIcons = { pending: '⬜', 'in-progress': '🔵', completed: '✅', failed: '❌', blocked: '🚫', waiting: '⏸️', revising: '🔧', stale: '⚠️' };
|
|
959
983
|
|
|
960
984
|
for (const stage of VALID_STAGES) {
|
|
961
985
|
const stageData = data.stages[stage] || emptyStage();
|
|
@@ -965,6 +989,17 @@ export class ProgressManager {
|
|
|
965
989
|
|
|
966
990
|
console.log(` ${icon} ${label}${isCurrent}`);
|
|
967
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
|
+
|
|
968
1003
|
if (stageData.steps && stageData.steps.length > 0) {
|
|
969
1004
|
for (const step of stageData.steps) {
|
|
970
1005
|
const si = statusIcons[step.status] || '○';
|
|
@@ -996,13 +1031,316 @@ export class ProgressManager {
|
|
|
996
1031
|
}
|
|
997
1032
|
}
|
|
998
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
|
+
|
|
999
1042
|
console.log('');
|
|
1000
1043
|
}
|
|
1001
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
|
+
|
|
1002
1108
|
async status(cwd, changeName = null) {
|
|
1003
1109
|
await this.show(cwd, changeName);
|
|
1004
1110
|
}
|
|
1005
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
|
+
|
|
1006
1344
|
async validate(cwd, changeName = null) {
|
|
1007
1345
|
const data = await this.read(cwd, changeName);
|
|
1008
1346
|
if (!data) { console.log('❌ 无法读取进度数据'); return false; }
|
|
@@ -1038,6 +1376,121 @@ export class ProgressManager {
|
|
|
1038
1376
|
return true;
|
|
1039
1377
|
}
|
|
1040
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
|
+
|
|
1041
1494
|
async reset(cwd, stage, changeName = null) {
|
|
1042
1495
|
if (stage) {
|
|
1043
1496
|
const data = await this.read(cwd, changeName);
|