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.
Files changed (196) hide show
  1. package/README.en.md +41 -11
  2. package/README.md +8 -3
  3. package/README.zh-CN.md +42 -12
  4. package/bin/openfeel.js +5 -0
  5. package/dist/cli/index.d.ts +15 -3
  6. package/dist/cli/index.d.ts.map +1 -1
  7. package/dist/cli/index.js +59 -15
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/cli/output-encoding.d.ts +32 -0
  10. package/dist/cli/output-encoding.d.ts.map +1 -0
  11. package/dist/cli/output-encoding.js +120 -0
  12. package/dist/cli/output-encoding.js.map +1 -0
  13. package/dist/cli/repl.d.ts +2 -2
  14. package/dist/cli/repl.d.ts.map +1 -1
  15. package/dist/cli/repl.js +46 -25
  16. package/dist/cli/repl.js.map +1 -1
  17. package/dist/commands/config.d.ts.map +1 -1
  18. package/dist/commands/config.js +56 -9
  19. package/dist/commands/config.js.map +1 -1
  20. package/dist/commands/flow.d.ts.map +1 -1
  21. package/dist/commands/flow.js +827 -138
  22. package/dist/commands/flow.js.map +1 -1
  23. package/dist/commands/init.d.ts.map +1 -1
  24. package/dist/commands/init.js +56 -42
  25. package/dist/commands/init.js.map +1 -1
  26. package/dist/commands/knowledge.d.ts.map +1 -1
  27. package/dist/commands/knowledge.js +69 -0
  28. package/dist/commands/knowledge.js.map +1 -1
  29. package/dist/commands/lint.d.ts.map +1 -1
  30. package/dist/commands/lint.js +17 -5
  31. package/dist/commands/lint.js.map +1 -1
  32. package/dist/commands/migrate.d.ts.map +1 -1
  33. package/dist/commands/migrate.js +6 -1
  34. package/dist/commands/migrate.js.map +1 -1
  35. package/dist/commands/model.d.ts.map +1 -1
  36. package/dist/commands/model.js +18 -1
  37. package/dist/commands/model.js.map +1 -1
  38. package/dist/commands/plan.d.ts.map +1 -1
  39. package/dist/commands/plan.js +185 -7
  40. package/dist/commands/plan.js.map +1 -1
  41. package/dist/commands/project.js +2 -2
  42. package/dist/commands/project.js.map +1 -1
  43. package/dist/commands/roadmap.d.ts.map +1 -1
  44. package/dist/commands/roadmap.js +20 -5
  45. package/dist/commands/roadmap.js.map +1 -1
  46. package/dist/commands/setup.d.ts +1 -1
  47. package/dist/commands/setup.d.ts.map +1 -1
  48. package/dist/commands/setup.js +8 -2
  49. package/dist/commands/setup.js.map +1 -1
  50. package/dist/commands/shared/errors.d.ts +22 -0
  51. package/dist/commands/shared/errors.d.ts.map +1 -0
  52. package/dist/commands/shared/errors.js +45 -0
  53. package/dist/commands/shared/errors.js.map +1 -0
  54. package/dist/commands/stage.d.ts.map +1 -1
  55. package/dist/commands/stage.js +133 -23
  56. package/dist/commands/stage.js.map +1 -1
  57. package/dist/commands/view.d.ts +1 -1
  58. package/dist/commands/view.d.ts.map +1 -1
  59. package/dist/commands/view.js +4 -26
  60. package/dist/commands/view.js.map +1 -1
  61. package/dist/core/archive/merge.d.ts.map +1 -1
  62. package/dist/core/archive/merge.js +9 -5
  63. package/dist/core/archive/merge.js.map +1 -1
  64. package/dist/core/artifact-graph/graph.d.ts +7 -2
  65. package/dist/core/artifact-graph/graph.d.ts.map +1 -1
  66. package/dist/core/artifact-graph/graph.js +25 -18
  67. package/dist/core/artifact-graph/graph.js.map +1 -1
  68. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  69. package/dist/core/artifact-graph/instruction-loader.js +4 -33
  70. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  71. package/dist/core/backup.d.ts +28 -0
  72. package/dist/core/backup.d.ts.map +1 -0
  73. package/dist/core/backup.js +118 -0
  74. package/dist/core/backup.js.map +1 -0
  75. package/dist/core/config.d.ts +30 -19
  76. package/dist/core/config.d.ts.map +1 -1
  77. package/dist/core/config.js +86 -26
  78. package/dist/core/config.js.map +1 -1
  79. package/dist/core/flow-manager.d.ts +256 -5
  80. package/dist/core/flow-manager.d.ts.map +1 -1
  81. package/dist/core/flow-manager.js +908 -68
  82. package/dist/core/flow-manager.js.map +1 -1
  83. package/dist/core/fs/atomic-write.d.ts +3 -0
  84. package/dist/core/fs/atomic-write.d.ts.map +1 -1
  85. package/dist/core/fs/atomic-write.js +3 -0
  86. package/dist/core/fs/atomic-write.js.map +1 -1
  87. package/dist/core/fs/safe-read.d.ts +11 -0
  88. package/dist/core/fs/safe-read.d.ts.map +1 -0
  89. package/dist/core/fs/safe-read.js +31 -0
  90. package/dist/core/fs/safe-read.js.map +1 -0
  91. package/dist/core/global-paths.d.ts +16 -7
  92. package/dist/core/global-paths.d.ts.map +1 -1
  93. package/dist/core/global-paths.js +23 -8
  94. package/dist/core/global-paths.js.map +1 -1
  95. package/dist/core/i18n-data/en.d.ts +2 -0
  96. package/dist/core/i18n-data/en.d.ts.map +1 -1
  97. package/dist/core/i18n-data/en.js +259 -23
  98. package/dist/core/i18n-data/en.js.map +1 -1
  99. package/dist/core/i18n-data/zh-CN.d.ts +2 -0
  100. package/dist/core/i18n-data/zh-CN.d.ts.map +1 -1
  101. package/dist/core/i18n-data/zh-CN.js +265 -23
  102. package/dist/core/i18n-data/zh-CN.js.map +1 -1
  103. package/dist/core/i18n.d.ts +8 -0
  104. package/dist/core/i18n.d.ts.map +1 -1
  105. package/dist/core/i18n.js +14 -4
  106. package/dist/core/i18n.js.map +1 -1
  107. package/dist/core/init.d.ts +4 -2
  108. package/dist/core/init.d.ts.map +1 -1
  109. package/dist/core/init.js +38 -13
  110. package/dist/core/init.js.map +1 -1
  111. package/dist/core/managed-region.d.ts +1 -1
  112. package/dist/core/managed-region.js +1 -1
  113. package/dist/core/metrics.d.ts +16 -4
  114. package/dist/core/metrics.d.ts.map +1 -1
  115. package/dist/core/metrics.js +31 -11
  116. package/dist/core/metrics.js.map +1 -1
  117. package/dist/core/migrate.d.ts.map +1 -1
  118. package/dist/core/migrate.js +53 -19
  119. package/dist/core/migrate.js.map +1 -1
  120. package/dist/core/model-config.d.ts.map +1 -1
  121. package/dist/core/model-config.js +4 -2
  122. package/dist/core/model-config.js.map +1 -1
  123. package/dist/core/opencode-config.d.ts +4 -4
  124. package/dist/core/opencode-config.d.ts.map +1 -1
  125. package/dist/core/opencode-config.js +6 -5
  126. package/dist/core/opencode-config.js.map +1 -1
  127. package/dist/core/plan/path.d.ts +40 -0
  128. package/dist/core/plan/path.d.ts.map +1 -1
  129. package/dist/core/plan/path.js +108 -1
  130. package/dist/core/plan/path.js.map +1 -1
  131. package/dist/core/plan/roadmap.js +2 -2
  132. package/dist/core/plan/roadmap.js.map +1 -1
  133. package/dist/core/plan/scheme.d.ts +74 -2
  134. package/dist/core/plan/scheme.d.ts.map +1 -1
  135. package/dist/core/plan/scheme.js +311 -26
  136. package/dist/core/plan/scheme.js.map +1 -1
  137. package/dist/core/plan/stage.d.ts +34 -3
  138. package/dist/core/plan/stage.d.ts.map +1 -1
  139. package/dist/core/plan/stage.js +90 -6
  140. package/dist/core/plan/stage.js.map +1 -1
  141. package/dist/core/public-logger.d.ts +18 -5
  142. package/dist/core/public-logger.d.ts.map +1 -1
  143. package/dist/core/public-logger.js +62 -23
  144. package/dist/core/public-logger.js.map +1 -1
  145. package/dist/core/runtime-log.d.ts +25 -0
  146. package/dist/core/runtime-log.d.ts.map +1 -0
  147. package/dist/core/runtime-log.js +94 -0
  148. package/dist/core/runtime-log.js.map +1 -0
  149. package/dist/core/setup.d.ts.map +1 -1
  150. package/dist/core/setup.js +47 -19
  151. package/dist/core/setup.js.map +1 -1
  152. package/dist/core/template-loader.d.ts.map +1 -1
  153. package/dist/core/template-loader.js +672 -288
  154. package/dist/core/template-loader.js.map +1 -1
  155. package/dist/core/templates.d.ts +2 -2
  156. package/dist/core/templates.d.ts.map +1 -1
  157. package/dist/core/templates.js +13 -9
  158. package/dist/core/templates.js.map +1 -1
  159. package/dist/core/update-infos.d.ts +14 -3
  160. package/dist/core/update-infos.d.ts.map +1 -1
  161. package/dist/core/update-infos.js +67 -15
  162. package/dist/core/update-infos.js.map +1 -1
  163. package/dist/core/update-state.d.ts.map +1 -1
  164. package/dist/core/update-state.js +7 -4
  165. package/dist/core/update-state.js.map +1 -1
  166. package/dist/core/update.d.ts +2 -1
  167. package/dist/core/update.d.ts.map +1 -1
  168. package/dist/core/update.js +253 -61
  169. package/dist/core/update.js.map +1 -1
  170. package/dist/core/view/entry.d.ts +53 -1
  171. package/dist/core/view/entry.d.ts.map +1 -1
  172. package/dist/core/view/entry.js +71 -2
  173. package/dist/core/view/entry.js.map +1 -1
  174. package/dist/core/workspace/identity.d.ts.map +1 -1
  175. package/dist/core/workspace/identity.js +15 -5
  176. package/dist/core/workspace/identity.js.map +1 -1
  177. package/dist/core/workspace/knowledge.d.ts.map +1 -1
  178. package/dist/core/workspace/knowledge.js +125 -19
  179. package/dist/core/workspace/knowledge.js.map +1 -1
  180. package/dist/core/workspace/structure.d.ts.map +1 -1
  181. package/dist/core/workspace/structure.js +6 -2
  182. package/dist/core/workspace/structure.js.map +1 -1
  183. package/dist/index.d.ts +0 -1
  184. package/dist/index.d.ts.map +1 -1
  185. package/dist/index.js +1 -2
  186. package/dist/index.js.map +1 -1
  187. package/dist/utils/kb-dedup.d.ts +5 -1
  188. package/dist/utils/kb-dedup.d.ts.map +1 -1
  189. package/dist/utils/kb-dedup.js +11 -5
  190. package/dist/utils/kb-dedup.js.map +1 -1
  191. package/package.json +6 -6
  192. package/dist/utils/path.d.ts +0 -22
  193. package/dist/utils/path.d.ts.map +0 -1
  194. package/dist/utils/path.js +0 -37
  195. package/dist/utils/path.js.map +0 -1
  196. 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 { execSync } from 'node:child_process';
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') && (!stageId || f.startsWith(stageId + '-')))
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('[WARN] flow.json 已被其它进程修改,拒绝从快照恢复(请重新加载后重试)');
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 stageId = opId.substring(0, dotIdx);
450
+ const rawStageId = opId.substring(0, dotIdx);
348
451
  const opLocalId = opId.substring(dotIdx + 1);
349
452
  // 排除空字符串情况
350
- if (!stageId || !opLocalId) {
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.keys(stage.ops).length;
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.keys(stage.ops).length;
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
- const stage = this.data.stages[stageName];
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(`[WARN] advanceStagePhase: Phase '${phase}' 自动修正为 '${corrected}'`);
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(stageName + '.') || r.op === stageName);
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[stageName].phase = targetPhase;
957
+ this.data.stages[key].phase = targetPhase;
783
958
  // Checkpoint 自动快照:phase 推进成功后保存 flow.json 快照到 .openfeel/checkpoints/(失败不阻塞推进)
784
- this.saveCheckpoint(stageName, targetPhase);
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(stageName, targetPhase);
794
- }
795
- // 同步更新 pipeline.current:从该 stage 的 ops 中找到第一个 pending/executing 的 op
796
- const pendingOpEntry = Object.entries(stage.ops).find(([, op]) => op.state === 'pending' || op.state === 'executing');
797
- this.data.pipeline.current = {
798
- stage: stageName,
799
- op: pendingOpEntry ? pendingOpEntry[0] : this.data.pipeline.current.op,
800
- };
801
- // 同步更新 pipeline.phase 为 'active'
802
- this.data.pipeline.phase = 'active';
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
- const newStatus = mapPhaseToStageStatus(targetPhase, prevStatus);
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(stageName);
997
+ this.startStage(key);
819
998
  }
820
999
  // 阶段完成时自动结束计时
821
1000
  if (targetPhase === 'done' && prevStatus !== 'done') {
822
- this.endStage(stageName);
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
- execSync(`git add -A && git commit -m "${msg}"`, { stdio: 'pipe', cwd: this.projectPath });
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('[DEPRECATED] advancePhase() 已弃用,请使用 advanceStagePhase(stageName, phase) 替代');
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(`[WARN] Phase '${to}' 自动修正为 '${corrected}'`);
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('[DEPRECATED] advancePhase: 无法解析 stageId,跳过');
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
- const stage = this.data.stages[targetStageName];
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
- const effective = {};
1103
- // 读取 config.yaml defaults
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
- configDefaults[key] = String(value);
1113
- effective[key] = String(value);
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
- // 读取当前 stage 的 status.md(如果存在)
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
- return { configDefaults, statusOverrides, effective };
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 stageId = opId.substring(0, opId.lastIndexOf('.'));
1358
- const stage = this.data.stages[stageId];
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
- console.error(`错误:opId "${opId}" 中的 stage "${stageId}" 不存在`);
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(`[WARN] addAutoFixReview 仅允许从 review_failed 状态调用,当前 stage ${stageId} phase 为 ${stage.phase}`);
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
- // 后缀匹配: 查找 normalized 是否为某合法值的后缀
1552
- for (const phase of PIPELINE_PHASES) {
1553
- if (phase.endsWith('_' + normalized) || phase.endsWith(normalized)) {
1554
- return phase;
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
- const stage = this.data.stages[stageName];
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 时,同步 phase 为 done
2458
+ // 修复: status=done 但 phase≠done → 同步 phase 为 done(dryRun 时仅报告,不写内存)
1854
2459
  if (stage.status === 'done' && stage.phase !== 'done') {
1855
- const oldPhase = stage.phase;
1856
- stage.phase = 'done';
1857
- return { fixed: true, detail: `phase ${oldPhase} → done (与 status 同步)` };
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 时,同步 status
2466
+ // 修复: phase=done 但 status≠done → 同步 status(dryRun 时仅报告)
1860
2467
  if (stage.phase === 'done' && stage.status !== 'done') {
1861
- const oldStatus = stage.status;
1862
- stage.status = 'done';
1863
- return { fixed: true, detail: `status ${oldStatus} → done (与 phase 同步)` };
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
- const stageReviews = this.data.reviews.filter((r) => r.op.startsWith(stageId));
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
- archive: 'archive',
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 流水线阶段