openfeel 1.1.1 → 1.1.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/README.en.md +41 -11
- package/README.md +8 -3
- package/README.zh-CN.md +42 -12
- package/bin/openfeel.js +5 -0
- package/dist/cli/index.d.ts +15 -3
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +59 -15
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/output-encoding.d.ts +32 -0
- package/dist/cli/output-encoding.d.ts.map +1 -0
- package/dist/cli/output-encoding.js +120 -0
- package/dist/cli/output-encoding.js.map +1 -0
- package/dist/cli/repl.d.ts +2 -2
- package/dist/cli/repl.d.ts.map +1 -1
- package/dist/cli/repl.js +46 -25
- package/dist/cli/repl.js.map +1 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +56 -9
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/flow.d.ts.map +1 -1
- package/dist/commands/flow.js +827 -138
- package/dist/commands/flow.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +56 -42
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/knowledge.d.ts.map +1 -1
- package/dist/commands/knowledge.js +69 -0
- package/dist/commands/knowledge.js.map +1 -1
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +17 -5
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +6 -1
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/model.d.ts.map +1 -1
- package/dist/commands/model.js +18 -1
- package/dist/commands/model.js.map +1 -1
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/plan.js +185 -7
- package/dist/commands/plan.js.map +1 -1
- package/dist/commands/project.js +2 -2
- package/dist/commands/project.js.map +1 -1
- package/dist/commands/roadmap.d.ts.map +1 -1
- package/dist/commands/roadmap.js +20 -5
- package/dist/commands/roadmap.js.map +1 -1
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +8 -2
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/shared/errors.d.ts +22 -0
- package/dist/commands/shared/errors.d.ts.map +1 -0
- package/dist/commands/shared/errors.js +45 -0
- package/dist/commands/shared/errors.js.map +1 -0
- package/dist/commands/stage.d.ts.map +1 -1
- package/dist/commands/stage.js +133 -23
- package/dist/commands/stage.js.map +1 -1
- package/dist/commands/view.d.ts +1 -1
- package/dist/commands/view.d.ts.map +1 -1
- package/dist/commands/view.js +4 -26
- package/dist/commands/view.js.map +1 -1
- package/dist/core/archive/merge.d.ts.map +1 -1
- package/dist/core/archive/merge.js +9 -5
- package/dist/core/archive/merge.js.map +1 -1
- package/dist/core/artifact-graph/graph.d.ts +7 -2
- package/dist/core/artifact-graph/graph.d.ts.map +1 -1
- package/dist/core/artifact-graph/graph.js +25 -18
- package/dist/core/artifact-graph/graph.js.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
- package/dist/core/artifact-graph/instruction-loader.js +4 -33
- package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
- package/dist/core/backup.d.ts +28 -0
- package/dist/core/backup.d.ts.map +1 -0
- package/dist/core/backup.js +118 -0
- package/dist/core/backup.js.map +1 -0
- package/dist/core/config.d.ts +30 -19
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +86 -26
- package/dist/core/config.js.map +1 -1
- package/dist/core/flow-manager.d.ts +256 -5
- package/dist/core/flow-manager.d.ts.map +1 -1
- package/dist/core/flow-manager.js +908 -68
- package/dist/core/flow-manager.js.map +1 -1
- package/dist/core/fs/atomic-write.d.ts +3 -0
- package/dist/core/fs/atomic-write.d.ts.map +1 -1
- package/dist/core/fs/atomic-write.js +3 -0
- package/dist/core/fs/atomic-write.js.map +1 -1
- package/dist/core/fs/safe-read.d.ts +11 -0
- package/dist/core/fs/safe-read.d.ts.map +1 -0
- package/dist/core/fs/safe-read.js +31 -0
- package/dist/core/fs/safe-read.js.map +1 -0
- package/dist/core/global-paths.d.ts +16 -7
- package/dist/core/global-paths.d.ts.map +1 -1
- package/dist/core/global-paths.js +23 -8
- package/dist/core/global-paths.js.map +1 -1
- package/dist/core/i18n-data/en.d.ts +2 -0
- package/dist/core/i18n-data/en.d.ts.map +1 -1
- package/dist/core/i18n-data/en.js +259 -23
- package/dist/core/i18n-data/en.js.map +1 -1
- package/dist/core/i18n-data/zh-CN.d.ts +2 -0
- package/dist/core/i18n-data/zh-CN.d.ts.map +1 -1
- package/dist/core/i18n-data/zh-CN.js +265 -23
- package/dist/core/i18n-data/zh-CN.js.map +1 -1
- package/dist/core/i18n.d.ts +8 -0
- package/dist/core/i18n.d.ts.map +1 -1
- package/dist/core/i18n.js +14 -4
- package/dist/core/i18n.js.map +1 -1
- package/dist/core/init.d.ts +4 -2
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +38 -13
- package/dist/core/init.js.map +1 -1
- package/dist/core/managed-region.d.ts +1 -1
- package/dist/core/managed-region.js +1 -1
- package/dist/core/metrics.d.ts +16 -4
- package/dist/core/metrics.d.ts.map +1 -1
- package/dist/core/metrics.js +31 -11
- package/dist/core/metrics.js.map +1 -1
- package/dist/core/migrate.d.ts.map +1 -1
- package/dist/core/migrate.js +53 -19
- package/dist/core/migrate.js.map +1 -1
- package/dist/core/model-config.d.ts.map +1 -1
- package/dist/core/model-config.js +4 -2
- package/dist/core/model-config.js.map +1 -1
- package/dist/core/opencode-config.d.ts +4 -4
- package/dist/core/opencode-config.d.ts.map +1 -1
- package/dist/core/opencode-config.js +6 -5
- package/dist/core/opencode-config.js.map +1 -1
- package/dist/core/plan/path.d.ts +40 -0
- package/dist/core/plan/path.d.ts.map +1 -1
- package/dist/core/plan/path.js +108 -1
- package/dist/core/plan/path.js.map +1 -1
- package/dist/core/plan/roadmap.js +2 -2
- package/dist/core/plan/roadmap.js.map +1 -1
- package/dist/core/plan/scheme.d.ts +74 -2
- package/dist/core/plan/scheme.d.ts.map +1 -1
- package/dist/core/plan/scheme.js +311 -26
- package/dist/core/plan/scheme.js.map +1 -1
- package/dist/core/plan/stage.d.ts +34 -3
- package/dist/core/plan/stage.d.ts.map +1 -1
- package/dist/core/plan/stage.js +90 -6
- package/dist/core/plan/stage.js.map +1 -1
- package/dist/core/public-logger.d.ts +18 -5
- package/dist/core/public-logger.d.ts.map +1 -1
- package/dist/core/public-logger.js +62 -23
- package/dist/core/public-logger.js.map +1 -1
- package/dist/core/runtime-log.d.ts +25 -0
- package/dist/core/runtime-log.d.ts.map +1 -0
- package/dist/core/runtime-log.js +94 -0
- package/dist/core/runtime-log.js.map +1 -0
- package/dist/core/setup.d.ts.map +1 -1
- package/dist/core/setup.js +47 -19
- package/dist/core/setup.js.map +1 -1
- package/dist/core/template-loader.d.ts.map +1 -1
- package/dist/core/template-loader.js +672 -288
- package/dist/core/template-loader.js.map +1 -1
- package/dist/core/templates.d.ts +2 -2
- package/dist/core/templates.d.ts.map +1 -1
- package/dist/core/templates.js +13 -9
- package/dist/core/templates.js.map +1 -1
- package/dist/core/update-infos.d.ts +14 -3
- package/dist/core/update-infos.d.ts.map +1 -1
- package/dist/core/update-infos.js +67 -15
- package/dist/core/update-infos.js.map +1 -1
- package/dist/core/update-state.d.ts.map +1 -1
- package/dist/core/update-state.js +7 -4
- package/dist/core/update-state.js.map +1 -1
- package/dist/core/update.d.ts +2 -1
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +253 -61
- package/dist/core/update.js.map +1 -1
- package/dist/core/view/entry.d.ts +53 -1
- package/dist/core/view/entry.d.ts.map +1 -1
- package/dist/core/view/entry.js +71 -2
- package/dist/core/view/entry.js.map +1 -1
- package/dist/core/workspace/identity.d.ts.map +1 -1
- package/dist/core/workspace/identity.js +15 -5
- package/dist/core/workspace/identity.js.map +1 -1
- package/dist/core/workspace/knowledge.d.ts.map +1 -1
- package/dist/core/workspace/knowledge.js +125 -19
- package/dist/core/workspace/knowledge.js.map +1 -1
- package/dist/core/workspace/structure.d.ts.map +1 -1
- package/dist/core/workspace/structure.js +6 -2
- package/dist/core/workspace/structure.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/utils/kb-dedup.d.ts +5 -1
- package/dist/utils/kb-dedup.d.ts.map +1 -1
- package/dist/utils/kb-dedup.js +11 -5
- package/dist/utils/kb-dedup.js.map +1 -1
- package/package.json +6 -6
- package/dist/utils/path.d.ts +0 -22
- package/dist/utils/path.d.ts.map +0 -1
- package/dist/utils/path.js +0 -37
- package/dist/utils/path.js.map +0 -1
- package/scripts/patch-inquirer.js +0 -70
|
@@ -10,15 +10,17 @@
|
|
|
10
10
|
* - 新增 repair() 方法,自动检测并修复 flow.json 常见问题
|
|
11
11
|
*/
|
|
12
12
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, unlinkSync } from 'node:fs';
|
|
13
|
-
import { resolve } from 'node:path';
|
|
14
|
-
import {
|
|
13
|
+
import { resolve, dirname, basename } from 'node:path';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
15
|
import { parse as parseYaml } from 'yaml';
|
|
16
16
|
import { PipelineConfigSchema, PipelinePhaseSchema, PIPELINE_PHASES, MetaPhaseSchema, META_PHASES, transitionKeyMatches, } from './pipeline-schema.js';
|
|
17
17
|
import { PublicLogger, formatDate } from './public-logger.js';
|
|
18
18
|
import { t, getCliLang } from './i18n.js';
|
|
19
|
-
import { findStageStatusPath } from './plan/path.js';
|
|
19
|
+
import { findStageStatusPath, findStageDirConflict, parseStageId, normalizeStageId } from './plan/path.js';
|
|
20
20
|
import { atomicWriteFileSync } from './fs/atomic-write.js';
|
|
21
21
|
import { withFileLock, projectLockPath } from './fs/file-lock.js';
|
|
22
|
+
import { DEFAULT_CONFIG, ConfigDefaultsSchema } from './config.js';
|
|
23
|
+
import { getGlobalProfilePath } from './global-paths.js';
|
|
22
24
|
/** flow.json 乐观并发冲突:磁盘 revision 与本次加载时不一致(存在并发写入) */
|
|
23
25
|
export class FlowConcurrentModificationError extends Error {
|
|
24
26
|
/** 本次实例加载时的 revision */
|
|
@@ -37,6 +39,21 @@ export function isFlowConcurrentError(err) {
|
|
|
37
39
|
return err instanceof FlowConcurrentModificationError
|
|
38
40
|
|| (typeof err === 'object' && err !== null && err.name === 'FlowConcurrentModificationError');
|
|
39
41
|
}
|
|
42
|
+
/** 阶段目录冲突:两个不同 stageId 映射同一 (series, stageDir)(cli/BUG-002) */
|
|
43
|
+
export class StageDirConflictError extends Error {
|
|
44
|
+
/** 本次冲突检测的 stageId */
|
|
45
|
+
stage;
|
|
46
|
+
/** 已存在、占用同一目录的其它 stageId */
|
|
47
|
+
other;
|
|
48
|
+
constructor(stage, other) {
|
|
49
|
+
super(`阶段目录冲突:'${stage}' 与 '${other}' 映射同一 (series, stageDir),请改用其它 stage-NN`);
|
|
50
|
+
this.name = 'StageDirConflictError';
|
|
51
|
+
this.stage = stage;
|
|
52
|
+
this.other = other;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** `openfeel config effective` 的四个受管键(与 DEFAULT_CONFIG 一致) */
|
|
56
|
+
const EFFECTIVE_CONFIG_KEYS = ['execution_mode', 'auto_advance', 'test_enabled', 'merge_mode'];
|
|
40
57
|
// ── 默认值 ──
|
|
41
58
|
/** 创建默认的 Checkpoints */
|
|
42
59
|
function defaultCheckpoints() {
|
|
@@ -72,6 +89,80 @@ function extractRevision(data) {
|
|
|
72
89
|
const rev = data?.meta?.revision;
|
|
73
90
|
return typeof rev === 'number' && Number.isInteger(rev) ? rev : 0;
|
|
74
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* 对账 flow.json 的 op 键与 ops/ 目录文件(N1-2 单一实现,repair/health 共用)。
|
|
94
|
+
*
|
|
95
|
+
* 判定规则(R1):op 模板文件同时匹配 `op-NNN.md`(新命名)与 `op-NNN_*.md`(历史命名),
|
|
96
|
+
* 两种命名都算「文件存在」,避免把历史命名误判为键孤儿。
|
|
97
|
+
* 无法解析 stageId 的阶段(非标准键)跳过对账。
|
|
98
|
+
*
|
|
99
|
+
* @param projectPath 项目根路径
|
|
100
|
+
* @param stages flow.json 的 stages 映射
|
|
101
|
+
* @returns keyOrphans(有注册键无文件)/ fileOrphans(有文件无注册键)
|
|
102
|
+
*/
|
|
103
|
+
function reconcileOrphans(projectPath, stages) {
|
|
104
|
+
const keyOrphans = [];
|
|
105
|
+
const fileOrphans = [];
|
|
106
|
+
for (const [stageId, stage] of Object.entries(stages ?? {})) {
|
|
107
|
+
// 非标准 stageId 无法定位 ops/ 目录,跳过(不误报)
|
|
108
|
+
const parsed = parseStageId(stageId);
|
|
109
|
+
if (!parsed) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const keys = Object.keys(stage?.ops ?? {});
|
|
113
|
+
const opsDir = resolve(projectPath, '.openfeel', 'plan', parsed.series, parsed.stageDir, 'ops');
|
|
114
|
+
const fileIds = new Set();
|
|
115
|
+
if (existsSync(opsDir)) {
|
|
116
|
+
let entries = [];
|
|
117
|
+
try {
|
|
118
|
+
entries = readdirSync(opsDir);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
entries = [];
|
|
122
|
+
}
|
|
123
|
+
for (const name of entries) {
|
|
124
|
+
const m = name.match(/^(op-\d+)/);
|
|
125
|
+
if (m) {
|
|
126
|
+
fileIds.add(m[1]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// 键孤儿:有注册键但 ops/ 无对应文件
|
|
131
|
+
for (const key of keys) {
|
|
132
|
+
if (!fileIds.has(key)) {
|
|
133
|
+
keyOrphans.push({ stage: stageId, opId: key });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// 文件孤儿:有文件但无注册键
|
|
137
|
+
for (const fileId of fileIds) {
|
|
138
|
+
if (!keys.includes(fileId)) {
|
|
139
|
+
fileOrphans.push({ stage: stageId, opId: fileId });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { keyOrphans, fileOrphans };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 对账 flow.json 的 op 键与 ops/ 目录文件(N1-2 公开入口)。
|
|
147
|
+
* @param projectPath 项目根路径
|
|
148
|
+
* @returns keyOrphans(有键无文件)/ fileOrphans(有文件无键);flow.json 缺失/损坏时返回空
|
|
149
|
+
*/
|
|
150
|
+
export function findOrphanOps(projectPath) {
|
|
151
|
+
const fp = resolve(projectPath, '.openfeel', 'flow.json');
|
|
152
|
+
if (!existsSync(fp)) {
|
|
153
|
+
return { keyOrphans: [], fileOrphans: [] };
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(readFileSync(fp, 'utf-8'));
|
|
157
|
+
return reconcileOrphans(projectPath, parsed.stages ?? {});
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// flow.json 不可解析时不做对账(不阻塞主流程)
|
|
161
|
+
return { keyOrphans: [], fileOrphans: [] };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** findPhasePath BFS 深度上限(B5-1;超出按拒绝处理) */
|
|
165
|
+
export const PHASE_PATH_MAX_DEPTH = 8;
|
|
75
166
|
// ── 核心类 ──
|
|
76
167
|
export class FlowManager {
|
|
77
168
|
projectPath;
|
|
@@ -107,6 +198,13 @@ export class FlowManager {
|
|
|
107
198
|
// 从 stages 的键名恢复 op 的 id 字段(运行时便利字段,磁盘不存储)
|
|
108
199
|
if (this.data && this.data.stages) {
|
|
109
200
|
for (const [, stage] of Object.entries(this.data.stages)) {
|
|
201
|
+
// T2 一处收口:存量/外部损坏数据缺 ops/deps 时补齐,避免下游 Object.entries(undefined) 崩溃
|
|
202
|
+
if (!stage.ops) {
|
|
203
|
+
stage.ops = {};
|
|
204
|
+
}
|
|
205
|
+
if (!stage.deps) {
|
|
206
|
+
stage.deps = [];
|
|
207
|
+
}
|
|
110
208
|
// 类型守卫:仅当 ops 为普通对象时才遍历(跳过 null/undefined/数组)
|
|
111
209
|
if (stage.ops && typeof stage.ops === 'object' && !Array.isArray(stage.ops)) {
|
|
112
210
|
for (const [opKey, op] of Object.entries(stage.ops)) {
|
|
@@ -126,6 +224,8 @@ export class FlowManager {
|
|
|
126
224
|
if (!this.data) {
|
|
127
225
|
return;
|
|
128
226
|
}
|
|
227
|
+
// 存量 flow.json 可能缺 meta(REV-41 REV-008):就地补齐,避免 save() 抛 TypeError(??= 仅补整体缺失,不覆盖既有字段)
|
|
228
|
+
this.data.meta ??= { version: '1.0', project: '', updated: '', revision: 0 };
|
|
129
229
|
this.data.meta.updated = new Date().toISOString();
|
|
130
230
|
// 创建清理后的副本:去除 op 中的 id 字段(id 由 stages.{stageId}.ops 的键名决定)
|
|
131
231
|
const serializable = JSON.parse(JSON.stringify(this.data));
|
|
@@ -224,8 +324,11 @@ export class FlowManager {
|
|
|
224
324
|
if (!existsSync(dir)) {
|
|
225
325
|
return [];
|
|
226
326
|
}
|
|
327
|
+
// REV-009:短名归一化(快照文件名前缀为全名);`||` 兜底兼容历史上以短名存的快照
|
|
328
|
+
const key = stageId ? (normalizeStageId(stageId) ?? stageId) : undefined;
|
|
227
329
|
return readdirSync(dir)
|
|
228
|
-
.filter((f) => f.endsWith('.json')
|
|
330
|
+
.filter((f) => f.endsWith('.json')
|
|
331
|
+
&& (!key || f.startsWith(key + '-') || (stageId !== undefined && stageId !== key && f.startsWith(stageId + '-'))))
|
|
229
332
|
.sort();
|
|
230
333
|
}
|
|
231
334
|
catch {
|
|
@@ -269,7 +372,7 @@ export class FlowManager {
|
|
|
269
372
|
atomicWriteFileSync(this.filePath, restoredContent, { backup: true });
|
|
270
373
|
});
|
|
271
374
|
if (conflict) {
|
|
272
|
-
console.warn('
|
|
375
|
+
console.warn(t('flow.manager.snapshotRestoreRefused', getCliLang(this.projectPath)));
|
|
273
376
|
return false;
|
|
274
377
|
}
|
|
275
378
|
this.load();
|
|
@@ -344,12 +447,20 @@ export class FlowManager {
|
|
|
344
447
|
if (dotIdx === -1) {
|
|
345
448
|
return null;
|
|
346
449
|
}
|
|
347
|
-
const
|
|
450
|
+
const rawStageId = opId.substring(0, dotIdx);
|
|
348
451
|
const opLocalId = opId.substring(dotIdx + 1);
|
|
349
452
|
// 排除空字符串情况
|
|
350
|
-
if (!
|
|
453
|
+
if (!rawStageId || !opLocalId) {
|
|
351
454
|
return null;
|
|
352
455
|
}
|
|
456
|
+
// REV-007:出口归一化(短名前缀 → 全名),一处收口覆盖 getOp/advancePhase/recordAttempt/hasTransition。
|
|
457
|
+
// 仅归一化 stageId 段,opLocalId 保持原值(不影响 pipeline.current.op 写入值);无法解析回退原值。
|
|
458
|
+
// 双键回退(与 setStageDeps/removeStage/removeScheme 同范式):归一化键优先;
|
|
459
|
+
// 归一化键不存在而原始键存在时回退原始键(兼容以短名注册的阶段,N-1),避免误判「阶段不存在」。
|
|
460
|
+
const normalized = normalizeStageId(rawStageId) ?? rawStageId;
|
|
461
|
+
const stageId = this.data?.stages[normalized]
|
|
462
|
+
? normalized
|
|
463
|
+
: (this.data?.stages[rawStageId] ? rawStageId : normalized);
|
|
353
464
|
return { stageId, opLocalId };
|
|
354
465
|
}
|
|
355
466
|
/** 根据 opId 查找 Op 对象 */
|
|
@@ -408,6 +519,32 @@ export class FlowManager {
|
|
|
408
519
|
}
|
|
409
520
|
return this.data.reviews.filter((r) => r.op === opId);
|
|
410
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* 读取 op 模板文件内容(兼容 `op-NNN.md` 与历史 `op-NNN_{title}.md` 两种命名)。
|
|
524
|
+
* B3-1 填充度检测与 healthCheck 空模板检查共用。
|
|
525
|
+
* @returns 文件内容;未找到/不可读返回 null
|
|
526
|
+
*/
|
|
527
|
+
readOpTemplate(stageId, opId) {
|
|
528
|
+
const parsed = parseStageId(stageId);
|
|
529
|
+
if (!parsed) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
const opsDir = resolve(this.projectPath, '.openfeel', 'plan', parsed.series, parsed.stageDir, 'ops');
|
|
533
|
+
if (!existsSync(opsDir)) {
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
const name = readdirSync(opsDir).find((f) => f === `${opId}.md` || f.startsWith(`${opId}_`));
|
|
538
|
+
if (!name) {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
return readFileSync(resolve(opsDir, name), 'utf-8');
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
// 目录/文件不可读视为未找到(不误判)
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
411
548
|
/** 获取指定 op 的重试次数 */
|
|
412
549
|
getRetryCount(opId) {
|
|
413
550
|
const op = this.getOp(opId);
|
|
@@ -421,9 +558,9 @@ export class FlowManager {
|
|
|
421
558
|
const stagesCount = Object.keys(this.data.stages).length;
|
|
422
559
|
let opsCount = 0;
|
|
423
560
|
for (const stage of Object.values(this.data.stages)) {
|
|
424
|
-
// 类型守卫:仅统计普通对象 ops(跳过 null/undefined
|
|
561
|
+
// 类型守卫:仅统计普通对象 ops(跳过 null/undefined/数组);draft 不计入完成度统计(A1 窄兼容第 2 条)
|
|
425
562
|
if (stage.ops && typeof stage.ops === 'object' && !Array.isArray(stage.ops)) {
|
|
426
|
-
opsCount += Object.
|
|
563
|
+
opsCount += Object.values(stage.ops).filter((o) => o.state !== 'draft').length;
|
|
427
564
|
}
|
|
428
565
|
}
|
|
429
566
|
const openReviews = this.data.reviews.filter((r) => r.status === 'open').length;
|
|
@@ -460,9 +597,9 @@ export class FlowManager {
|
|
|
460
597
|
}
|
|
461
598
|
let opsCount = 0;
|
|
462
599
|
for (const stage of Object.values(this.data.stages)) {
|
|
463
|
-
// 类型守卫:仅统计普通对象 ops(跳过 null/undefined
|
|
600
|
+
// 类型守卫:仅统计普通对象 ops(跳过 null/undefined/数组);draft 不计入完成度统计(A1 窄兼容第 2 条)
|
|
464
601
|
if (stage.ops && typeof stage.ops === 'object' && !Array.isArray(stage.ops)) {
|
|
465
|
-
opsCount += Object.
|
|
602
|
+
opsCount += Object.values(stage.ops).filter((o) => o.state !== 'draft').length;
|
|
466
603
|
}
|
|
467
604
|
}
|
|
468
605
|
return {
|
|
@@ -487,7 +624,12 @@ export class FlowManager {
|
|
|
487
624
|
return;
|
|
488
625
|
}
|
|
489
626
|
if (this.data.stages[stageName]) {
|
|
490
|
-
return; //
|
|
627
|
+
return; // 已注册,跳过(幂等,保持既有行为)
|
|
628
|
+
}
|
|
629
|
+
// 冲突检测:不同 stageId 映射同一 (series, stageDir) 时拒绝写入(结构化错误,命令层按类型分流 i18n)
|
|
630
|
+
const conflict = findStageDirConflict(this.projectPath, stageName);
|
|
631
|
+
if (conflict) {
|
|
632
|
+
throw new StageDirConflictError(stageName, conflict);
|
|
491
633
|
}
|
|
492
634
|
this.data.stages[stageName] = {
|
|
493
635
|
name: stageName,
|
|
@@ -496,6 +638,10 @@ export class FlowManager {
|
|
|
496
638
|
deps,
|
|
497
639
|
ops: {},
|
|
498
640
|
};
|
|
641
|
+
// 审计日志(P7):仅在实际新增时写入;同 stageId 幂等跳过时不写(保持「仅记录实际动作」)。
|
|
642
|
+
// 双轨说明(REV-42-002):add_stage = 注册层(flow stage add / stage create,agent=flow-manager);
|
|
643
|
+
// register_stage = 完整层(plan stage add,建目录 + overview/status + 注册,agent=cli)。入口不同,非重复。
|
|
644
|
+
this.appendLog({ time: '', agent: 'cli', action: 'register_stage', detail: { stageName, deps } });
|
|
499
645
|
}
|
|
500
646
|
// ═══ 阶段耗时统计 ═══
|
|
501
647
|
/**
|
|
@@ -732,6 +878,31 @@ export class FlowManager {
|
|
|
732
878
|
return { phase, currentOp, stageStatus, blockedBy, pendingTasks };
|
|
733
879
|
}
|
|
734
880
|
// ═══ 推进 ═══
|
|
881
|
+
/**
|
|
882
|
+
* 同步 pipeline.current 到指定阶段中首个 pending / executing 的 op。
|
|
883
|
+
* 单一 owner:被 advanceStagePhase 与 recordAttempt 共用,禁止各自实现。
|
|
884
|
+
* @param stageName 目标阶段 ID(如 'v1.1.2-stage-50')
|
|
885
|
+
* @returns 同步后的 current 快照(便于调用方复用,不写盘)
|
|
886
|
+
*/
|
|
887
|
+
syncCurrentOp(stageName) {
|
|
888
|
+
// 防御:阶段不存在时不改动 pipeline.current,直接返回当前快照
|
|
889
|
+
const stage = this.data?.stages[stageName];
|
|
890
|
+
if (!stage) {
|
|
891
|
+
return {
|
|
892
|
+
stage: this.data?.pipeline.current.stage ?? '',
|
|
893
|
+
op: this.data?.pipeline.current.op ?? '',
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
// 取首个 pending / executing 的 op
|
|
897
|
+
const pendingOpEntry = Object.entries(stage.ops ?? {}).find(([, op]) => op.state === 'pending' || op.state === 'executing');
|
|
898
|
+
// 命中 → 指向该 op;未命中(ops 全 done / 为空)→ 置空 op,绝不保留旧 op(T1 缺陷修复点)
|
|
899
|
+
const synced = { stage: stageName, op: pendingOpEntry ? pendingOpEntry[0] : '' };
|
|
900
|
+
if (this.data) {
|
|
901
|
+
// 不触碰 pipeline.phase / pipeline.retry,也不做 IO(由调用方 save())
|
|
902
|
+
this.data.pipeline.current = synced;
|
|
903
|
+
}
|
|
904
|
+
return synced;
|
|
905
|
+
}
|
|
735
906
|
/**
|
|
736
907
|
* 推进指定阶段到目标流水线阶段(新 API)
|
|
737
908
|
* @param stageName 阶段名(如 stage-01)
|
|
@@ -745,7 +916,11 @@ export class FlowManager {
|
|
|
745
916
|
}
|
|
746
917
|
const actualTrigger = triggeredBy ?? 'flow-manager';
|
|
747
918
|
// 校验 stageName 存在
|
|
748
|
-
|
|
919
|
+
// REV-005:归一化 stageId(短名 → 全名);此后函数体内一律使用 key(防半归一化)
|
|
920
|
+
// 双键回退:兼容以短名建键的存量 / 测试数据(归一化键不存在时回退原始键)
|
|
921
|
+
const normalized = normalizeStageId(stageName) ?? stageName;
|
|
922
|
+
const key = this.data.stages[normalized] ? normalized : stageName;
|
|
923
|
+
const stage = this.data.stages[key];
|
|
749
924
|
if (!stage) {
|
|
750
925
|
throw new Error(`阶段 '${stageName}' 不存在`);
|
|
751
926
|
}
|
|
@@ -755,7 +930,7 @@ export class FlowManager {
|
|
|
755
930
|
if (!phaseResult.success) {
|
|
756
931
|
const corrected = this.fuzzyCorrectPhase(phase);
|
|
757
932
|
if (corrected) {
|
|
758
|
-
console.warn(
|
|
933
|
+
console.warn(t('flow.manager.phaseAutoCorrectTmpl', getCliLang(this.projectPath), { phase: String(phase), corrected }));
|
|
759
934
|
targetPhase = corrected;
|
|
760
935
|
}
|
|
761
936
|
else {
|
|
@@ -767,7 +942,7 @@ export class FlowManager {
|
|
|
767
942
|
}
|
|
768
943
|
// REV 闭环:blocking REV > 0 时拒绝推进到 done(--force 仅降级警告)
|
|
769
944
|
if (targetPhase === 'done') {
|
|
770
|
-
const stageReviews = this.data.reviews.filter((r) => r.op.startsWith(
|
|
945
|
+
const stageReviews = this.data.reviews.filter((r) => r.op.startsWith(key + '.') || r.op === key);
|
|
771
946
|
const blockingOpen = stageReviews.filter((r) => r.blocking !== false && r.status === 'open');
|
|
772
947
|
if (blockingOpen.length > 0) {
|
|
773
948
|
const revList = blockingOpen
|
|
@@ -779,9 +954,9 @@ export class FlowManager {
|
|
|
779
954
|
// 保存旧 phase 用于日志
|
|
780
955
|
const fromPhase = stage.phase;
|
|
781
956
|
// 更新 stage phase
|
|
782
|
-
this.data.stages[
|
|
957
|
+
this.data.stages[key].phase = targetPhase;
|
|
783
958
|
// Checkpoint 自动快照:phase 推进成功后保存 flow.json 快照到 .openfeel/checkpoints/(失败不阻塞推进)
|
|
784
|
-
this.saveCheckpoint(
|
|
959
|
+
this.saveCheckpoint(key, targetPhase);
|
|
785
960
|
// 日志强制落档骨架:关键 phase 节点自动创建骨架文件
|
|
786
961
|
const SKELETON_PHASES = [
|
|
787
962
|
'exec_running',
|
|
@@ -790,36 +965,40 @@ export class FlowManager {
|
|
|
790
965
|
'archiving',
|
|
791
966
|
];
|
|
792
967
|
if (SKELETON_PHASES.includes(targetPhase)) {
|
|
793
|
-
this.createLogSkeleton(
|
|
794
|
-
}
|
|
795
|
-
// 同步更新 pipeline.current
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
968
|
+
this.createLogSkeleton(key, targetPhase);
|
|
969
|
+
}
|
|
970
|
+
// 同步更新 pipeline.current:单一 owner syncCurrentOp(未命中置空 op,修复跨阶段悬空 T1)
|
|
971
|
+
this.syncCurrentOp(key);
|
|
972
|
+
// 同步更新 pipeline.phase:所有 stage 均 done 时置 'done',否则 'active'(P3 全量 done 判定)
|
|
973
|
+
// 说明(T6):pipeline.phase 无条件覆写,手工 `paused` 属**软状态** —— 阶段推进即视为恢复,
|
|
974
|
+
// 覆写为 active/done 属预期语义。如需硬暂停,请勿推进阶段(保持 paused 且不调用 advance)。
|
|
975
|
+
// 空集守卫:无 stage 时 [].every(...) 为 true(vacuous truth),须排除以免误置 done。
|
|
976
|
+
const allDone = Object.keys(this.data.stages).length > 0
|
|
977
|
+
&& Object.values(this.data.stages).every((s) => s.phase === 'done');
|
|
978
|
+
this.data.pipeline.phase = (allDone ? 'done' : 'active');
|
|
803
979
|
// 追加日志(使用实际触发者名,而非硬编码 'flow-manager')
|
|
804
980
|
this.appendLog({
|
|
805
981
|
time: '',
|
|
806
982
|
agent: actualTrigger,
|
|
807
983
|
action: 'advance_stage_phase',
|
|
808
|
-
detail: { stageName, from: fromPhase, to: targetPhase },
|
|
984
|
+
detail: { stageName: key, from: fromPhase, to: targetPhase },
|
|
809
985
|
});
|
|
810
986
|
// REV-003: advance_stage_phase 改为 endStage 时批量聚合,取消逐条写入
|
|
811
987
|
// this.publicLogger.logPhaseChange({...});
|
|
812
988
|
// 同步 stage status(调用已有 mapPhaseToStageStatus)
|
|
813
989
|
const prevStatus = stage.status;
|
|
814
|
-
|
|
990
|
+
// test_enabled 由 buildCascadeConfig(config.yaml/status.md/profile 级联,无反向依赖)取真实值,
|
|
991
|
+
// 使 mapPhaseToStageStatus 的 testEnabled 分支可达(T9)
|
|
992
|
+
const testEnabled = this.buildCascadeConfig().effective['test_enabled'] !== 'false';
|
|
993
|
+
const newStatus = mapPhaseToStageStatus(targetPhase, prevStatus, testEnabled);
|
|
815
994
|
stage.status = newStatus;
|
|
816
995
|
// 自动记录阶段计时:首次状态变更时启动
|
|
817
996
|
if (!stage.stats || !stage.stats.start_time) {
|
|
818
|
-
this.startStage(
|
|
997
|
+
this.startStage(key);
|
|
819
998
|
}
|
|
820
999
|
// 阶段完成时自动结束计时
|
|
821
1000
|
if (targetPhase === 'done' && prevStatus !== 'done') {
|
|
822
|
-
this.endStage(
|
|
1001
|
+
this.endStage(key);
|
|
823
1002
|
// 归档 git commit 移出本方法:必须在 flow.json save 之后执行(否则 commit 不含本次 phase 变更)。
|
|
824
1003
|
// 返回 true 由命令层在 save 后调用 autoCommitOnDone。
|
|
825
1004
|
return true;
|
|
@@ -838,7 +1017,9 @@ export class FlowManager {
|
|
|
838
1017
|
try {
|
|
839
1018
|
const msg = `chore: 阶段归档 ${stageName}`;
|
|
840
1019
|
// cwd 指向项目根,确保 git 在 flow.json 所在仓库执行而非进程工作目录
|
|
841
|
-
|
|
1020
|
+
// 使用 execFileSync 数组参数(无 shell 解析),避免 stageName 注入 shell(T16)
|
|
1021
|
+
execFileSync('git', ['add', '-A'], { stdio: 'pipe', cwd: this.projectPath });
|
|
1022
|
+
execFileSync('git', ['commit', '-m', msg], { stdio: 'pipe', cwd: this.projectPath });
|
|
842
1023
|
console.log(t('flow.advance.gitCommitOkTmpl', lang, { stage: stageName }));
|
|
843
1024
|
}
|
|
844
1025
|
catch {
|
|
@@ -858,6 +1039,11 @@ export class FlowManager {
|
|
|
858
1039
|
if (this.data.stages[stageId]) {
|
|
859
1040
|
throw new Error(`Stage '${stageId}' already exists`);
|
|
860
1041
|
}
|
|
1042
|
+
// 冲突检测:不同 stageId 映射同一 (series, stageDir) 时拒绝写入(结构化错误,命令层按类型分流 i18n)
|
|
1043
|
+
const conflict = findStageDirConflict(this.projectPath, stageId);
|
|
1044
|
+
if (conflict) {
|
|
1045
|
+
throw new StageDirConflictError(stageId, conflict);
|
|
1046
|
+
}
|
|
861
1047
|
this.data.stages[stageId] = {
|
|
862
1048
|
name: stageId,
|
|
863
1049
|
phase: initialPhase,
|
|
@@ -876,6 +1062,138 @@ export class FlowManager {
|
|
|
876
1062
|
detail: { stageId, phase: initialPhase },
|
|
877
1063
|
});
|
|
878
1064
|
}
|
|
1065
|
+
/**
|
|
1066
|
+
* 检查阶段是否可安全移除(只读,不写盘)。
|
|
1067
|
+
*
|
|
1068
|
+
* 判定顺序:ops 非空 → 当前活跃阶段 → 被其它阶段 deps 引用。
|
|
1069
|
+
* force=true 时越过三项校验(ok 恒 true,阶段不存在除外),但 opCount/isCurrent/referencing 仍如实返回。
|
|
1070
|
+
*
|
|
1071
|
+
* deps 匹配:精确相等,或 dep/target 经 parseStageId 解析后 (series, stageDir) 相同(兼容短名)。
|
|
1072
|
+
* 存量 stage 可能缺 deps 字段(StageData.deps 虽为必填,历史数据未必),以 Array.isArray 守卫。
|
|
1073
|
+
*
|
|
1074
|
+
* @param stageId 目标阶段 ID
|
|
1075
|
+
* @param options.force 是否越权检查
|
|
1076
|
+
* @returns 可移除性检查结果
|
|
1077
|
+
*/
|
|
1078
|
+
checkRemovable(stageId, options = {}) {
|
|
1079
|
+
if (!this.data) {
|
|
1080
|
+
return { ok: false, reason: '流水线数据未加载', opCount: 0, isCurrent: false, referencing: [] };
|
|
1081
|
+
}
|
|
1082
|
+
// REV-007:入口归一化(短名 → 全名)+ 双键回退(归一化键优先,短名键不被误改)
|
|
1083
|
+
const key = normalizeStageId(stageId) ?? stageId;
|
|
1084
|
+
const raw = this.data.stages[stageId];
|
|
1085
|
+
const stageKey = this.data.stages[key] ? key : (raw ? stageId : key);
|
|
1086
|
+
const stage = this.data.stages[stageKey];
|
|
1087
|
+
if (!stage) {
|
|
1088
|
+
return { ok: false, reason: `阶段不存在:'${stageKey}'`, opCount: 0, isCurrent: false, referencing: [] };
|
|
1089
|
+
}
|
|
1090
|
+
const opCount = stage.ops && typeof stage.ops === 'object' && !Array.isArray(stage.ops)
|
|
1091
|
+
? Object.keys(stage.ops).length
|
|
1092
|
+
: 0;
|
|
1093
|
+
const isCurrent = this.data.pipeline.current.stage === stageKey;
|
|
1094
|
+
// 引用者扫描:其余 stages 的 deps 命中该阶段者(REV-004)
|
|
1095
|
+
const target = parseStageId(stageKey);
|
|
1096
|
+
const referencing = [];
|
|
1097
|
+
for (const [otherId, otherStage] of Object.entries(this.data.stages)) {
|
|
1098
|
+
if (otherId === stageKey) {
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
// 存量 stage 可能缺 deps 字段,以 Array.isArray 守卫
|
|
1102
|
+
const deps = Array.isArray(otherStage.deps) ? otherStage.deps : [];
|
|
1103
|
+
const hit = deps.some((dep) => {
|
|
1104
|
+
if (dep === stageKey) {
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1107
|
+
const parsed = parseStageId(dep);
|
|
1108
|
+
return !!parsed && !!target && parsed.series === target.series && parsed.stageDir === target.stageDir;
|
|
1109
|
+
});
|
|
1110
|
+
if (hit) {
|
|
1111
|
+
referencing.push(otherId);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if (!options.force) {
|
|
1115
|
+
// 错误路径:ops 非空(存在未归档操作方案)
|
|
1116
|
+
if (opCount > 0) {
|
|
1117
|
+
return { ok: false, reason: `阶段 '${stageKey}' 仍有 ${opCount} 个未归档的 op;如需强制移除此阶段,请加 --force`, opCount, isCurrent, referencing };
|
|
1118
|
+
}
|
|
1119
|
+
// 错误路径:当前活跃阶段
|
|
1120
|
+
if (isCurrent) {
|
|
1121
|
+
return { ok: false, reason: `阶段 '${stageKey}' 是当前活跃阶段;如需强制移除,请加 --force(将自动回退 current)`, opCount, isCurrent, referencing };
|
|
1122
|
+
}
|
|
1123
|
+
// 错误路径:被其它阶段 deps 引用(REV-004)
|
|
1124
|
+
if (referencing.length > 0) {
|
|
1125
|
+
return { ok: false, reason: `阶段 '${stageKey}' 被其它阶段依赖:${referencing.join(', ')};如需强制移除(将产生悬空依赖),请加 --force`, opCount, isCurrent, referencing };
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return { ok: true, opCount, isCurrent, referencing };
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* 移除流水线阶段(仅注销 flow.json 注册;**不删除目录**)。
|
|
1132
|
+
*
|
|
1133
|
+
* 安全校验复用 checkRemovable:ops 非空 / 当前活跃阶段 / 被其它阶段 deps 引用,默认拒绝,--force 越过。
|
|
1134
|
+
* --force 移除时**不清理**引用者 deps 中的悬空项(deps 为声明式引用;保留原样 + 日志 referencing/snapshot 使失真可审计)。
|
|
1135
|
+
* 移除后 current 兜底:若 current 指向被删阶段,按 stages 插入序回退首个非 done;无则清空 current。
|
|
1136
|
+
* 目录删除由调用方在 `save()` 成功后执行(REV-41 REV-009 事务顺序:避免 save 失败但目录已删的中间态)。
|
|
1137
|
+
*
|
|
1138
|
+
* @param stageId 目标阶段 ID
|
|
1139
|
+
* @param options.force 越过安全校验;options.purge 计算待删 plan 目录(不在此处删除)
|
|
1140
|
+
* @returns purgeTarget:`--purge` 时待删除的 plan 目录绝对路径;否则 undefined
|
|
1141
|
+
*/
|
|
1142
|
+
removeStage(stageId, options = {}) {
|
|
1143
|
+
if (!this.data) {
|
|
1144
|
+
return {};
|
|
1145
|
+
}
|
|
1146
|
+
// REV-007:入口归一化(短名 → 全名)+ 双键回退(归一化键优先,短名键不被误改),以 stageKey 贯穿
|
|
1147
|
+
const key = normalizeStageId(stageId) ?? stageId;
|
|
1148
|
+
const raw = this.data.stages[stageId];
|
|
1149
|
+
const stageKey = this.data.stages[key] ? key : (raw ? stageId : key);
|
|
1150
|
+
const check = this.checkRemovable(stageKey, { force: options.force });
|
|
1151
|
+
if (!check.ok) {
|
|
1152
|
+
// 错误路径:安全校验未通过 → 保留既有文案(用户输入原样)
|
|
1153
|
+
throw new Error(check.reason ?? `无法移除阶段 '${stageId}'`);
|
|
1154
|
+
}
|
|
1155
|
+
// 阶段快照(REV-006):误删后可从审计日志重建
|
|
1156
|
+
const stage = this.data.stages[stageKey];
|
|
1157
|
+
const snapshot = {
|
|
1158
|
+
phase: stage.phase,
|
|
1159
|
+
status: stage.status,
|
|
1160
|
+
deps: Array.isArray(stage.deps) ? [...stage.deps] : [],
|
|
1161
|
+
opKeys: Object.keys(stage.ops ?? {}),
|
|
1162
|
+
};
|
|
1163
|
+
// ── 注销 flow.json 注册 ──
|
|
1164
|
+
delete this.data.stages[stageKey];
|
|
1165
|
+
// ── current 兜底:不悬空 ──
|
|
1166
|
+
if (this.data.pipeline.current.stage === stageKey) {
|
|
1167
|
+
const remaining = Object.keys(this.data.stages);
|
|
1168
|
+
const fallback = remaining.find((k) => this.data.stages[k].phase !== 'done');
|
|
1169
|
+
if (fallback) {
|
|
1170
|
+
const pendingOp = Object.entries(this.data.stages[fallback].ops ?? {}).find(([, op]) => op.state === 'pending' || op.state === 'executing');
|
|
1171
|
+
this.data.pipeline.current = { stage: fallback, op: pendingOp ? pendingOp[0] : '' };
|
|
1172
|
+
}
|
|
1173
|
+
else {
|
|
1174
|
+
this.data.pipeline.current = { stage: '', op: '' };
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
// ── --purge:仅计算待删目录,不在此处删除(须 save() 成功后再删,REV-009 事务顺序) ──
|
|
1178
|
+
let purgeTarget;
|
|
1179
|
+
if (options.purge) {
|
|
1180
|
+
const parsed = parseStageId(stageKey);
|
|
1181
|
+
if (parsed) {
|
|
1182
|
+
const dir = resolve(this.projectPath, '.openfeel', 'plan', parsed.series, parsed.stageDir);
|
|
1183
|
+
if (existsSync(dir)) {
|
|
1184
|
+
purgeTarget = dir;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
// ── 审计日志(含引用者与阶段快照,REV-004 / REV-006;purgeTarget 记录删除意图) ──
|
|
1189
|
+
this.appendLog({
|
|
1190
|
+
time: '',
|
|
1191
|
+
agent: 'cli',
|
|
1192
|
+
action: 'remove_stage',
|
|
1193
|
+
detail: { stageId: stageKey, purgeTarget: purgeTarget ?? null, referencing: check.referencing, snapshot },
|
|
1194
|
+
});
|
|
1195
|
+
return { purgeTarget };
|
|
1196
|
+
}
|
|
879
1197
|
/**
|
|
880
1198
|
* 推进流水线阶段(旧 API)
|
|
881
1199
|
* @param opId 操作 ID(格式 "stage-xx.op-xxx")
|
|
@@ -885,7 +1203,7 @@ export class FlowManager {
|
|
|
885
1203
|
* @deprecated 请使用 advanceStagePhase(stageName, phase) 替代。内部保留 op 级逻辑后委托给 advanceStagePhase。
|
|
886
1204
|
*/
|
|
887
1205
|
advancePhase(opId, to, stageId, force) {
|
|
888
|
-
console.warn('
|
|
1206
|
+
console.warn(t('flow.manager.advancePhaseDeprecated', getCliLang(this.projectPath)));
|
|
889
1207
|
// ── 1. 校验目标 phase ──
|
|
890
1208
|
let targetPhase;
|
|
891
1209
|
const phaseResult = PipelinePhaseSchema.safeParse(to);
|
|
@@ -893,7 +1211,7 @@ export class FlowManager {
|
|
|
893
1211
|
if (force) {
|
|
894
1212
|
const corrected = this.fuzzyCorrectPhase(to);
|
|
895
1213
|
if (corrected) {
|
|
896
|
-
console.warn(
|
|
1214
|
+
console.warn(t('flow.manager.phaseAutoCorrectShortTmpl', getCliLang(this.projectPath), { to: String(to), corrected }));
|
|
897
1215
|
targetPhase = corrected;
|
|
898
1216
|
}
|
|
899
1217
|
else {
|
|
@@ -916,7 +1234,7 @@ export class FlowManager {
|
|
|
916
1234
|
// ── 2. 解析 stageId ──
|
|
917
1235
|
const resolvedStageId = stageId || (opId ? this.parseOpId(opId)?.stageId : this.data.pipeline.current.stage);
|
|
918
1236
|
if (!resolvedStageId) {
|
|
919
|
-
console.warn('
|
|
1237
|
+
console.warn(t('flow.manager.advancePhaseNoStageId', getCliLang(this.projectPath)));
|
|
920
1238
|
return;
|
|
921
1239
|
}
|
|
922
1240
|
// ── 3. Op 级工作(检查点更新、重试计数) ──
|
|
@@ -1010,6 +1328,91 @@ export class FlowManager {
|
|
|
1010
1328
|
return [];
|
|
1011
1329
|
return this.getValidTargets(currentPhase);
|
|
1012
1330
|
}
|
|
1331
|
+
/**
|
|
1332
|
+
* 在运行时 transitions 上求 stageName 当前 phase → to 的**唯一**可达路径(B5-1)。
|
|
1333
|
+
* BFS(深度上限 PHASE_PATH_MAX_DEPTH);最短深度上存在多条路径 → ambiguous(拒绝,不猜测)。
|
|
1334
|
+
* 纯只读:不写盘、不改内存。
|
|
1335
|
+
* @param stageName 阶段 ID
|
|
1336
|
+
* @param to 目标 phase
|
|
1337
|
+
* @returns 路径与原因(no-path/ambiguous/depth-exceeded 供命令层报错;already-at-target 按 no-op)
|
|
1338
|
+
*/
|
|
1339
|
+
findPhasePath(stageName, to) {
|
|
1340
|
+
// REV-005:归一化 stageId(短名 → 全名),与 setStageDeps / plan scheme * 同范式
|
|
1341
|
+
// 双键回退:兼容以短名建键的存量 / 测试数据(归一化键不存在时回退原始键)
|
|
1342
|
+
const normalized = normalizeStageId(stageName) ?? stageName;
|
|
1343
|
+
const key = this.data?.stages[normalized] ? normalized : stageName;
|
|
1344
|
+
const start = this.data?.stages[key]?.phase;
|
|
1345
|
+
if (!start) {
|
|
1346
|
+
return { path: [], reason: 'no-path' };
|
|
1347
|
+
}
|
|
1348
|
+
if (start === to) {
|
|
1349
|
+
return { path: [], reason: 'already-at-target' };
|
|
1350
|
+
}
|
|
1351
|
+
// BFS 逐层扩展;visitedDepth 记录各 phase 首次到达的最小深度(剪枝,避免重复/环)
|
|
1352
|
+
const visitedDepth = new Map();
|
|
1353
|
+
visitedDepth.set(start, 0);
|
|
1354
|
+
let layer = [{ phase: start, path: [] }];
|
|
1355
|
+
while (layer.length > 0) {
|
|
1356
|
+
const hits = [];
|
|
1357
|
+
const nextLayer = [];
|
|
1358
|
+
for (const node of layer) {
|
|
1359
|
+
// 深度已达上限 → 不再扩展(超限在下方统一判定)
|
|
1360
|
+
if (node.path.length >= PHASE_PATH_MAX_DEPTH) {
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
for (const target of this.getValidTargets(node.phase)) {
|
|
1364
|
+
const path = [...node.path, target];
|
|
1365
|
+
if (target === to) {
|
|
1366
|
+
hits.push(path);
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
const prevDepth = visitedDepth.get(target);
|
|
1370
|
+
if (prevDepth !== undefined && prevDepth <= path.length) {
|
|
1371
|
+
continue; // 已在更浅/等深访问过 → 剪枝
|
|
1372
|
+
}
|
|
1373
|
+
visitedDepth.set(target, path.length);
|
|
1374
|
+
nextLayer.push({ phase: target, path });
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
if (hits.length > 1) {
|
|
1378
|
+
return { path: [], reason: 'ambiguous' }; // 最短深度多条等价路径 → 拒绝
|
|
1379
|
+
}
|
|
1380
|
+
if (hits.length === 1) {
|
|
1381
|
+
return { path: hits[0], reason: 'ok' };
|
|
1382
|
+
}
|
|
1383
|
+
// 本层已到深度上限但未命中 → 拒绝(depth-exceeded)
|
|
1384
|
+
if (layer[0]?.path.length !== undefined && layer[0].path.length + 1 > PHASE_PATH_MAX_DEPTH) {
|
|
1385
|
+
return { path: [], reason: 'depth-exceeded' };
|
|
1386
|
+
}
|
|
1387
|
+
layer = nextLayer;
|
|
1388
|
+
}
|
|
1389
|
+
return { path: [], reason: 'no-path' };
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* 获取运行时生效的 phase 列表(供 flow phases 自描述)。
|
|
1393
|
+
* 数据源 = this.pipelineConfig(构造时经 loadPipelineConfig 加载 .openfeel/pipeline.yaml);
|
|
1394
|
+
* 缺省/解析失败时回退内置默认配置。返回副本,避免调用方修改内部状态。
|
|
1395
|
+
*/
|
|
1396
|
+
getPipelinePhases() {
|
|
1397
|
+
const phases = this.pipelineConfig?.phases ?? this.getDefaultPipelineConfig().phases;
|
|
1398
|
+
return [...phases];
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* 获取运行时生效的 phase 转移表(key 可含 '|' 组合条件)。
|
|
1402
|
+
* 缺省/解析失败时回退内置默认配置。返回浅拷贝。
|
|
1403
|
+
*/
|
|
1404
|
+
getPipelineTransitions() {
|
|
1405
|
+
const transitions = this.pipelineConfig?.transitions ?? this.getDefaultPipelineConfig().transitions;
|
|
1406
|
+
return Object.fromEntries(Object.entries(transitions).map(([k, v]) => [k, [...v]]));
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* 获取内置默认 phase 转移表(不受项目 pipeline.yaml 影响)。
|
|
1410
|
+
* 供 `flow phases` 做「运行时 vs 默认」差异报告(T19)。
|
|
1411
|
+
* @returns 默认 transitions 的深拷贝
|
|
1412
|
+
*/
|
|
1413
|
+
getDefaultTransitions() {
|
|
1414
|
+
return Object.fromEntries(Object.entries(this.getDefaultPipelineConfig().transitions).map(([k, v]) => [k, [...v]]));
|
|
1415
|
+
}
|
|
1013
1416
|
/**
|
|
1014
1417
|
* 解析当前阶段 phase:优先使用传入的 stageName,其次 current.stage,最后 fallback
|
|
1015
1418
|
*/
|
|
@@ -1019,7 +1422,11 @@ export class FlowManager {
|
|
|
1019
1422
|
const targetStageName = stageName || this.data.pipeline.current.stage;
|
|
1020
1423
|
if (!targetStageName)
|
|
1021
1424
|
return null;
|
|
1022
|
-
|
|
1425
|
+
// REV-005:归一化 stageId(短名 → 全名);current.stage 本身为全名时归一化为恒等
|
|
1426
|
+
// 双键回退:兼容以短名建键的存量 / 测试数据
|
|
1427
|
+
const normalized = normalizeStageId(targetStageName) ?? targetStageName;
|
|
1428
|
+
const key = this.data.stages[normalized] ? normalized : targetStageName;
|
|
1429
|
+
const stage = this.data.stages[key];
|
|
1023
1430
|
return stage?.phase ?? null;
|
|
1024
1431
|
}
|
|
1025
1432
|
/**
|
|
@@ -1095,12 +1502,31 @@ export class FlowManager {
|
|
|
1095
1502
|
const downstreamPhases = this.buildDownstreamPhases();
|
|
1096
1503
|
return { basic, cascade, recentChanges, downstreamPhases };
|
|
1097
1504
|
}
|
|
1098
|
-
/**
|
|
1505
|
+
/**
|
|
1506
|
+
* 构建配置级联信息(优先级:profileDefaults < configDefaults < statusOverrides)
|
|
1507
|
+
* 全局画像仅作最低优先级兜底(P2),且只取 preferences.auto_advance 单值,
|
|
1508
|
+
* 不对 preferences 做整体替换(与 config.yaml defaults 键集重叠的仅 auto_advance)。
|
|
1509
|
+
*/
|
|
1099
1510
|
buildCascadeConfig() {
|
|
1511
|
+
const profileDefaults = {};
|
|
1100
1512
|
const configDefaults = {};
|
|
1101
1513
|
const statusOverrides = {};
|
|
1102
|
-
|
|
1103
|
-
//
|
|
1514
|
+
// 最低优先级:全局画像 ~/.config/openfeel/profile.yaml 的 preferences.auto_advance(P2 兜底)
|
|
1515
|
+
// BUG-003(stage-47):仅当画像文件真实存在且原始 YAML **显式声明**该键时兜底;否则落 builtin。
|
|
1516
|
+
const profilePath = getGlobalProfilePath();
|
|
1517
|
+
if (existsSync(profilePath)) {
|
|
1518
|
+
try {
|
|
1519
|
+
const raw = parseYaml(readFileSync(profilePath, 'utf-8'));
|
|
1520
|
+
const explicit = raw?.preferences?.auto_advance;
|
|
1521
|
+
if (explicit === 'enabled' || explicit === 'disabled') {
|
|
1522
|
+
profileDefaults['auto_advance'] = explicit;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
catch {
|
|
1526
|
+
// 解析失败 → 不填 → 落 builtin(更贴近「无有效画像」)
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
// 中优先级:项目 .openfeel/config.yaml 的 defaults 块
|
|
1104
1530
|
const configPath = resolve(this.projectPath, '.openfeel', 'config.yaml');
|
|
1105
1531
|
if (existsSync(configPath)) {
|
|
1106
1532
|
try {
|
|
@@ -1108,9 +1534,23 @@ export class FlowManager {
|
|
|
1108
1534
|
const config = parseYaml(raw);
|
|
1109
1535
|
if (config && typeof config === 'object' && config.defaults) {
|
|
1110
1536
|
const defaults = config.defaults;
|
|
1537
|
+
const lang = getCliLang(this.projectPath);
|
|
1111
1538
|
for (const [key, value] of Object.entries(defaults)) {
|
|
1112
|
-
|
|
1113
|
-
|
|
1539
|
+
// 逐键用 ConfigDefaultsSchema 校验(T29):非法值跳过并告警,保持「有效配置」语义可用
|
|
1540
|
+
const fieldSchema = ConfigDefaultsSchema.shape[key];
|
|
1541
|
+
if (!fieldSchema) {
|
|
1542
|
+
// 非受管扩展键:保持原行为纳入
|
|
1543
|
+
configDefaults[key] = String(value);
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
const parsed = fieldSchema.safeParse(value);
|
|
1547
|
+
if (parsed.success) {
|
|
1548
|
+
configDefaults[key] = String(value);
|
|
1549
|
+
}
|
|
1550
|
+
else {
|
|
1551
|
+
const reason = parsed.error?.issues[0]?.message ?? 'invalid';
|
|
1552
|
+
console.warn(t('config.effective.invalidValueSkipped', lang, { key, reason }));
|
|
1553
|
+
}
|
|
1114
1554
|
}
|
|
1115
1555
|
}
|
|
1116
1556
|
}
|
|
@@ -1118,7 +1558,7 @@ export class FlowManager {
|
|
|
1118
1558
|
// 解析失败则用空值
|
|
1119
1559
|
}
|
|
1120
1560
|
}
|
|
1121
|
-
//
|
|
1561
|
+
// 最高优先级:当前 stage 的 status.md(如果存在)
|
|
1122
1562
|
if (this.data && this.data.pipeline.current.stage) {
|
|
1123
1563
|
const stageId = this.data.pipeline.current.stage;
|
|
1124
1564
|
const statusPath = this.findStatusPath(stageId);
|
|
@@ -1130,11 +1570,9 @@ export class FlowManager {
|
|
|
1130
1570
|
const autoMatch = content.match(/\*\*自动推进\*\*[::]\s*(disabled|enabled)/);
|
|
1131
1571
|
if (execMatch) {
|
|
1132
1572
|
statusOverrides['execution_mode'] = execMatch[1];
|
|
1133
|
-
effective['execution_mode'] = execMatch[1];
|
|
1134
1573
|
}
|
|
1135
1574
|
if (autoMatch) {
|
|
1136
1575
|
statusOverrides['auto_advance'] = autoMatch[1];
|
|
1137
|
-
effective['auto_advance'] = autoMatch[1];
|
|
1138
1576
|
}
|
|
1139
1577
|
}
|
|
1140
1578
|
catch {
|
|
@@ -1142,7 +1580,35 @@ export class FlowManager {
|
|
|
1142
1580
|
}
|
|
1143
1581
|
}
|
|
1144
1582
|
}
|
|
1145
|
-
|
|
1583
|
+
// 级联合并(后写覆盖前写):全局画像兜底 < 项目 config < status.md 覆盖
|
|
1584
|
+
const effective = { ...profileDefaults, ...configDefaults, ...statusOverrides };
|
|
1585
|
+
return { profileDefaults, configDefaults, statusOverrides, effective };
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* 解析四个受管配置键的「有效值 + 生效来源」(`openfeel config effective`,P9)。
|
|
1589
|
+
* 复用 buildCascadeConfig(单一权威,避免第二套解析),优先级:
|
|
1590
|
+
* status.md > config.yaml > profile.yaml > builtin。
|
|
1591
|
+
* @returns key → { value, source }
|
|
1592
|
+
*/
|
|
1593
|
+
resolveEffectiveConfig() {
|
|
1594
|
+
const cascade = this.buildCascadeConfig();
|
|
1595
|
+
const result = {};
|
|
1596
|
+
for (const key of EFFECTIVE_CONFIG_KEYS) {
|
|
1597
|
+
if (cascade.statusOverrides[key] !== undefined) {
|
|
1598
|
+
result[key] = { value: cascade.statusOverrides[key], source: 'status.md' };
|
|
1599
|
+
}
|
|
1600
|
+
else if (cascade.configDefaults[key] !== undefined) {
|
|
1601
|
+
result[key] = { value: cascade.configDefaults[key], source: 'config.yaml' };
|
|
1602
|
+
}
|
|
1603
|
+
else if (cascade.profileDefaults[key] !== undefined) {
|
|
1604
|
+
result[key] = { value: cascade.profileDefaults[key], source: 'profile.yaml' };
|
|
1605
|
+
}
|
|
1606
|
+
else {
|
|
1607
|
+
// 三层皆无 → 框架内置默认
|
|
1608
|
+
result[key] = { value: String(DEFAULT_CONFIG[key]), source: 'builtin' };
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
return result;
|
|
1146
1612
|
}
|
|
1147
1613
|
/** 查找 status.md 的路径(三级回退:plan/{series}/ 精确 → plan 递归 → stages 兜底) */
|
|
1148
1614
|
findStatusPath(stageId) {
|
|
@@ -1254,9 +1720,25 @@ export class FlowManager {
|
|
|
1254
1720
|
if (!op) {
|
|
1255
1721
|
return { shouldRetry: false, shouldReplan: false };
|
|
1256
1722
|
}
|
|
1723
|
+
// B4-5(A1 第 5 条)/ REV-52-002:draft op 拒绝记录执行结果(双层守卫之**核心层兜底**)。
|
|
1724
|
+
// 命令层已先行守卫;此处保证 API 调用方亦被拦截。返回既有结构,不抛错(不改返回契约)。
|
|
1725
|
+
if (op.state === 'draft') {
|
|
1726
|
+
this.appendLog({
|
|
1727
|
+
time: '',
|
|
1728
|
+
agent: 'openfeel-executor',
|
|
1729
|
+
action: 'attempt_refused_draft',
|
|
1730
|
+
detail: { opId },
|
|
1731
|
+
});
|
|
1732
|
+
return { shouldRetry: false, shouldReplan: false };
|
|
1733
|
+
}
|
|
1257
1734
|
op.attempts += 1;
|
|
1735
|
+
// current.op 生命周期单一 owner:state 变更后同步(T1,禁双实现)
|
|
1736
|
+
const stageIdOfOp = this.parseOpId(opId)?.stageId;
|
|
1258
1737
|
if (result === 'pass') {
|
|
1259
1738
|
op.state = 'done';
|
|
1739
|
+
if (stageIdOfOp) {
|
|
1740
|
+
this.syncCurrentOp(stageIdOfOp);
|
|
1741
|
+
}
|
|
1260
1742
|
this.data.pipeline.retry = 0;
|
|
1261
1743
|
this.appendLog({
|
|
1262
1744
|
time: '',
|
|
@@ -1275,6 +1757,9 @@ export class FlowManager {
|
|
|
1275
1757
|
// result === 'fail'
|
|
1276
1758
|
if (op.attempts < op.max_attempts) {
|
|
1277
1759
|
op.state = 'pending'; // 回到 pending 等待重试
|
|
1760
|
+
if (stageIdOfOp) {
|
|
1761
|
+
this.syncCurrentOp(stageIdOfOp);
|
|
1762
|
+
}
|
|
1278
1763
|
this.data.pipeline.retry += 1;
|
|
1279
1764
|
this.appendLog({
|
|
1280
1765
|
time: '',
|
|
@@ -1338,6 +1823,89 @@ export class FlowManager {
|
|
|
1338
1823
|
review.status = 'resolved';
|
|
1339
1824
|
return true;
|
|
1340
1825
|
}
|
|
1826
|
+
/**
|
|
1827
|
+
* 更新审查条目的可编辑字段(N2-2):仅覆盖 patch 中提供的键。
|
|
1828
|
+
* 留审计日志 `review_update`,并 save()。
|
|
1829
|
+
* @param revId 审查条目 ID
|
|
1830
|
+
* @param patch 待覆盖字段(priority/title/blocking)
|
|
1831
|
+
* @returns 是否命中条目
|
|
1832
|
+
*/
|
|
1833
|
+
updateReview(revId, patch) {
|
|
1834
|
+
if (!this.data) {
|
|
1835
|
+
return false;
|
|
1836
|
+
}
|
|
1837
|
+
const review = this.data.reviews.find((r) => r.id === revId);
|
|
1838
|
+
if (!review) {
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
if (patch.priority !== undefined) {
|
|
1842
|
+
review.priority = patch.priority;
|
|
1843
|
+
}
|
|
1844
|
+
if (patch.title !== undefined) {
|
|
1845
|
+
review.title = patch.title;
|
|
1846
|
+
}
|
|
1847
|
+
if (patch.blocking !== undefined) {
|
|
1848
|
+
review.blocking = patch.blocking;
|
|
1849
|
+
}
|
|
1850
|
+
this.appendLog({
|
|
1851
|
+
time: '',
|
|
1852
|
+
agent: 'openfeel-reviewer',
|
|
1853
|
+
action: 'review_update',
|
|
1854
|
+
detail: { revId, patch: patch },
|
|
1855
|
+
});
|
|
1856
|
+
this.save();
|
|
1857
|
+
return true;
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* 删除审查条目(N2-2):留审计日志 `review_remove`,并 save()。
|
|
1861
|
+
* @param revId 审查条目 ID
|
|
1862
|
+
* @returns 是否命中并删除
|
|
1863
|
+
*/
|
|
1864
|
+
removeReview(revId) {
|
|
1865
|
+
if (!this.data) {
|
|
1866
|
+
return false;
|
|
1867
|
+
}
|
|
1868
|
+
const idx = this.data.reviews.findIndex((r) => r.id === revId);
|
|
1869
|
+
if (idx === -1) {
|
|
1870
|
+
return false;
|
|
1871
|
+
}
|
|
1872
|
+
const removed = this.data.reviews[idx];
|
|
1873
|
+
this.data.reviews.splice(idx, 1);
|
|
1874
|
+
this.appendLog({
|
|
1875
|
+
time: '',
|
|
1876
|
+
agent: 'openfeel-reviewer',
|
|
1877
|
+
action: 'review_remove',
|
|
1878
|
+
detail: { revId, title: removed.title },
|
|
1879
|
+
});
|
|
1880
|
+
this.save();
|
|
1881
|
+
return true;
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* 覆盖写入某阶段的 deps 列表(N2-1)。
|
|
1885
|
+
* 悬空依赖的强制校验在命令层(核心层不静默,仅写入 + 审计日志 + save)。
|
|
1886
|
+
* @param stageName 阶段 ID(简写或全称,经 normalizeStageId 归一)
|
|
1887
|
+
* @param deps 依赖阶段 ID 列表
|
|
1888
|
+
* @throws Error 阶段不存在时抛出(调用方决定退出码)
|
|
1889
|
+
*/
|
|
1890
|
+
setStageDeps(stageName, deps) {
|
|
1891
|
+
if (!this.data) {
|
|
1892
|
+
throw new Error('flow.json 未加载');
|
|
1893
|
+
}
|
|
1894
|
+
const normalized = normalizeStageId(stageName) ?? stageName;
|
|
1895
|
+
const stage = this.data.stages[normalized] ?? this.data.stages[stageName];
|
|
1896
|
+
if (!stage) {
|
|
1897
|
+
throw new Error(`阶段 '${stageName}' 不存在`);
|
|
1898
|
+
}
|
|
1899
|
+
const next = [...deps];
|
|
1900
|
+
stage.deps = next;
|
|
1901
|
+
this.appendLog({
|
|
1902
|
+
time: '',
|
|
1903
|
+
agent: 'cli',
|
|
1904
|
+
action: 'stage_deps_set',
|
|
1905
|
+
detail: { stage: normalized, deps: next },
|
|
1906
|
+
});
|
|
1907
|
+
this.save();
|
|
1908
|
+
}
|
|
1341
1909
|
/**
|
|
1342
1910
|
* 添加自动修复审查条目
|
|
1343
1911
|
* 当 openfeel-reviewer 认为问题可直接修复时调用,REV 条目状态直接设为 resolved,
|
|
@@ -1354,15 +1922,18 @@ export class FlowManager {
|
|
|
1354
1922
|
console.error(`错误:opId 格式不正确 "${opId}",应为 stage-xx.op-xxx`);
|
|
1355
1923
|
return;
|
|
1356
1924
|
}
|
|
1357
|
-
const
|
|
1358
|
-
|
|
1925
|
+
const rawStageId = opId.substring(0, opId.lastIndexOf('.'));
|
|
1926
|
+
// REV-009:短名前缀归一化 + 双键回退(与 parseOpId / setStageDeps 同范式)
|
|
1927
|
+
const stageId = normalizeStageId(rawStageId) ?? rawStageId;
|
|
1928
|
+
const stage = this.data.stages[stageId] ?? this.data.stages[rawStageId];
|
|
1359
1929
|
if (!stage) {
|
|
1360
|
-
|
|
1930
|
+
// 诊断文案显示用户输入的原始 stage 名(便于定位)
|
|
1931
|
+
console.error(`错误:opId "${opId}" 中的 stage "${rawStageId}" 不存在`);
|
|
1361
1932
|
return;
|
|
1362
1933
|
}
|
|
1363
1934
|
// 前置条件:仅允许从 review_failed 状态调用,检查对应 stage 的 phase
|
|
1364
1935
|
if (stage.phase !== 'review_failed') {
|
|
1365
|
-
console.warn(
|
|
1936
|
+
console.warn(t('flow.manager.autoFixPhaseGuardTmpl', getCliLang(this.projectPath), { stage: stageId, phase: String(stage.phase) }));
|
|
1366
1937
|
return;
|
|
1367
1938
|
}
|
|
1368
1939
|
// 标记为自动修复
|
|
@@ -1477,6 +2048,10 @@ export class FlowManager {
|
|
|
1477
2048
|
else {
|
|
1478
2049
|
// 遍历每个 stage,校验其 phase 字段为合法 PipelinePhase
|
|
1479
2050
|
for (const [stageId, stage] of Object.entries(this.data.stages)) {
|
|
2051
|
+
// T2:normalize 已补 ops 则不报;仅对无法识别的结构(非普通对象)报错
|
|
2052
|
+
if (typeof stage.ops !== 'object' || stage.ops === null || Array.isArray(stage.ops)) {
|
|
2053
|
+
errors.push(`stages.${stageId}.ops 缺失或不是有效对象`);
|
|
2054
|
+
}
|
|
1480
2055
|
if (!stage.phase) {
|
|
1481
2056
|
warnings.push(`stages.${stageId}.phase 缺失,设为默认 'plan_pending'`);
|
|
1482
2057
|
stage.phase = 'plan_pending';
|
|
@@ -1548,11 +2123,10 @@ export class FlowManager {
|
|
|
1548
2123
|
if (containsMatches.length === 1) {
|
|
1549
2124
|
return containsMatches[0];
|
|
1550
2125
|
}
|
|
1551
|
-
//
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
}
|
|
2126
|
+
// 后缀匹配(唯一命中时采用;多重命中返回 null,与 prefix/contains 分支语义对齐,T4)
|
|
2127
|
+
const suffixMatches = PIPELINE_PHASES.filter((p) => p.endsWith('_' + normalized) || p.endsWith(normalized));
|
|
2128
|
+
if (suffixMatches.length === 1) {
|
|
2129
|
+
return suffixMatches[0];
|
|
1556
2130
|
}
|
|
1557
2131
|
// 常见拼写修正(硬编码扩展)
|
|
1558
2132
|
const extraCorrections = {
|
|
@@ -1601,7 +2175,7 @@ export class FlowManager {
|
|
|
1601
2175
|
* @param dryRun true 时仅检测不修复
|
|
1602
2176
|
* @returns 修复结果(修改列表 + 是否从 .bak 恢复)
|
|
1603
2177
|
*/
|
|
1604
|
-
repair(dryRun = false) {
|
|
2178
|
+
repair(dryRun = false, options = {}) {
|
|
1605
2179
|
const changes = [];
|
|
1606
2180
|
let recovered = false;
|
|
1607
2181
|
// 尝试加载 flow.json
|
|
@@ -1797,6 +2371,32 @@ export class FlowManager {
|
|
|
1797
2371
|
changes.push('已补全缺失的 log');
|
|
1798
2372
|
modified = true;
|
|
1799
2373
|
}
|
|
2374
|
+
// ── N1-2:op 键 ↔ ops/ 目录对账(A2:默认只报告,不删任何条目)──
|
|
2375
|
+
const orphans = reconcileOrphans(this.projectPath, flowData.stages);
|
|
2376
|
+
// --prune-orphans:仅清理 keyOrphans(flow.json 孤儿键);fileOrphans 永不自动删除
|
|
2377
|
+
if (options.pruneOrphans && !dryRun && orphans.keyOrphans.length > 0) {
|
|
2378
|
+
for (const orphan of orphans.keyOrphans) {
|
|
2379
|
+
const targetStage = flowData.stages?.[orphan.stage];
|
|
2380
|
+
if (targetStage && targetStage.ops && orphan.opId in targetStage.ops) {
|
|
2381
|
+
delete targetStage.ops[orphan.opId];
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
// 审计日志写入 flowData(repair 以 flowData 序列化落盘,而非 this.data)
|
|
2385
|
+
if (!Array.isArray(flowData.log)) {
|
|
2386
|
+
flowData.log = [];
|
|
2387
|
+
}
|
|
2388
|
+
flowData.log.push({
|
|
2389
|
+
time: new Date().toISOString(),
|
|
2390
|
+
agent: 'cli',
|
|
2391
|
+
action: 'prune_orphan_ops',
|
|
2392
|
+
detail: {
|
|
2393
|
+
count: orphans.keyOrphans.length,
|
|
2394
|
+
ops: orphans.keyOrphans.map((o) => `${o.stage}.${o.opId}`),
|
|
2395
|
+
},
|
|
2396
|
+
});
|
|
2397
|
+
changes.push(`已清理 ${orphans.keyOrphans.length} 个键孤儿(文件未删除)`);
|
|
2398
|
+
modified = true;
|
|
2399
|
+
}
|
|
1800
2400
|
// 写入修复后的数据(recovered 场景下磁盘文件仍损坏,即使无字段修改也必须写回恢复内容)
|
|
1801
2401
|
if ((modified || recovered) && !dryRun) {
|
|
1802
2402
|
// (乐观并发修订)恢复/修复亦视为一次写入:先在源对象上递增 revision,
|
|
@@ -1833,34 +2433,43 @@ export class FlowManager {
|
|
|
1833
2433
|
this.load();
|
|
1834
2434
|
}
|
|
1835
2435
|
// changes 在无问题时保持空数组,CLI 层通过 changes.length === 0 判断"没问题"
|
|
1836
|
-
return { fixed: modified || recovered, changes, recovered };
|
|
2436
|
+
return { fixed: modified || recovered, changes, recovered, orphans };
|
|
1837
2437
|
}
|
|
1838
2438
|
/**
|
|
1839
2439
|
* 自动修复指定阶段的 phase/status 不一致
|
|
1840
2440
|
* - status=done 但 phase≠done → 同步 phase 为 done
|
|
1841
2441
|
* - phase=done 但 status≠done → 同步 status 为 done
|
|
1842
2442
|
* @param stageName 阶段名(如 stage-01)
|
|
2443
|
+
* @param options.dryRun 为 true 时仅计算并返回等价结果,不修改内存(供 --dry-run 预览用)
|
|
1843
2444
|
* @returns 修复结果(是否修复 + 详情)
|
|
1844
2445
|
*/
|
|
1845
|
-
autoRepairInconsistency(stageName) {
|
|
2446
|
+
autoRepairInconsistency(stageName, options = {}) {
|
|
1846
2447
|
if (!this.data) {
|
|
1847
2448
|
return { fixed: false, detail: 'flow.json 未加载' };
|
|
1848
2449
|
}
|
|
1849
|
-
|
|
2450
|
+
// REV-005:归一化 stageId(短名 → 全名),否则短名调用会静默返回「不存在」
|
|
2451
|
+
// 双键回退:兼容以短名建键的存量 / 测试数据
|
|
2452
|
+
const normalized = normalizeStageId(stageName) ?? stageName;
|
|
2453
|
+
const key = this.data.stages[normalized] ? normalized : stageName;
|
|
2454
|
+
const stage = this.data.stages[key];
|
|
1850
2455
|
if (!stage) {
|
|
1851
2456
|
return { fixed: false, detail: `阶段 '${stageName}' 不存在` };
|
|
1852
2457
|
}
|
|
1853
|
-
// 修复: status=done 但 phase≠done
|
|
2458
|
+
// 修复: status=done 但 phase≠done → 同步 phase 为 done(dryRun 时仅报告,不写内存)
|
|
1854
2459
|
if (stage.status === 'done' && stage.phase !== 'done') {
|
|
1855
|
-
const
|
|
1856
|
-
|
|
1857
|
-
|
|
2460
|
+
const detail = `phase ${stage.phase} → done (与 status 同步)`;
|
|
2461
|
+
if (!options.dryRun) {
|
|
2462
|
+
stage.phase = 'done';
|
|
2463
|
+
}
|
|
2464
|
+
return { fixed: true, detail };
|
|
1858
2465
|
}
|
|
1859
|
-
// 修复: phase=done 但 status≠done
|
|
2466
|
+
// 修复: phase=done 但 status≠done → 同步 status(dryRun 时仅报告)
|
|
1860
2467
|
if (stage.phase === 'done' && stage.status !== 'done') {
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1863
|
-
|
|
2468
|
+
const detail = `status ${stage.status} → done (与 phase 同步)`;
|
|
2469
|
+
if (!options.dryRun) {
|
|
2470
|
+
stage.status = 'done';
|
|
2471
|
+
}
|
|
2472
|
+
return { fixed: true, detail };
|
|
1864
2473
|
}
|
|
1865
2474
|
return { fixed: false, detail: '未检测到不一致' };
|
|
1866
2475
|
}
|
|
@@ -2000,9 +2609,190 @@ export class FlowManager {
|
|
|
2000
2609
|
if (!quick) {
|
|
2001
2610
|
this.checkDepsYaml(items);
|
|
2002
2611
|
}
|
|
2612
|
+
// ── 7. 悬空依赖检测 ──
|
|
2613
|
+
if (!quick) {
|
|
2614
|
+
this.checkDanglingDeps(items);
|
|
2615
|
+
}
|
|
2616
|
+
// ── 8. 孤儿 op 检测(N1-3)──
|
|
2617
|
+
if (!quick) {
|
|
2618
|
+
this.checkOrphanOps(items);
|
|
2619
|
+
}
|
|
2620
|
+
// ── 9. 空模板检测(B3/B4-4;显式跳过 draft)──
|
|
2621
|
+
if (!quick) {
|
|
2622
|
+
this.checkEmptyTemplates(items);
|
|
2623
|
+
}
|
|
2003
2624
|
const ok = items.every((i) => i.status !== 'fail');
|
|
2004
2625
|
return { items, ok };
|
|
2005
2626
|
}
|
|
2627
|
+
/**
|
|
2628
|
+
* 结构化健康报告访问器(B1-3:供 `flow health --json` 消费)。
|
|
2629
|
+
* 复用 healthCheck() 结果,仅做字段别名映射(section→name / message→detail),不改判定。
|
|
2630
|
+
* @param quick 是否快速模式(仅检查关键项)
|
|
2631
|
+
* @returns { ok, items: [{ name, status, detail }] }
|
|
2632
|
+
*/
|
|
2633
|
+
getHealthReport(quick = false) {
|
|
2634
|
+
const result = this.healthCheck(quick);
|
|
2635
|
+
return {
|
|
2636
|
+
ok: result.ok,
|
|
2637
|
+
items: result.items.map((i) => ({ name: i.section, status: i.status, detail: i.message })),
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* 以 flow.json 为权威,对账并回写各阶段 status.md 的「状态」字段(B2-1 / A2)。
|
|
2642
|
+
* 仅处理「状态」字段;**绝不**触碰执行模式/自动推进/当前任务/状态记录表(独立字段)。
|
|
2643
|
+
* 与 `commands/stage.ts:setStatusField` 同语义(定向替换,其余字节不变);后续可下沉合并。
|
|
2644
|
+
* @param options.dryRun true 时只计算差异、不写盘
|
|
2645
|
+
* @returns 差异清单(含 dry-run 预览结果);已一致项不入清单(保证幂等 applied=0)
|
|
2646
|
+
*/
|
|
2647
|
+
reconcileStatusMd(options) {
|
|
2648
|
+
const out = [];
|
|
2649
|
+
if (!this.data) {
|
|
2650
|
+
return out;
|
|
2651
|
+
}
|
|
2652
|
+
const testEnabled = this.buildCascadeConfig().effective['test_enabled'] !== 'false';
|
|
2653
|
+
for (const [stageId, stage] of Object.entries(this.data.stages)) {
|
|
2654
|
+
// 跳过 draft(未发布不参与对账,与 op-005 窄兼容一致)
|
|
2655
|
+
if (stage.phase === 'draft') {
|
|
2656
|
+
continue;
|
|
2657
|
+
}
|
|
2658
|
+
const authoritative = mapPhaseToStageStatus(stage.phase, stage.status, testEnabled);
|
|
2659
|
+
const statusPath = this.findStatusPath(stageId);
|
|
2660
|
+
if (!statusPath || !existsSync(statusPath)) {
|
|
2661
|
+
// status.md 缺失 → 安全跳过,不新建文件/字段
|
|
2662
|
+
out.push({ stage: stageId, from: '', to: authoritative, result: 'skipped-not-found' });
|
|
2663
|
+
continue;
|
|
2664
|
+
}
|
|
2665
|
+
const current = readStatusFieldValue(readFileSync(statusPath, 'utf-8'), '状态');
|
|
2666
|
+
if (current === null) {
|
|
2667
|
+
// 字段缺失 → 记为 skipped-not-found(不新建字段,避免扩大写入面)
|
|
2668
|
+
out.push({ stage: stageId, from: '', to: authoritative, result: 'skipped-not-found' });
|
|
2669
|
+
continue;
|
|
2670
|
+
}
|
|
2671
|
+
if (current === authoritative) {
|
|
2672
|
+
// 已一致 → 不入清单(无差异,零写入)
|
|
2673
|
+
continue;
|
|
2674
|
+
}
|
|
2675
|
+
if (options?.dryRun) {
|
|
2676
|
+
out.push({ stage: stageId, from: current, to: authoritative, result: 'planned' });
|
|
2677
|
+
continue;
|
|
2678
|
+
}
|
|
2679
|
+
// 定向替换「状态」字段行(其余字节不变),并留审计日志
|
|
2680
|
+
this.writeStatusField(statusPath, '状态', authoritative);
|
|
2681
|
+
this.appendLog({
|
|
2682
|
+
time: '',
|
|
2683
|
+
agent: 'openfeel-executor',
|
|
2684
|
+
action: 'status_reconcile',
|
|
2685
|
+
detail: { stage: stageId, from: current, to: authoritative },
|
|
2686
|
+
});
|
|
2687
|
+
out.push({ stage: stageId, from: current, to: authoritative, result: 'applied' });
|
|
2688
|
+
}
|
|
2689
|
+
return out;
|
|
2690
|
+
}
|
|
2691
|
+
/**
|
|
2692
|
+
* status.md「状态」字段定向写(原子写 + 按阶段目录加锁)。
|
|
2693
|
+
* 仅替换首个匹配的 `- **{key}**:{value}` 行的值,其余内容逐字节保留。
|
|
2694
|
+
* 与 `commands/stage.ts:setStatusField` 同语义。
|
|
2695
|
+
*/
|
|
2696
|
+
writeStatusField(statusPath, key, newValue) {
|
|
2697
|
+
const stageDir = basename(dirname(statusPath));
|
|
2698
|
+
const lockPath = projectLockPath(this.projectPath, `status-${stageDir}`);
|
|
2699
|
+
return withFileLock(lockPath, () => {
|
|
2700
|
+
const content = readFileSync(statusPath, 'utf-8');
|
|
2701
|
+
const fieldRegex = new RegExp(`^(-\\s*(?:\\*\\*)?${escapeRegex(key)}(?:\\*\\*)?[::]\\s*)(.*)$`, 'gm');
|
|
2702
|
+
const updated = content.replace(fieldRegex, `$1${newValue}`);
|
|
2703
|
+
if (updated === content) {
|
|
2704
|
+
return false; // 未命中字段
|
|
2705
|
+
}
|
|
2706
|
+
atomicWriteFileSync(statusPath, updated);
|
|
2707
|
+
return true;
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
/**
|
|
2711
|
+
* 7. 悬空依赖检测:stages[].deps 是否均指向已注册阶段(B2 存量防御)
|
|
2712
|
+
* 仅作数据卫生告警(warn),不阻断;强制校验点在命令层 `commands/plan.ts`
|
|
2713
|
+
* @param items 健康检查结果收集数组
|
|
2714
|
+
*/
|
|
2715
|
+
checkDanglingDeps(items) {
|
|
2716
|
+
if (!this.data) {
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
const known = new Set(Object.keys(this.data.stages).map((k) => normalizeStageId(k) ?? k));
|
|
2720
|
+
const dangling = [];
|
|
2721
|
+
// 逐阶段比对 deps 是否可归一化命中已注册阶段
|
|
2722
|
+
for (const [stageId, stage] of Object.entries(this.data.stages)) {
|
|
2723
|
+
const deps = Array.isArray(stage.deps) ? stage.deps : [];
|
|
2724
|
+
for (const d of deps) {
|
|
2725
|
+
if (!known.has(normalizeStageId(d) ?? d)) {
|
|
2726
|
+
dangling.push(`${stageId} → ${d}`);
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
if (dangling.length > 0) {
|
|
2731
|
+
items.push({
|
|
2732
|
+
section: '悬空依赖',
|
|
2733
|
+
status: 'warn',
|
|
2734
|
+
message: `检出 ${dangling.length} 条悬空依赖:${dangling.slice(0, 5).join('; ')}${dangling.length > 5 ? ' …' : ''}`,
|
|
2735
|
+
});
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
items.push({ section: '悬空依赖', status: 'pass', message: '未检测到悬空依赖' });
|
|
2739
|
+
}
|
|
2740
|
+
/**
|
|
2741
|
+
* 8. 孤儿 op 检测:flow.json 的 op 键与 ops/ 目录文件是否一致(N1-3)
|
|
2742
|
+
* 仅作数据卫生告警(warn),不阻断;退出码不受影响(仅 fail 决定)。
|
|
2743
|
+
* 无孤儿时不产生条目(保持输出精简)。
|
|
2744
|
+
* @param items 健康检查结果收集数组
|
|
2745
|
+
*/
|
|
2746
|
+
checkOrphanOps(items) {
|
|
2747
|
+
const { keyOrphans, fileOrphans } = findOrphanOps(this.projectPath);
|
|
2748
|
+
if (keyOrphans.length === 0 && fileOrphans.length === 0) {
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
const base = t('flow.health.orphanOpsDetail', 'zh-CN', {
|
|
2752
|
+
n: String(keyOrphans.length),
|
|
2753
|
+
m: String(fileOrphans.length),
|
|
2754
|
+
});
|
|
2755
|
+
// keyOrphans 详情列出前 5 条 + 总数
|
|
2756
|
+
const sample = keyOrphans.slice(0, 5).map((o) => `${o.stage}.${o.opId}`).join('; ');
|
|
2757
|
+
const message = sample
|
|
2758
|
+
? `${base} [${sample}${keyOrphans.length > 5 ? ' …' : ''}]`
|
|
2759
|
+
: base;
|
|
2760
|
+
items.push({ section: t('flow.health.orphanOps'), status: 'warn', message });
|
|
2761
|
+
}
|
|
2762
|
+
/**
|
|
2763
|
+
* 9. 空模板检测:op 模板仍含 `- [ ] 待补充` 时告警(B3/B4)。
|
|
2764
|
+
* 窄兼容(A1 第 1 条):**显式跳过 `draft`** op(draft 本就允许空模板)。
|
|
2765
|
+
* 仅作数据卫生告警(warn),不阻断。
|
|
2766
|
+
* @param items 健康检查结果收集数组
|
|
2767
|
+
*/
|
|
2768
|
+
checkEmptyTemplates(items) {
|
|
2769
|
+
if (!this.data) {
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
const empties = [];
|
|
2773
|
+
for (const [stageId, stage] of Object.entries(this.data.stages)) {
|
|
2774
|
+
const opsMap = stage.ops && typeof stage.ops === 'object' && !Array.isArray(stage.ops) ? stage.ops : {};
|
|
2775
|
+
for (const [opId, op] of Object.entries(opsMap)) {
|
|
2776
|
+
if (op.state === 'draft') {
|
|
2777
|
+
continue; // 窄兼容:draft 允许空模板
|
|
2778
|
+
}
|
|
2779
|
+
const content = this.readOpTemplate(stageId, opId);
|
|
2780
|
+
if (content !== null && isTemplateEmpty(content)) {
|
|
2781
|
+
empties.push(`${stageId}.${opId}`);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
if (empties.length > 0) {
|
|
2786
|
+
items.push({
|
|
2787
|
+
section: t('flow.health.emptyTemplate', 'zh-CN'),
|
|
2788
|
+
status: 'warn',
|
|
2789
|
+
message: t('flow.health.emptyTemplateDetail', 'zh-CN', {
|
|
2790
|
+
n: String(empties.length),
|
|
2791
|
+
items: empties.slice(0, 5).join(', ') + (empties.length > 5 ? ' …' : ''),
|
|
2792
|
+
}),
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2006
2796
|
/** 1. 检查 flow.json 合法性 */
|
|
2007
2797
|
checkFlowJson(items) {
|
|
2008
2798
|
const fp = resolve(this.projectPath, '.openfeel', 'flow.json');
|
|
@@ -2124,7 +2914,8 @@ export class FlowManager {
|
|
|
2124
2914
|
for (const [stageId, stage] of Object.entries(this.data.stages)) {
|
|
2125
2915
|
// 所有 REV 已 closed 但 stage 仍为 review_failed
|
|
2126
2916
|
if (stage.status === 'review_failed') {
|
|
2127
|
-
|
|
2917
|
+
// 锚定 stageId + '.'(对齐 :1064),避免前缀重叠(如 stage-1 与 stage-10)误报 zombie(T14)
|
|
2918
|
+
const stageReviews = this.data.reviews.filter((r) => r.op.startsWith(stageId + '.') || r.op === stageId);
|
|
2128
2919
|
const allClosed = stageReviews.length > 0 && stageReviews.every((r) => r.status === 'closed');
|
|
2129
2920
|
if (allClosed) {
|
|
2130
2921
|
items.push({ section: '僵尸状态', status: 'warn', message: `${stageId}: 所有 REV 已 closed 但 stage 仍为 review_failed` });
|
|
@@ -2374,7 +3165,8 @@ export class FlowManager {
|
|
|
2374
3165
|
exec: 'exec',
|
|
2375
3166
|
review: 'review',
|
|
2376
3167
|
test: 'test',
|
|
2377
|
-
|
|
3168
|
+
archiving: 'archiving', // 主用:使 archiving 阶段更新 checkpoint(T12)
|
|
3169
|
+
archive: 'archive', // 历史键,兼容自定义 pipeline 中 phase 名为 archive 的场景
|
|
2378
3170
|
},
|
|
2379
3171
|
phase_corrections: {
|
|
2380
3172
|
completed: 'done',
|
|
@@ -2413,6 +3205,54 @@ export function normalizeAgentName(name) {
|
|
|
2413
3205
|
}
|
|
2414
3206
|
return LEGACY_AGENT_NAME_MAP[key] ?? key;
|
|
2415
3207
|
}
|
|
3208
|
+
/**
|
|
3209
|
+
* 转义正则元字符(供 status.md 字段名匹配使用)。
|
|
3210
|
+
*/
|
|
3211
|
+
function escapeRegex(str) {
|
|
3212
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
3213
|
+
}
|
|
3214
|
+
/**
|
|
3215
|
+
* 读取 status.md 中 `- **{key}**:{value}` 字段值(首个匹配)。
|
|
3216
|
+
* @param content status.md 全文
|
|
3217
|
+
* @param key 字段名(如「状态」)
|
|
3218
|
+
* @returns 字段值;未命中返回 null
|
|
3219
|
+
*/
|
|
3220
|
+
function readStatusFieldValue(content, key) {
|
|
3221
|
+
const fieldRegex = new RegExp(`^-\\s*(?:\\*\\*)?${escapeRegex(key)}(?:\\*\\*)?[::]\\s*(.*)$`, 'm');
|
|
3222
|
+
const m = content.match(fieldRegex);
|
|
3223
|
+
return m ? m[1] : null;
|
|
3224
|
+
}
|
|
3225
|
+
/**
|
|
3226
|
+
* 空模板标记(**单一来源**):B3 填充度检测、B4 publish 校验、healthCheck 空模板检查共用。
|
|
3227
|
+
* 见 op-005 验收 12(无第二套判定)。
|
|
3228
|
+
*/
|
|
3229
|
+
export const EMPTY_TEMPLATE_MARKER = '- [ ] 待补充';
|
|
3230
|
+
/**
|
|
3231
|
+
* 空模板标记的**整行锚定**正则(单一来源):
|
|
3232
|
+
* 行首可含缩进([ \t]*)、`-` 与 `[ ]` 之间允许空白、行尾允许空白并容忍 CRLF;
|
|
3233
|
+
* 仅当标记**独占一行**(含缩进)时判为未填充——避免正文**行内引用**该字面导致误报(cli/BUG-005,stage-54 E1)。
|
|
3234
|
+
* **已知边界**:代码围栏(```)内的独占行仍判为 empty(不引入围栏解析,见 stage-54 A1)。
|
|
3235
|
+
*/
|
|
3236
|
+
const EMPTY_TEMPLATE_LINE_RE = /(?:^|\n)[ \t]*-\s*\[\s*\]\s*待补充[ \t]*(?=\r?\n|$)/;
|
|
3237
|
+
/** 模板是否仍含空模板标记(未填充;**整行锚定**,非子串) */
|
|
3238
|
+
export function isTemplateEmpty(content) {
|
|
3239
|
+
return EMPTY_TEMPLATE_LINE_RE.test(content);
|
|
3240
|
+
}
|
|
3241
|
+
/**
|
|
3242
|
+
* 判定 op 模板填充度(B3-1)。
|
|
3243
|
+
* - empty:仍含空模板标记 `- [ ] 待补充`(**整行锚定**,行内引用不误报)
|
|
3244
|
+
* - partial:已删除该标记但存在其它未勾选 `- [ ]`(行首 `[ \t]*` 锚定,口径与 empty 统一)
|
|
3245
|
+
* - filled:无未完成标记
|
|
3246
|
+
*/
|
|
3247
|
+
export function detectFillState(content) {
|
|
3248
|
+
if (isTemplateEmpty(content)) {
|
|
3249
|
+
return 'empty';
|
|
3250
|
+
}
|
|
3251
|
+
if (/(?:^|\n)[ \t]*-\s*\[\s*\]/.test(content)) {
|
|
3252
|
+
return 'partial';
|
|
3253
|
+
}
|
|
3254
|
+
return 'filled';
|
|
3255
|
+
}
|
|
2416
3256
|
/**
|
|
2417
3257
|
* 将 PipelinePhase 映射为 stage 状态
|
|
2418
3258
|
* @param phase 流水线阶段
|