openfeel 1.2.4 → 1.3.1

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 (51) hide show
  1. package/README.en.md +40 -74
  2. package/README.md +39 -62
  3. package/README.zh-CN.md +40 -74
  4. package/dist/commands/setup.d.ts.map +1 -1
  5. package/dist/commands/setup.js +90 -5
  6. package/dist/commands/setup.js.map +1 -1
  7. package/dist/commands/update.d.ts.map +1 -1
  8. package/dist/commands/update.js +7 -2
  9. package/dist/commands/update.js.map +1 -1
  10. package/dist/core/config.js +2 -2
  11. package/dist/core/dsh-profile.d.ts +93 -0
  12. package/dist/core/dsh-profile.d.ts.map +1 -0
  13. package/dist/core/dsh-profile.js +400 -0
  14. package/dist/core/dsh-profile.js.map +1 -0
  15. package/dist/core/i18n-data/en.d.ts.map +1 -1
  16. package/dist/core/i18n-data/en.js +21 -2
  17. package/dist/core/i18n-data/en.js.map +1 -1
  18. package/dist/core/i18n-data/zh-CN.d.ts.map +1 -1
  19. package/dist/core/i18n-data/zh-CN.js +21 -2
  20. package/dist/core/i18n-data/zh-CN.js.map +1 -1
  21. package/dist/core/setup.d.ts +12 -1
  22. package/dist/core/setup.d.ts.map +1 -1
  23. package/dist/core/setup.js +25 -10
  24. package/dist/core/setup.js.map +1 -1
  25. package/dist/core/template-loader.d.ts.map +1 -1
  26. package/dist/core/template-loader.js +18 -9
  27. package/dist/core/template-loader.js.map +1 -1
  28. package/dist/core/update.d.ts +2 -0
  29. package/dist/core/update.d.ts.map +1 -1
  30. package/dist/core/update.js +25 -7
  31. package/dist/core/update.js.map +1 -1
  32. package/dsh/ACTION-GUIDE.md +366 -0
  33. package/dsh/ADAPTER.md +65 -0
  34. package/dsh/README.md +264 -0
  35. package/dsh/cordis.patch.yml +26 -0
  36. package/dsh/docs/compat/commands.md +873 -0
  37. package/dsh/docs/compat/kb-skills.md +883 -0
  38. package/dsh/docs/compat/persistence.md +1013 -0
  39. package/dsh/docs/compat/pipeline.md +1565 -0
  40. package/dsh/docs/compat/req.md +1243 -0
  41. package/dsh/docs/compat/stage.md +1437 -0
  42. package/dsh/lib/adapters/cli.js +624 -0
  43. package/dsh/lib/adapters/dashboard.js +393 -0
  44. package/dsh/lib/adapters/workspace.js +150 -0
  45. package/dsh/lib/index.js +189 -0
  46. package/dsh/lib/surface/command.js +186 -0
  47. package/dsh/lib/surface/prompt.js +131 -0
  48. package/dsh/lib/tools/index.js +1245 -0
  49. package/dsh/lib/tools/shared.js +320 -0
  50. package/dsh/package.json +45 -0
  51. package/package.json +9 -2
@@ -0,0 +1,1565 @@
1
+ # OpenFeel — Pipeline State Machine (Behavioral Specification)
2
+
3
+ Exact behavioral specification of the OpenFeel pipeline state machine: phase vocabulary, transition
4
+ tables (full / fast / general), review tiers, gates, config cascade, stage-id parsing, and the
5
+ invariants a reimplementation must preserve.
6
+
7
+ All identifiers, constants and user-visible strings below are copied verbatim from source.
8
+
9
+ **Source of truth (read-only analysis):**
10
+
11
+ | File | Role |
12
+ | --- | --- |
13
+ | `src/core/pipeline-schema.ts` | `PIPELINE_PHASES`, `META_PHASES`, `REVIEW_TIERS`, `TIER_PRUNED_PHASES`, `TRANSITION_OR_SEPARATOR`, `parseTransitionKey`, `transitionKeyMatches`, `PipelineConfigSchema`, `FastTransitionsSchema` |
14
+ | `src/core/flow-manager.ts` | `FAST_TRANSITIONS`, `MODE_DERIVED_TIER`, `pruneTransitionsByTier`, `resolveTransitionsForMode`, `advanceStagePhase`, `resetStagePhase`, `findPhasePath`, `mapPhaseToStageStatus`, `save`, config cascade |
15
+ | `src/core/config.ts` | `WORKFLOW_MODES`, `DEFAULT_CONFIG`, `ConfigDefaultsSchema`, `STAGE_FIELD_BY_CONFIG_KEY`, `resolveConfigDefaults` |
16
+ | `src/commands/flow.ts` | `flow phases`, `flow mode`, `flow advance`, `flow stage reset`, `flow archive` command surface |
17
+ | `src/core/plan/path.ts` | `parseStageId`, `normalizeStageId`, `isArchivedVersionOf`, `findStageDirConflict` |
18
+ | `.openfeel/pipeline.yaml` | The project's concrete runtime transition table |
19
+
20
+ ---
21
+
22
+ ## 1. Phase vocabulary
23
+
24
+ ### 1.1 `PIPELINE_PHASES` — 15 phase values, in canonical order
25
+
26
+ ```ts
27
+ export const PIPELINE_PHASES = [
28
+ 'plan_pending', 'plan_review', 'plan_passed',
29
+ 'scheme_pending', 'scheme_review', 'scheme_passed',
30
+ 'exec_running', 'review_pending', 'review_failed',
31
+ 'review_passed', 'test_pending', 'test_failed',
32
+ 'test_passed', 'archiving', 'done',
33
+ ] as const;
34
+ ```
35
+
36
+ `PipelinePhaseSchema = z.enum(PIPELINE_PHASES)`. This is the **advance whitelist** (`advanceAccepted`
37
+ in `flow phases --json`) and is **never shrunk by tier pruning** — pruning only edits transition
38
+ tables, never the `PipelinePhase` value domain.
39
+
40
+ ### 1.2 `META_PHASES` — 3 global macro states
41
+
42
+ ```ts
43
+ export const META_PHASES = ['active', 'paused', 'done'] as const;
44
+ ```
45
+
46
+ `flow.json.pipeline.phase` is a **MetaPhase**, not a PipelinePhase. It is recomputed
47
+ unconditionally on every phase transition:
48
+
49
+ ```ts
50
+ const allDone = Object.keys(this.data.stages).length > 0
51
+ && Object.values(this.data.stages).every((s) => s.phase === 'done');
52
+ this.data.pipeline.phase = (allDone ? 'done' : 'active') as MetaPhase;
53
+ ```
54
+
55
+ Notes a reimplementation must preserve:
56
+
57
+ - **Non-empty guard**: `[].every(...)` is vacuously `true`, so a project with zero stages must
58
+ **not** be set to `done`.
59
+ - `paused` is a **soft state**: any advance overwrites it to `active`/`done`. There is no hard-pause
60
+ write path in the transition code.
61
+ - `fuzzyCorrectMetaPhase` maps `input.trim().toLowerCase()` — if it is a MetaPhase value it is
62
+ returned, **every other value (including any PipelinePhase value) maps to `'active'`**.
63
+ - `needsMigration()` = `pipeline.phase ∈ PIPELINE_PHASES ∧ pipeline.phase ∉ META_PHASES`
64
+ (legacy v4.0 global-phase format).
65
+
66
+ ### 1.3 Phase semantics, owning agent, checkpoint prefix
67
+
68
+ `mapPhaseToAgent` derives the agent from `phase.split('_')[0]`; `getCheckpointFromPhase` derives the
69
+ checkpoint field from `pipelineConfig.checkpoint_mapping[prefix]`.
70
+
71
+ | # | Phase | Meaning | Owner agent (`mapPhaseToAgent`) | `checkpoint_mapping` prefix |
72
+ | --- | --- | --- | --- | --- |
73
+ | 1 | `plan_pending` | Plan awaited / being revised | `openfeel-planner` | `plan` |
74
+ | 2 | `plan_review` | Plan under review | `openfeel-planner` | `plan` |
75
+ | 3 | `plan_passed` | Plan approved | `openfeel-planner` | `plan` |
76
+ | 4 | `scheme_pending` | Op scheme awaited / being re-planned | `openfeel-schemer` | `scheme` |
77
+ | 5 | `scheme_review` | Scheme under review | `openfeel-schemer` | `scheme` |
78
+ | 6 | `scheme_passed` | Scheme approved | `openfeel-schemer` | `scheme` |
79
+ | 7 | `exec_running` | Execution in progress | `openfeel-executor` | `exec` |
80
+ | 8 | `review_pending` | Code review awaited | `openfeel-reviewer` | `review` |
81
+ | 9 | `review_failed` | Code review rejected | `openfeel-reviewer` | `review` |
82
+ | 10 | `review_passed` | Code review approved | `openfeel-reviewer` | `review` |
83
+ | 11 | `test_pending` | Test awaited | `openfeel-feel-tester` | `test` |
84
+ | 12 | `test_failed` | Test rejected | `openfeel-feel-tester` | `test` |
85
+ | 13 | `test_passed` | Test approved | `openfeel-feel-tester` | `test` |
86
+ | 14 | `archiving` | Archiving in progress | `openfeel-archiver` | `archiving` → `archive` |
87
+ | 15 | `done` | Stage complete (terminal) | `none` | — (prefix `done` unmapped) |
88
+
89
+ Built-in `checkpoint_mapping` (see §2.3) contains **both** `archiving: 'archiving'` and
90
+ `archive: 'archive'`; the project `.openfeel/pipeline.yaml` contains **only** `archive: 'archive'`.
91
+ Consequence: with the shipped `pipeline.yaml`, `getCheckpointFromPhase('archiving')` looks up
92
+ `checkpoint_mapping['archiving']`, which is `undefined` → `null` → no checkpoint update for
93
+ `archiving`.
94
+
95
+ ### 1.4 Phase display labels (`getPhaseLabels`)
96
+
97
+ Built-in labels are keyed by phase; any runtime `pipeline.yaml` phase not in the built-in map gets
98
+ the fallback `phase.replace(/_/g, ' ')`.
99
+
100
+ | Phase | zh-CN | en |
101
+ | --- | --- | --- |
102
+ | `plan_pending` | 计划待定 | Plan Pending |
103
+ | `plan_review` | 计划审查中 | Plan Reviewing |
104
+ | `plan_passed` | 计划已通过 | Plan Passed |
105
+ | `scheme_pending` | 方案待定 | Scheme Pending |
106
+ | `scheme_review` | 方案审查中 | Scheme Reviewing |
107
+ | `scheme_passed` | 方案已通过 | Scheme Passed |
108
+ | `exec_running` | 执行中 | Executing |
109
+ | `review_pending` | 审查待定 | Review Pending |
110
+ | `review_failed` | 审查未通过 | Review Failed |
111
+ | `review_passed` | 审查已通过 | Review Passed |
112
+ | `test_pending` | 测试待定 | Test Pending |
113
+ | `test_failed` | 测试未通过 | Test Failed |
114
+ | `test_passed` | 测试已通过 | Test Passed |
115
+ | `archiving` | 归档中 | Archiving |
116
+ | `done` | 已完成 | Completed |
117
+
118
+ The iteration order of the returned map is **`pipelineConfig.phases` order**, not the built-in order.
119
+
120
+ ---
121
+
122
+ ## 2. FULL mode transition table
123
+
124
+ ### 2.1 Runtime table — `.openfeel/pipeline.yaml` (in effect for this repository)
125
+
126
+ ```yaml
127
+ transitions:
128
+ plan_pending: [plan_review, plan_passed]
129
+ plan_review: [plan_passed, plan_pending]
130
+ plan_passed: [scheme_pending]
131
+ scheme_pending: [scheme_review, scheme_passed]
132
+ scheme_review: [scheme_passed, scheme_pending]
133
+ scheme_passed: [exec_running]
134
+ exec_running: [review_pending, scheme_pending] # 允许回退到重定方案
135
+ review_pending: [review_failed, review_passed]
136
+ review_failed: [review_pending, scheme_pending] # 审查失败后可重定方案
137
+ review_passed: [test_pending]
138
+ test_pending: [test_failed, test_passed]
139
+ test_failed: [test_pending, scheme_pending] # 测试失败后可重定方案
140
+ test_passed: [archiving]
141
+ archiving: [done]
142
+ done: []
143
+ ```
144
+
145
+ Full mode is the **only** mode whose table is read from `pipeline.yaml`. `fast` and `general` are
146
+ not affected by `transitions:`.
147
+
148
+ ### 2.2 Built-in default FULL table — `getDefaultPipelineConfig('full').transitions`
149
+
150
+ ```ts
151
+ {
152
+ plan_pending: ['plan_review', 'plan_passed'],
153
+ plan_review: ['plan_passed', 'plan_pending'],
154
+ plan_passed: ['scheme_pending'],
155
+ scheme_pending: ['scheme_review', 'scheme_passed'],
156
+ scheme_review: ['scheme_passed', 'scheme_pending'],
157
+ scheme_passed: ['exec_running'],
158
+ exec_running: ['review_pending', 'scheme_pending'], // BUG 修复:增加 scheme_pending
159
+ review_pending: ['review_failed', 'review_passed'],
160
+ review_failed: ['review_pending', 'scheme_pending'], // BUG 修复:增加 scheme_pending
161
+ review_passed: ['test_pending'],
162
+ test_pending: ['test_failed', 'test_passed'],
163
+ test_failed: ['test_pending', 'scheme_pending'], // BUG 修复:增加 scheme_pending
164
+ test_passed: ['archiving'],
165
+ // 组合条件示例:审查通过 或 测试通过 任一即可推进到 archiving(多 Agent 并行场景,任一完成即推进)
166
+ 'review_passed|test_passed': ['archiving'],
167
+ archiving: ['done'],
168
+ done: [],
169
+ }
170
+ ```
171
+
172
+ **Observable drift (must be reproduced, not "fixed"):** the built-in table has **16 keys**, the
173
+ shipped `pipeline.yaml` has **15**. The combo key `'review_passed|test_passed'` is **absent from
174
+ `pipeline.yaml`**, so with this repository's runtime config:
175
+
176
+ - `review_passed` has exactly one legal target: `['test_pending']` (no direct `archiving` shortcut).
177
+ - `test_passed` has exactly one legal target: `['archiving']` (the plain key already covers it).
178
+ - `openfeel flow phases --json` reports
179
+ `transitionsDiff.missing === ['review_passed|test_passed']` (see §7.3).
180
+
181
+ With the **built-in** table instead, `review_passed` resolves to `['test_pending', 'archiving']`
182
+ because the OR-key also matches it (see §2.4).
183
+
184
+ ### 2.3 Built-in `pipeline.yaml` side tables
185
+
186
+ ```ts
187
+ checkpoint_mapping: {
188
+ plan: 'plan',
189
+ scheme: 'scheme',
190
+ exec: 'exec',
191
+ review: 'review',
192
+ test: 'test',
193
+ archiving: 'archiving', // 主用:使 archiving 阶段更新 checkpoint(T12)
194
+ archive: 'archive', // 历史键,兼容自定义 pipeline 中 phase 名为 archive 的场景
195
+ },
196
+ phase_corrections: {
197
+ completed: 'done',
198
+ finished: 'done',
199
+ archived: 'done',
200
+ pending: 'plan_pending',
201
+ },
202
+ ```
203
+
204
+ The shipped `pipeline.yaml` `phase_corrections` is identical; its `checkpoint_mapping` omits
205
+ `archiving:` (see §1.3).
206
+
207
+ `PipelineConfigSchema`:
208
+
209
+ ```ts
210
+ export const PipelineConfigSchema = z.object({
211
+ phases: z.array(z.string()),
212
+ transitions: z.record(z.string(), z.array(z.string())),
213
+ checkpoint_mapping: z.record(z.string(), z.string()),
214
+ phase_corrections: z.record(z.string(), z.string()).default({}),
215
+ });
216
+ ```
217
+
218
+ `phase_corrections` is the only key with a default. Any other missing/invalid key fails the whole
219
+ `PipelineConfigSchema.parse` → `loadPipelineConfig` catch → **entire runtime config falls back to the
220
+ built-in FULL defaults** (and `fastTransitions` stays `null`).
221
+
222
+ ### 2.4 The `|` OR-key form
223
+
224
+ ```ts
225
+ export const TRANSITION_OR_SEPARATOR = '|';
226
+
227
+ export function parseTransitionKey(key: string): string[] {
228
+ return key
229
+ .split(TRANSITION_OR_SEPARATOR)
230
+ .map((c) => c.trim())
231
+ .filter(Boolean);
232
+ }
233
+
234
+ export function transitionKeyMatches(key: string, phase: string): boolean {
235
+ return parseTransitionKey(key).includes(phase);
236
+ }
237
+ ```
238
+
239
+ Resolution — `getValidTargets(fromPhase, mode, tier)`:
240
+
241
+ ```ts
242
+ const transitions = this.resolveTransitionsForMode(mode, tier);
243
+ const result = new Set<string>();
244
+ for (const [key, targets] of Object.entries(transitions)) {
245
+ if (transitionKeyMatches(key, fromPhase)) {
246
+ for (const target of targets) {
247
+ result.add(target);
248
+ }
249
+ }
250
+ }
251
+ return [...result];
252
+ ```
253
+
254
+ Semantics a reimplementation must match:
255
+
256
+ 1. **All matching keys contribute** (union), not just the first. With the built-in full table,
257
+ `fromPhase = 'review_passed'` matches both `review_passed` and `review_passed|test_passed` →
258
+ `['test_pending', 'archiving']`; `fromPhase = 'test_passed'` matches both `test_passed` and the
259
+ OR-key → `['archiving']` (deduped).
260
+ 2. Order = first appearance while iterating `Object.entries(transitions)` (insertion order), then
261
+ target array order. `Set` preserves insertion order, duplicates collapse.
262
+ 3. Keys are trimmed and empty segments dropped: `'a| |b'` → `['a', 'b']`; `'|'` → `[]` → matches
263
+ nothing.
264
+ 4. Matching is **exact string equality** on the parsed segment — no prefix/fuzzy matching.
265
+ 5. A key that matches nothing is legal and simply inert (e.g. a `scheme_review` key in `general`
266
+ mode).
267
+
268
+ ---
269
+
270
+ ## 3. FAST mode transition table
271
+
272
+ ### 3.1 Built-in fast table — `FAST_TRANSITIONS`
273
+
274
+ ```ts
275
+ const FAST_TRANSITIONS: Record<string, string[]> = {
276
+ plan_pending: ['plan_review'],
277
+ plan_review: ['plan_passed', 'plan_pending'],
278
+ plan_passed: ['exec_running'],
279
+ exec_running: ['review_pending'],
280
+ review_pending: ['review_failed', 'review_passed'],
281
+ review_failed: ['review_pending', 'exec_running'],
282
+ review_passed: ['archiving'],
283
+ archiving: ['done'],
284
+ done: [],
285
+ };
286
+ ```
287
+
288
+ 9 entries = **8 flow keys + `done`** (source comment: "8 条流转键 + `done` 终止项(共 9 条目)").
289
+ Single source: only `getDefaultPipelineConfig('fast')` / `resolveTransitionsForMode('fast')` consume
290
+ it; it must not be duplicated in the command or template layer.
291
+
292
+ ### 3.2 Phases skipped in fast mode
293
+
294
+ Fast keeps **9** phases and skips **6**:
295
+
296
+ | Retained (9) | Skipped (6) |
297
+ | --- | --- |
298
+ | `plan_pending`, `plan_review`, `plan_passed`, `exec_running`, `review_pending`, `review_failed`, `review_passed`, `archiving`, `done` | `scheme_pending`, `scheme_review`, `scheme_passed`, `test_pending`, `test_failed`, `test_passed` |
299
+
300
+ Fast shortcuts relative to full:
301
+
302
+ - `plan_passed → exec_running` (skips the whole scheme leg).
303
+ - `review_passed → archiving` (skips the whole test leg).
304
+ - `exec_running` has **no** `scheme_pending` fallback; the only exit is `review_pending`.
305
+ - **`plan_review` is mandatory in fast mode**: `plan_pending → plan_review → plan_passed`;
306
+ `plan_review` can reject back to `plan_pending`.
307
+ - Code review is single-round-ish but still mandatory: `exec_running → review_pending` is the only
308
+ exit from `exec_running`.
309
+
310
+ ### 3.3 `review_failed` looping rule
311
+
312
+ `review_failed: ['review_pending', 'exec_running']`
313
+
314
+ Two exits, both legal:
315
+
316
+ | Target | Meaning |
317
+ | --- | --- |
318
+ | `review_pending` | **Re-review loop** — fixes were trivial / reviewer re-checks; re-enter review. |
319
+ | `exec_running` | **Back-to-execution loop** — return to the executor to repair. This slot occupies the position that the full table gives to `scheme_pending` ("对应 full 表 scheme_pending 位"), because fast mode has no scheme leg. |
320
+
321
+ In FULL mode the corresponding pair is `review_failed: ['review_pending', 'scheme_pending']`.
322
+
323
+ `review_passed → archiving` in fast, so nothing between review and archive; `test_*` phases are
324
+ unreachable in fast mode (no inbound edge targets them).
325
+
326
+ ### 3.4 Optional runtime override — `fast_transitions`
327
+
328
+ `fast_transitions` is an **optional top-level key** in `pipeline.yaml`, parsed by an **independent**
329
+ schema so a bad value cannot fail the whole pipeline config:
330
+
331
+ ```ts
332
+ export const FastTransitionsSchema = z.record(z.string(), z.array(z.string()));
333
+ ```
334
+
335
+ `loadPipelineConfig` logic, verbatim in effect:
336
+
337
+ ```ts
338
+ const fastRaw = (parsed as Record<string, unknown> | null)?.['fast_transitions'];
339
+ if (fastRaw !== undefined) {
340
+ const fastParsed = FastTransitionsSchema.safeParse(fastRaw);
341
+ if (fastParsed.success) {
342
+ // [REV-74-009 修订] 空对象 {} 等同缺失(空表使 fast 全链不可推进,无合法用途)→ 回落内置快线表
343
+ this.fastTransitions = Object.keys(fastParsed.data).length > 0 ? fastParsed.data : null;
344
+ } else {
345
+ this.fastTransitions = null;
346
+ console.warn(t('flow.manager.fastTransitionsInvalid', getCliLang(this.projectPath)));
347
+ }
348
+ }
349
+ ```
350
+
351
+ Rules:
352
+
353
+ | `fast_transitions` value | Result |
354
+ | --- | --- |
355
+ | key absent | `fastTransitions = null` → built-in `FAST_TRANSITIONS` |
356
+ | `{}` (empty object) | **treated as absent** → built-in `FAST_TRANSITIONS` (not an empty table) |
357
+ | non-empty object of `string → string[]` | used verbatim as the fast table |
358
+ | any other type (string, array, values not arrays) | `fastTransitions = null` + warn; **whole pipeline config still loads** |
359
+
360
+ Warning string (zh-CN): `[WARN] pipeline.yaml 的 fast_transitions 类型非法,已忽略并使用内置快线表`
361
+ (en: `[WARN] pipeline.yaml fast_transitions has invalid type; ignored, using built-in fast table`).
362
+
363
+ Note the deliberate asymmetry: `fast_transitions: {}` can **not** express "fast mode has no
364
+ pipeline". That state is expressible only via `mode === 'general'`.
365
+
366
+ ---
367
+
368
+ ## 4. GENERAL mode
369
+
370
+ ### 4.1 What it means
371
+
372
+ `WORKFLOW_MODES` has **3** values:
373
+
374
+ ```ts
375
+ // 3 值(stage-90):general = 无流水线 / 按需调度通用工作者 / 仅需求级合法
376
+ export const WORKFLOW_MODES = ['full', 'fast', 'general'] as const;
377
+ ```
378
+
379
+ - **No pipeline**: `resolveTransitionsForMode('general')` returns **`{}`** — an empty table. This is
380
+ "mode semantics itself", *not* a "missing config fallback". (The source explicitly distinguishes
381
+ this from `fast_transitions: {}`)
382
+ - Work is dispatched **on demand** to general workers (`openfeel-executor`, `openfeel-explorer`)
383
+ instead of being driven through the phase state machine.
384
+ - `phases` remains the full 15 built-in phases — general adds no new phase and removes none.
385
+ - General may be **explicitly upgraded** to `fast` / `full`; there is **no automatic downgrade**.
386
+
387
+ ### 4.2 Where it is legal (requirement level only)
388
+
389
+ Three writers **reject** `general` before any disk write:
390
+
391
+ | Writer | Location | Message key |
392
+ | --- | --- | --- |
393
+ | `openfeel stage set <id> --workflow-mode general` | `commands/stage.ts` | `common.generalOnlyRequirement` |
394
+ | `openfeel plan stage add --workflow-mode general` | `commands/plan.ts` | `common.generalOnlyRequirement` |
395
+ | `openfeel config set defaults.workflow_mode general` (deployment level) | `commands/config.ts` | `common.generalOnlyRequirement` |
396
+
397
+ Exact string (`common.generalOnlyRequirement`):
398
+
399
+ > `general(通用模式)仅限需求级——阶段级 / 部署级不支持;阶段请使用 fast / full。`
400
+
401
+ en: `general (General Mode) is valid at the requirement level only — not supported at stage / deployment level; use fast / full for stages.`
402
+
403
+ The interactive `flow wizard` does not offer `general` at all (choices are only `full` / `fast`).
404
+
405
+ Requirement-side carrier rules (`core/req/requirement-ops.ts`, `D-90-4`):
406
+
407
+ - `req add|set --workflow-mode general` is legal **only if the requirement has no linked stages**;
408
+ otherwise `general_mode_conflict`.
409
+ - `{id} 已关联阶段——general(无流水线)仅限无关联阶段的需求(可先解绑阶段,或改走 fast / full)`
410
+ - A `general` requirement **does not accept stage linking**.
411
+ - `{id} 工作流模式为 general(无流水线)——不接受阶段关联`
412
+ - `general` participates in the requirement `done` gate via the carrier duality: a `general`
413
+ requirement with zero linked stages may be completed explicitly, bypassing the
414
+ "all linked stages done" gate.
415
+ - Non-general rejection: `{id} 目标状态 done 被拒(当前 {current})——done 须全部关联阶段 done;零关联阶段的需求可设 general 模式显式完成`
416
+ - Defensive fallback: `general` **with** linked stages is treated as data corruption → falls back to
417
+ the strict "all linked stages done" rule (no exemption).
418
+
419
+ ### 4.3 Transitions and gates in general mode
420
+
421
+ | Aspect | Behavior |
422
+ | --- | --- |
423
+ | `getPipelineTransitions('general')` | `{}` (empty object, after tier pruning — pruning an empty table yields an empty table) |
424
+ | `getAvailablePhases(...)` | `[]` |
425
+ | `getValidTargets(any, 'general')` | `[]` |
426
+ | `hasTransition(...)` / `canAdvance(...)` | always `false` |
427
+ | `findPhasePath(stage, to, tier)` | `{ path: [], reason: 'no-path' }` (start ≠ target and BFS expands nothing) |
428
+ | `MODE_DERIVED_TIER.general` | `'full'` — "general(无流水线)恒全量语义,不特判" |
429
+ | `flow phases --mode general` | prints `phases` (15) + `transitions: {}` + `reviewTier: "full"` + `workflowMode: "general"` |
430
+
431
+ `flow advance` in general mode (defensive path; normal write paths never create a general stage):
432
+
433
+ ```
434
+ 错误: 阶段 "{stage}" 的工作流模式为 general(无流水线)——无合法跳转目标;如需推进请先显式升级为 fast / full。
435
+ ```
436
+
437
+ followed by the standard jump diagnostics block (see §6.4) and `exit 1`.
438
+
439
+ **Surprising bypass:** `--force` skips the entire `findPhasePath` step (`phasePath = [options.to]`),
440
+ so `flow advance --force` **does** move a general-mode stage. The only unbypassable check is the
441
+ blocking-REV gate on `to === 'done'`.
442
+
443
+ ---
444
+
445
+ ## 5. Review tiers (`full` / `lean` / `skip`)
446
+
447
+ ```ts
448
+ export const REVIEW_TIERS = ['full', 'lean', 'skip'] as const;
449
+
450
+ /**
451
+ * 各档位裁剪的审查关相位(**单源常量**)。
452
+ * 消费点唯一 = flow-manager 的 pruneTransitionsByTier(禁第二份裁剪谱)。
453
+ */
454
+ export const TIER_PRUNED_PHASES: Record<ReviewTier, readonly PipelinePhase[]> = {
455
+ full: [],
456
+ lean: ['scheme_review'],
457
+ skip: ['plan_review', 'scheme_review'],
458
+ };
459
+ ```
460
+
461
+ Semantics = **review-gate pruning spectrum** relative to the workflow-mode base table:
462
+
463
+ | Tier | Prunes | Description (source wording) |
464
+ | --- | --- | --- |
465
+ | `full` | *(nothing)* | 全量 = 零裁剪 |
466
+ | `lean` | `scheme_review` | 精简 = 裁 plan/scheme 两道中的 scheme_review |
467
+ | `skip` | `plan_review`, `scheme_review` | 免审 = 裁 plan_review + scheme_review |
468
+
469
+ **Always retained in all three tiers:** `review_pending` (code review = audit-chain backbone), the
470
+ test gate, the archive gate. Naming discipline in source comments: the word "快线" (fast line) is
471
+ forbidden for tiers to avoid collision with `workflow_mode=fast`.
472
+
473
+ ### 5.1 The exact pruning rule
474
+
475
+ ```ts
476
+ private pruneTransitionsByTier(table: Record<string, string[]>, tier: ReviewTier): Record<string, string[]> {
477
+ if (tier === 'full') {
478
+ return Object.fromEntries(Object.entries(table).map(([k, v]) => [k, [...v]]));
479
+ }
480
+ const pruned = TIER_PRUNED_PHASES[tier];
481
+ const isPruned = (p: string): boolean => pruned.includes(p as PipelinePhase);
482
+
483
+ const result: Record<string, string[]> = {};
484
+ for (const [key, targets] of Object.entries(table)) {
485
+ const keyConds = parseTransitionKey(key);
486
+ // ② 目标侧过滤被裁剪相位(裁剪只作用于入边 / 目标侧——被裁剪相位作源键时保留,其出边可推进)
487
+ let filtered = targets.filter((t) => !isPruned(t));
488
+ // ③ 边收缩回退:过滤后为空且原 targets 非空 → 并入被删目标的 targets(过滤被裁剪项与自身后去重)
489
+ if (filtered.length === 0 && targets.length > 0) {
490
+ const merged = new Set<string>();
491
+ for (const dropped of targets) {
492
+ if (!isPruned(dropped)) {
493
+ continue;
494
+ }
495
+ for (const cascaded of table[dropped] ?? []) {
496
+ if (!isPruned(cascaded) && !keyConds.includes(cascaded)) {
497
+ merged.add(cascaded);
498
+ }
499
+ }
500
+ }
501
+ filtered = [...merged];
502
+ }
503
+ result[key] = filtered;
504
+ }
505
+ return result;
506
+ }
507
+ ```
508
+
509
+ Formal statement:
510
+
511
+ 1. `tier === 'full'` → deep copy, **zero pruning** (this is why every legacy call site is
512
+ byte-identical). Never mutate the input (`FAST_TRANSITIONS` / `pipelineConfig` must stay clean).
513
+ 2. **Targets are filtered on the inbound/target side.** The pruned phase is a *value* only.
514
+ 3. **Keys are never deleted.** A pruned phase kept as a *source key* keeps its outbound edges, so a
515
+ stage **already sitting in** a pruned gate can still move forward. Only *entering* a pruned gate
516
+ is removed.
517
+ 4. **Edge contraction fallback**: if filtering empties a non-empty target list, the targets of the
518
+ dropped (pruned) target are spliced in, filtered again for pruned values **and for the key's own
519
+ conditions** (`!keyConds.includes(cascaded)`), then deduped via `Set`.
520
+ - The `keyConds` exclusion prevents a **self-loop**: contracting `plan_review → ['plan_passed',
521
+ 'plan_pending']` into `plan_pending`'s edge would otherwise reintroduce `plan_pending →
522
+ plan_pending`.
523
+ 5. **Registered boundary:** a *combined* key that itself contains a pruned phase (e.g.
524
+ `'scheme_review|x'`) is not deleted; only rule ② applies to its targets. Neither the built-in
525
+ full/fast tables nor this repo's runtime table contain such a key (the only OR-key,
526
+ `'review_passed|test_passed'`, contains no pruned phase).
527
+ 6. Pruning is applied to the **whole table**, including rows for pruned phases (their targets are
528
+ filtered too — e.g. `scheme_review: ['scheme_passed','scheme_pending']` stays intact because
529
+ neither target is pruned).
530
+
531
+ ### 5.2 FULL base table, `lean`
532
+
533
+ Only one edge disappears; nothing else changes:
534
+
535
+ | from | full targets | lean targets |
536
+ | --- | --- | --- |
537
+ | `plan_pending` | `[plan_review, plan_passed]` | `[plan_review, plan_passed]` |
538
+ | `plan_review` | `[plan_passed, plan_pending]` | `[plan_passed, plan_pending]` |
539
+ | `plan_passed` | `[scheme_pending]` | `[scheme_pending]` |
540
+ | `scheme_pending` | `[scheme_review, scheme_passed]` | **`[scheme_passed]`** |
541
+ | `scheme_review` | `[scheme_passed, scheme_pending]` | `[scheme_passed, scheme_pending]` (kept: source key) |
542
+ | `scheme_passed` | `[exec_running]` | `[exec_running]` |
543
+ | `exec_running` | `[review_pending, scheme_pending]` | `[review_pending, scheme_pending]` |
544
+ | `review_pending` | `[review_failed, review_passed]` | `[review_failed, review_passed]` |
545
+ | `review_failed` | `[review_pending, scheme_pending]` | `[review_pending, scheme_pending]` |
546
+ | `review_passed` | `[test_pending]` | `[test_pending]` |
547
+ | `test_pending` | `[test_failed, test_passed]` | `[test_failed, test_passed]` |
548
+ | `test_failed` | `[test_pending, scheme_pending]` | `[test_pending, scheme_pending]` |
549
+ | `test_passed` | `[archiving]` | `[archiving]` |
550
+ | `archiving` | `[done]` | `[done]` |
551
+ | `done` | `[]` | `[]` |
552
+
553
+ ### 5.3 FULL base table, `skip`
554
+
555
+ Two edges disappear; **no contraction occurs** (both affected rows retain a non-pruned target):
556
+
557
+ | from | full targets | skip targets |
558
+ | --- | --- | --- |
559
+ | `plan_pending` | `[plan_review, plan_passed]` | **`[plan_passed]`** |
560
+ | `plan_review` | `[plan_passed, plan_pending]` | `[plan_passed, plan_pending]` (kept: source key) |
561
+ | `plan_passed` | `[scheme_pending]` | `[scheme_pending]` |
562
+ | `scheme_pending` | `[scheme_review, scheme_passed]` | **`[scheme_passed]`** |
563
+ | `scheme_review` | `[scheme_passed, scheme_pending]` | `[scheme_passed, scheme_pending]` (kept: source key) |
564
+ | all others | — | unchanged |
565
+
566
+ ### 5.4 FAST base table, `lean` → **identical to fast**
567
+
568
+ `TIER_PRUNED_PHASES.lean` = `['scheme_review']`, which appears **nowhere** in `FAST_TRANSITIONS`
569
+ (neither as key nor as target). Result: fast+lean ≡ fast, byte-identical target arrays.
570
+
571
+ ### 5.5 FAST base table, `skip` → one contraction
572
+
573
+ `skip` prunes `plan_review` + `scheme_review`. `scheme_review` is absent from the fast table;
574
+ `plan_review` is the **sole target** of `plan_pending` → filtering empties it → **contraction fires**:
575
+
576
+ - dropped = `plan_review`; `table['plan_review']` = `['plan_passed', 'plan_pending']`
577
+ - `keyConds` = `['plan_pending']` → `plan_pending` excluded (self-loop guard), `plan_passed` accepted
578
+ - result: `plan_pending: ['plan_passed']`
579
+
580
+ | from | fast targets | fast+skip targets |
581
+ | --- | --- | --- |
582
+ | `plan_pending` | `[plan_review]` | **`[plan_passed]`** (contracted) |
583
+ | `plan_review` | `[plan_passed, plan_pending]` | `[plan_passed, plan_pending]` (kept: source key) |
584
+ | `plan_passed` | `[exec_running]` | `[exec_running]` |
585
+ | `exec_running` | `[review_pending]` | `[review_pending]` |
586
+ | `review_pending` | `[review_failed, review_passed]` | `[review_failed, review_passed]` |
587
+ | `review_failed` | `[review_pending, exec_running]` | `[review_pending, exec_running]` |
588
+ | `review_passed` | `[archiving]` | `[archiving]` |
589
+ | `archiving` | `[done]` | `[done]` |
590
+ | `done` | `[]` | `[]` |
591
+
592
+ This is the canonical worked example in the source: `['plan_review']` → contract → `plan_pending →
593
+ ['plan_passed']`, guaranteeing that pruning fast's base table by `plan_review` does not strand
594
+ `plan_pending` with an empty target set.
595
+
596
+ ### 5.6 Tier derivation from mode
597
+
598
+ ```ts
599
+ /**
600
+ * 工作流模式 → 派生的审查档位(**单源常量**;stage-92 D-92-3)。
601
+ * 仅用于「展示 / 提示」(`flow phases` 档位分节行与 `--json.reviewTier`);**不参与推进语义**
602
+ * (`flow advance` 的有效档位 = 显式声明 ?? full,缺省恒零裁剪 ⟹ 既有流程零回归)。
603
+ * general(无流水线)恒全量语义,不特判。
604
+ */
605
+ export const MODE_DERIVED_TIER: Record<WorkflowMode, ReviewTier> = {
606
+ full: 'full',
607
+ fast: 'lean',
608
+ general: 'full',
609
+ };
610
+ ```
611
+
612
+ **Two distinct tier variables — do not conflate:**
613
+
614
+ | Context | Value | Effect |
615
+ | --- | --- | --- |
616
+ | `flow phases` display line + `--json.reviewTier` | `MODE_DERIVED_TIER[derivedMode]` | **Display only.** `full→full`, `fast→lean`, `general→full`. |
617
+ | `flow advance` effective tier | `explicitTier ?? 'full'` | **Actual pruning.** `--review-tier` unspecified ⇒ **`full` (zero pruning)**, *not* the mode-derived tier. |
618
+
619
+ Source comment: "生效档位:显式声明 ?? full(**不传 = 零裁剪**;派生档仅用于 `flow phases` 展示与提示,不改变推进语义)".
620
+
621
+ Additional quirk: `flow phases` calls `mgr.getPipelineTransitions(derivedMode)` with the **default
622
+ tier `'full'`**, so the printed/JSON `transitions` object is **never tier-pruned**, even when
623
+ `reviewTier` says `lean`. A reimplementation must show the raw mode table alongside an unpruned
624
+ `reviewTier` label to match byte-for-byte.
625
+
626
+ Every other internal call site also defaults to `tier = 'full'`: `getAvailablePhases`,
627
+ `findPhasePath`, `hasTransition`, `canAdvance`, `getPipelineTransitions`, `getDefaultTransitions`,
628
+ `buildDownstreamPhases`, `flow wizard`. Only `flow advance` ever passes a non-`full` tier.
629
+
630
+ ---
631
+
632
+ ## 6. Gates — every condition that can block a transition
633
+
634
+ ### 6.1 Core layer: `advanceStagePhase`
635
+
636
+ Signature: `advanceStagePhase(stageName, phase, triggeredBy?, opts?: { reviewTier?: ReviewTier }): boolean`.
637
+
638
+ | # | Condition | Behavior / exact message |
639
+ | --- | --- | --- |
640
+ | C1 | `this.data === null` | returns `false` (silent) |
641
+ | C2 | stage not found after normalization + double-key fallback | throws `阶段 '${stageName}' 不存在` |
642
+ | C3 | `PipelinePhaseSchema.safeParse(phase)` fails, fuzzy correction succeeds | `console.warn` → `[WARN] advanceStagePhase: Phase '{phase}' 自动修正为 '{corrected}'` (`flow.manager.phaseAutoCorrectTmpl`), then proceeds |
643
+ | C4 | same, fuzzy correction fails | throws `非法 phase '${phase}',模糊修正失败` |
644
+ | C5 | `targetPhase === 'done'` and ≥1 blocking open REV | throws (see below) |
645
+
646
+ **C5 — the only unbypassable gate in the core.** Predicate, verbatim:
647
+
648
+ ```ts
649
+ const stageReviews = this.data!.reviews.filter(
650
+ (r) => r.op.startsWith(key + '.') || r.op === key,
651
+ );
652
+ const blockingOpen = stageReviews.filter(
653
+ (r) => r.blocking !== false && r.status === 'open',
654
+ );
655
+ ```
656
+
657
+ - A REV belongs to the stage iff `r.op === stageKey` **or** `r.op` starts with `stageKey + '.'`
658
+ (the `.` separator prevents `v1.0.0-stage-1` from matching `v1.0.0-stage-10`).
659
+ - "Blocking" is `blocking !== false` → **`undefined` counts as blocking**; only `blocking === false`
660
+ is non-blocking.
661
+ - Only `status === 'open'` blocks; `resolved` / `closed` do not.
662
+ - `--force` **cannot** bypass it. There is no force parameter in this function.
663
+
664
+ Exact thrown message:
665
+
666
+ ```
667
+ 无法推进到 done:存在 {N} 个未解决的阻塞 REV:
668
+ {id}: {title} (priority={priority})
669
+ {id}: {title} (priority={priority})
670
+ ```
671
+
672
+ (with a leading newline before the first REV line; two leading spaces per entry)
673
+
674
+ **There is no op-state gate anywhere in `advanceStagePhase`.** Advancing never inspects
675
+ `stages[..].ops[..].state`; a stage may be advanced with every op still `pending`/`draft`/`failed`.
676
+ The only op-count gate in the codebase is `checkRemovable` (§6.6).
677
+
678
+ ### 6.2 Core layer: `resetStagePhase`
679
+
680
+ `resetStagePhase(stageName, to, options: { dryRun?: boolean }): ResetStageResult`
681
+
682
+ | # | Condition | Behavior / exact message |
683
+ | --- | --- | --- |
684
+ | R1 | `!this.data` | throws `flow.json 未加载` |
685
+ | R2 | stage not found | throws `阶段 '${stageName}' 不存在` |
686
+ | R3 | `to` is not a valid `PipelinePhase` | throws `非法 phase '${to}'` — **no fuzzy correction** on this path |
687
+ | R4 | `to === 'done'` with blocking open REV | throws `无法复位到 done:存在 {N} 个未解决的阻塞 REV:\n{list}` — `--force` **不可绕过** |
688
+ | R5 | no effective change (`from === to && status === projectedStatus`) | no-op, returns `{ changed: false, ... }` |
689
+ | R6 | `dryRun` | returns projected result, writes nothing |
690
+
691
+ `reset` **bypasses the transition table entirely** (forward and backward jumps allowed), **does not
692
+ write a checkpoint**, **does not trigger the archive git commit**, and writes audit action
693
+ `reset_stage_phase`.
694
+
695
+ ### 6.3 Command layer: `flow advance` — pre-transition gates, in exact evaluation order
696
+
697
+ | Order | Gate | Failure behavior / exact string |
698
+ | --- | --- | --- |
699
+ | 1 | `--stage` missing | `错误:--stage 参数必须指定阶段 ID(如 stage-03)。如果是新项目,请先运行 openfeel stage create <id> 创建阶段。` → exit 1 |
700
+ | 2 | `--review-tier` not in `REVIEW_TIERS` | `字段 审查档位 的取值非法:{value}(允许:full, lean, skip)` → exit 1, **zero writes** (checked before any read/path resolve/autoRepair) |
701
+ | 3 | `!mgr.isLoaded()` | `错误:flow.json 未初始化,请先运行 openfeel init` → exit 1 |
702
+ | 4 | `autoRepairInconsistency(stage, {dryRun})` fixed something | prints `检测到阶段数据不一致,已自动修复: {detail}` (or `检测到阶段数据不一致,正式执行将自动修复: {detail}` for dry-run), then `save()` when not dry-run |
703
+ | 5 | `validate()` returns warnings | each `[WARN] {w}` to stderr, then `(非标准 phase 已自动修正,继续推进)` |
704
+ | 6 | `validate()` returns `valid === false` | `错误:flow.json 格式不合法` + ` - {err}` per error → exit 1 |
705
+ | 7 | `--force` absent + `options.to` fails `PipelinePhaseSchema` | `错误: '{phase}' 不是合法的 PipelinePhase。` / `合法值: [{15 phases}]` / `使用 --force 强制执行(自动模糊修正)` → exit 1 |
706
+ | 8 | `--force` absent + `findPhasePath(stage, to, effTier)` returns `ambiguous` | `存在多条等价路径,已拒绝;请逐步显式指定 --to` → then §6.4 block → exit 1 |
707
+ | 9 | same, `depth-exceeded` | `路径深度超过上限(8),已拒绝(目标可能不可达:目标相位在当前档位下被裁剪或无可达入边)` → then §6.4 → exit 1 |
708
+ | 10 | same, `no-path` **and** stage mode is `general` | `错误: 阶段 "{stage}" 的工作流模式为 general(无流水线)——无合法跳转目标;如需推进请先显式升级为 fast / full。` → then §6.4 → exit 1 |
709
+ | 11 | same, `no-path` | `无法从 {from} 到达 {to};当前可达目标:{targets}` (`{targets}` = `[a, b]`, or `(无)` when empty) → then §6.4 → exit 1 |
710
+ | 12 | same, `already-at-target` | no-op success: `已在目标阶段:{stage} → {to}(无操作)`, **return (exit 0)** |
711
+ | 13 | `to === 'done'` and current phase ∈ `['exec_running','review_pending']` | warning only: `[!] 跳过审查阶段直接标记 done,确认继续` |
712
+ | 14 | explicitTier ∈ `lean`/`skip` | warning only: `[!] 审查档位『{tier}』:本次推进免除审查关 {gates}(代码审查关恒保留)` |
713
+ | 15 | `to === 'done'` and blocking open REV | if `--force`: `[!] --force 已指定,但 REV 安全检查不可绕过。拒绝推进。` (warn); then `错误:blocking REV 未解决前禁止推进到 done。` + `请先解决上述 REV 或通过 flow review resolve 标记为非阻塞。` → exit 1 |
714
+ | 16 | `--dry-run` | prints preview, **return** (no writes, no metrics, no save) |
715
+ | 17 | runtime failure inside a step of a multi-step path | `已完成到 {done};失败于 {failed};剩余路径:{remaining}` + `错误:{msg}` → exit 1 (**no rollback** of completed steps) |
716
+ | 18 | after each step of a multi-step path, blocking open REV appears | `存在阻塞中的审查条目,已停止推进:{revs};剩余路径:{remaining}` → exit 1 |
717
+
718
+ Before step 15, the command prints the offending REVs:
719
+
720
+ ```
721
+ [!] 检测到 {n} 个未解决的阻塞 REV:
722
+ {id}: {title} (priority={priority})
723
+ ```
724
+
725
+ (4 leading spaces per entry). `assertNoBlockingOpenRev` uses the same predicate as the core:
726
+ `r.op === stage || r.op.startsWith(stage + '.')`, `r.blocking !== false`, `r.status === 'open'`.
727
+
728
+ ### 6.4 The shared "phase jump" diagnostic block
729
+
730
+ Printed after gates 8/9/10/11, always before `exit 1`:
731
+
732
+ ```
733
+ 错误: 阶段 "{stage}" 当前 phase 无法跳转到 "{to}"
734
+ 当前阶段 phase: {phase}
735
+ 合法跳转目标: [{a, b}]
736
+ 使用 --force 强制执行
737
+ ```
738
+
739
+ Line 3 variants:
740
+
741
+ - non-empty targets → `合法跳转目标: [{targets}]`
742
+ - empty targets → `当前 phase 无合法跳转目标`
743
+
744
+ ### 6.5 `--force` semantics (exact scope)
745
+
746
+ | Check | `--force` |
747
+ | --- | --- |
748
+ | Gate 2 (`--review-tier` domain) | **not** bypassed (runs before force is consulted) |
749
+ | Gate 7 (illegal target phase) | **skipped** (falls into `advancePhase`'s fuzzy correction instead) |
750
+ | Gates 8–12 (path/jump legality) | **skipped** — `phasePath = [options.to]`, single step, no `findPhasePath` |
751
+ | Gate 13/14 warnings | still printed |
752
+ | Gate 15 (blocking REV on `done`) | **NOT bypassed** (only adds the `forceRevRefused` warning) |
753
+ | Multi-step loop + per-step REV recheck (17/18) | n/a (single step) |
754
+ | Transition-table legality in `advanceStagePhase` | never checked anyway |
755
+
756
+ ### 6.6 Other gates in the pipeline area
757
+
758
+ **`checkRemovable` / `flow stage remove <stageId>`** — the only gate that looks at ops:
759
+
760
+ | Order | Condition | Exact reason string |
761
+ | --- | --- | --- |
762
+ | 1 | `!this.data` | `流水线数据未加载` |
763
+ | 2 | stage not found | `阶段不存在:'{stageKey}'` |
764
+ | 3 | `opCount > 0` | `阶段 '{stageKey}' 仍有 {opCount} 个未归档的 op;如需强制移除此阶段,请加 --force` |
765
+ | 4 | `isCurrent` | `阶段 '{stageKey}' 是当前活跃阶段;如需强制移除,请加 --force(将自动回退 current)` |
766
+ | 5 | referenced by other stages' `deps` | `阶段 '{stageKey}' 被其它阶段依赖:{refs};如需强制移除(将产生悬空依赖),请加 --force` |
767
+
768
+ All five are bypassable with `--force` (except "stage not found"). `--purge` additionally deletes
769
+ `plan/{series}/{stageDir}/` **only after `save()` succeeds** — the directory deletion happens in the
770
+ command layer, never in `removeStage`.
771
+
772
+ **`flow archive <version>` judge gate** (version sharding; not a transition but blocks the
773
+ version-level "done" closure):
774
+
775
+ | Condition | Exact string |
776
+ | --- | --- |
777
+ | version not active (absent or already archived) | `版本 {version} 未注册于活跃 flow.json(不存在或已归档);拒绝归档(零写盘)` (`flow.archive.versionNotActiveTmpl`) |
778
+ | any stage in the version not `done` | `版本 {version} 存在未完成阶段({n} 个):` + ` - {id} [{phase}]` per stage |
779
+ | any blocking open REV in the version | `版本 {version} 存在阻塞 open 审查({n} 条):` + ` - {id}: {title} (priority={priority})` |
780
+ | either of the above | `归档判据未满足,拒绝执行(零写盘)` → exit 1, **zero writes** |
781
+ | invalid version string | `flow.archive.invalidVersionTmpl` → exit 1, zero writes |
782
+
783
+ `--json` failure payloads: `{schemaVersion:1, error:'no-init'}`,
784
+ `{...error:'invalid-version', input}`, `{...error:'version-not-active', version}`,
785
+ `{...error:'gate-rejected', version, notDone, blockingOpen}`, `{...error:'archive-write-failed', version}`.
786
+
787
+ **`flow attempt --op <id> --result <pass|fail>`** — draft ops are refused at both layers:
788
+
789
+ - Command layer: `flow.attempt.draftRefusedTmpl` =
790
+ `{opId} 处于 draft(未发布),无法记录执行结果;请先运行 openfeel plan scheme publish {stage} {opId}` → exit 1.
791
+ - Core `recordAttempt`: writes audit `attempt_refused_draft` and returns
792
+ `{ shouldRetry: false, shouldReplan: false }` (does **not** throw, does **not** change state).
793
+
794
+ ---
795
+
796
+ ## 7. `mapPhaseToStageStatus` — full mapping table
797
+
798
+ ```ts
799
+ export const STAGE_STATUS_VALUES = ['planned', 'review_failed', 'review_passed', 'testing', 'archiving', 'done'] as const;
800
+
801
+ export function mapPhaseToStageStatus(
802
+ phase: PipelinePhase,
803
+ currentStatus: string,
804
+ ): string {
805
+ switch (phase) {
806
+ case 'review_failed':
807
+ return 'review_failed';
808
+ case 'review_passed':
809
+ return 'review_passed';
810
+ case 'test_passed':
811
+ // status=done 是 phase=done 的专属状态:test_passed 映射为中间状态 testing,
812
+ // 避免 autoRepairInconsistency 在 flow advance 时把 phase 误修正为 done,截断 test_passed→archiving 路径
813
+ return 'testing';
814
+ case 'archiving':
815
+ // 归档阶段映射为中间状态 archiving,避免被 autoRepairInconsistency 误修正为 done
816
+ return 'archiving';
817
+ case 'done':
818
+ return 'done';
819
+ default:
820
+ // 其他阶段保持 stage 当前状态不变
821
+ return currentStatus;
822
+ }
823
+ }
824
+ ```
825
+
826
+ | `phase` | → `status` |
827
+ | --- | --- |
828
+ | `plan_pending` | `currentStatus` (unchanged) |
829
+ | `plan_review` | `currentStatus` (unchanged) |
830
+ | `plan_passed` | `currentStatus` (unchanged) |
831
+ | `scheme_pending` | `currentStatus` (unchanged) |
832
+ | `scheme_review` | `currentStatus` (unchanged) |
833
+ | `scheme_passed` | `currentStatus` (unchanged) |
834
+ | `exec_running` | `currentStatus` (unchanged) |
835
+ | `review_pending` | `currentStatus` (unchanged) |
836
+ | `review_failed` | **`review_failed`** |
837
+ | `review_passed` | **`review_passed`** |
838
+ | `test_pending` | `currentStatus` (unchanged) |
839
+ | `test_failed` | `currentStatus` (unchanged) |
840
+ | `test_passed` | **`testing`** ← **not** `done` |
841
+ | `archiving` | **`archiving`** |
842
+ | `done` | **`done`** |
843
+
844
+ Invariants:
845
+
846
+ - `status` is a **coarse projection** of `phase`; `phase` is the single source of truth.
847
+ - `test_passed → 'testing'` (a "middle" state) is deliberate: mapping it to `done` would let
848
+ `autoRepairInconsistency` rewrite `phase` to `done` and truncate the `test_passed → archiving`
849
+ path.
850
+ - `review_passed` **always** maps to `'review_passed'`; intermediate phases never project to a
851
+ terminal state.
852
+ - The projected value domain ∪ the initial value `'planned'` = `STAGE_STATUS_VALUES`, which is the
853
+ write domain for `stage set --status`. Free-form/historical status strings are readable
854
+ (`default:` returns them) but not writable through `stage set`.
855
+ - New stages start at `phase: 'plan_pending'`, `status: 'planned'` (`addStage`).
856
+ - `autoRepairInconsistency` reconciles only the two terminal mismatches:
857
+ - `status === 'done' && phase !== 'done'` → `status = mapPhaseToStageStatus(phase, status)`
858
+ (detail: `status {old} → {new} (以 phase 为权威,撤销非法 done)`)
859
+ - `phase === 'done' && status !== 'done'` → `status = 'done'`
860
+ (detail: `status {old} → done (与 phase 同步)`)
861
+
862
+ ---
863
+
864
+ ## 8. Config cascade — precedence and the four managed keys
865
+
866
+ ### 8.1 The four managed keys
867
+
868
+ `EFFECTIVE_CONFIG_KEYS` and `DEFAULT_CONFIG`:
869
+
870
+ ```ts
871
+ const EFFECTIVE_CONFIG_KEYS = ['execution_mode', 'auto_advance', 'merge_mode', 'workflow_mode'] as const;
872
+
873
+ export const DEFAULT_CONFIG: Config = {
874
+ execution_mode: 'manual',
875
+ auto_advance: 'enabled', // 裁定 E:部署级默认自动推进开启(与 workflow_mode 无关)
876
+ merge_mode: 'manual',
877
+ workflow_mode: 'fast', // 裁定 B:新建默认 fast
878
+ };
879
+ ```
880
+
881
+ `ConfigDefaultsSchema` (per-key `.default()` **must** stay in sync with `DEFAULT_CONFIG`):
882
+
883
+ ```ts
884
+ export const ConfigDefaultsSchema = z.object({
885
+ execution_mode: z.enum(['manual', 'auto']).optional().default('manual'),
886
+ auto_advance: z.enum(['disabled', 'enabled']).optional().default('enabled'),
887
+ merge_mode: z.enum(['manual', 'auto']).optional().default('manual'),
888
+ workflow_mode: z.enum(WORKFLOW_MODES).optional().default('fast'),
889
+ }).passthrough();
890
+ ```
891
+
892
+ | Key | Domain | Default | status.md carrier field | Regex used to read it |
893
+ | --- | --- | --- | --- | --- |
894
+ | `execution_mode` | `manual` \| `auto` | `manual` | `执行模式` | `/\*\*执行模式\*\*[::]\s*(manual\|auto)/` |
895
+ | `auto_advance` | `disabled` \| `enabled` | `enabled` | `自动推进` | `/\*\*自动推进\*\*[::]\s*(disabled\|enabled)/` |
896
+ | `merge_mode` | `manual` \| `auto` | `manual` | **none** (not in `STAGE_FIELD_BY_CONFIG_KEY`) | never read from status.md |
897
+ | `workflow_mode` | `full` \| `fast` \| `general` | `fast` | `工作流模式` | ``new RegExp(`\\*\\*工作流模式\\*\\*[::]\\s*(${WORKFLOW_MODES.join('\|')})`)`` |
898
+
899
+ `STAGE_FIELD_BY_CONFIG_KEY`:
900
+
901
+ ```ts
902
+ export const STAGE_FIELD_BY_CONFIG_KEY: Record<string, string> = {
903
+ auto_advance: '自动推进',
904
+ execution_mode: '执行模式',
905
+ workflow_mode: '工作流模式',
906
+ };
907
+ ```
908
+
909
+ `merge_mode` is intentionally absent — batch sync (`config set --sync-stages`) reports it as
910
+ `skipped-no-field` (D-config ruling). `[::]` accepts both full-width and half-width colons.
911
+
912
+ `getConfigFieldLegalValues(key)` derives the legal-value list from the schema:
913
+ `ZodEnum → [...options]`, `ZodBoolean → ['true','false']`, otherwise `null`. The depth-limited
914
+ `unwrapSchema` loop peels up to 8 `ZodDefault`/`ZodOptional`/`ZodNullable` layers.
915
+
916
+ ### 8.2 Precedence order
917
+
918
+ **Four-layer cascade (`buildCascadeConfig`)** — lowest to highest:
919
+
920
+ ```
921
+ profile.yaml (preferences) < config.yaml (defaults) < status.md (stage overrides)
922
+ ↑ ↑ ↑
923
+ lowest priority middle priority highest priority
924
+ ```
925
+
926
+ `effective = { ...profileDefaults, ...configDefaults, ...statusOverrides }` (later write wins).
927
+
928
+ `resolveEffectiveConfig()` maps each key to `{ value, source }` with `source ∈
929
+ 'status.md' | 'requirement' | 'config.yaml' | 'profile.yaml' | 'builtin'`:
930
+
931
+ ```
932
+ status.md > config.yaml > profile.yaml > builtin
933
+ ```
934
+
935
+ **But `workflow_mode` has a five-layer chain** via `resolveStageWorkflowMode(stageId?)`, which
936
+ `resolveEffectiveConfig` delegates to (single point of layer judgement — a second layer ordering is
937
+ forbidden):
938
+
939
+ ```
940
+ status.md > requirement > config.yaml > profile.yaml > builtin
941
+ (A) (B) (C) (D) (E)
942
+ ```
943
+
944
+ | Layer | Source | Detail |
945
+ | --- | --- | --- |
946
+ | A | `status.md` | `findStageStatusPath(projectPath, stageId)` (3-level fallback), regex on `**工作流模式**` |
947
+ | B | `requirement` | `stages[normalize(stageId)].requirement_id` → `data.requirements.find(r => r.id === ...)` → `reqRec.workflow_mode`, accepted only if `WORKFLOW_MODES.includes(...)`; missing/dangling/illegal → falls through silently |
948
+ | C | `config.yaml` | `.openfeel/config.yaml` → `defaults.workflow_mode`, per-key `ConfigDefaultsSchema.shape[key].safeParse` |
949
+ | D | `profile.yaml` | **Intentionally does not carry `workflow_mode`** — branch kept for symmetry, never produced in practice (REV-74-005) |
950
+ | E | `builtin` | `DEFAULT_CONFIG.workflow_mode` = `'fast'` |
951
+
952
+ Because `resolveEffectiveConfig()` delegates `workflow_mode` to this function with **no argument**,
953
+ its result is scoped to the **current stage** (`pipeline.current.stage`); the other three keys are
954
+ resolved through the plain 3-layer cascade with no stage scoping and no requirement layer.
955
+
956
+ **`profile.yaml` only supplies `auto_advance`**, and only when the file exists **and** the raw YAML
957
+ **explicitly declares** `preferences.auto_advance ∈ {'enabled','disabled'}`. Otherwise that layer is
958
+ empty and the key falls to `builtin`. (`ProfileSchema.preferences.review_mode` /
959
+ `communication` / `confirm_threshold` do **not** participate in this cascade.)
960
+
961
+ **No stage-level or requirement-level carrier exists for `execution_mode`, `auto_advance`,
962
+ `merge_mode`** other than status.md's `执行模式` / `自动推进` (merge_mode has none at all).
963
+
964
+ ### 8.3 Per-key validation and degradation
965
+
966
+ `buildCascadeConfig`, config.yaml layer:
967
+
968
+ ```ts
969
+ for (const [key, value] of Object.entries(defaults)) {
970
+ const fieldSchema = ConfigDefaultsSchema.shape[key as keyof typeof ConfigDefaultsSchema.shape];
971
+ if (!fieldSchema) {
972
+ // 非受管扩展键:保持原行为纳入
973
+ configDefaults[key] = String(value);
974
+ continue;
975
+ }
976
+ const parsed = fieldSchema.safeParse(value);
977
+ if (parsed.success) {
978
+ configDefaults[key] = String(value);
979
+ } else {
980
+ const reason = parsed.error?.issues[0]?.message ?? 'invalid';
981
+ console.warn(t('config.effective.invalidValueSkipped', lang, { key, reason }));
982
+ }
983
+ }
984
+ ```
985
+
986
+ - **Unmanaged extension keys pass through** as `String(value)`.
987
+ - **Invalid managed values are skipped with a warning** and the key falls through to the next layer
988
+ — they do not throw and do not abort the read. Warning text
989
+ (`config.effective.invalidValueSkipped`): `配置项 {key} 取值非法,已跳过:{reason}`.
990
+ - A YAML parse failure yields an empty layer (`catch {}`), not an error.
991
+ - Missing `defaults` block or missing file → empty layer.
992
+
993
+ ### 8.4 Skeleton initialisation — `resolveConfigDefaults(projectPath)`
994
+
995
+ Separate path, used to seed `status.md` skeleton values at stage creation:
996
+
997
+ - Reads **only** `.openfeel/config.yaml` `defaults` — **no status.md, no profile.yaml, no
998
+ requirement layer, no effective merge**.
999
+ - Starts from `DEFAULT_CONFIG`, then per key:
1000
+ `ConfigDefaultsSchema.shape[key].safeParse(defaults[key])` → accept only when
1001
+ `parsed.success && typeof parsed.data === 'string'`.
1002
+ - Because every field carries `.default()`, `safeParse(undefined)` succeeds and yields the schema
1003
+ default, which equals `DEFAULT_CONFIG[key]` — so absent keys are silently equal to builtin.
1004
+ - YAML/read errors → keep all four defaults.
1005
+
1006
+ `normalizeConfigKey(key)` strips **one** `defaults.` prefix (`defaults.X` ≡ `X`); a bare `defaults.`
1007
+ normalises to `''` and is rejected by the whitelist.
1008
+
1009
+ ### 8.5 `flow.json`'s `workflow_mode` field
1010
+
1011
+ `StageData.workflow_mode?: WorkflowMode` is an **optional redundant field that does NOT participate
1012
+ in the cascade** — the read authority is always status.md → requirement → config.yaml → builtin
1013
+ (ruling G). It must never be used to resolve mode.
1014
+
1015
+ ---
1016
+
1017
+ ## 9. Stage-id parsing and the `archived_versions` prefix rule
1018
+
1019
+ Constants:
1020
+
1021
+ ```ts
1022
+ export const DEFAULT_SERIES = 'v1';
1023
+ export const DEFAULT_STAGE_VERSION = 'v1.0.0';
1024
+ ```
1025
+
1026
+ ### 9.1 `parseStageId(stageId): ParsedStageId | null`
1027
+
1028
+ ```ts
1029
+ // 完整/历史格式:{version}-stage-{NN},version 可为 v1.0.0(四级)或 v4(仅主版本)
1030
+ const full = stageId.match(/^(v\d+(?:\.\d+)*)-stage-(\d+)$/);
1031
+ if (full) {
1032
+ const version = full[1]; // v1.0.0 或 v4
1033
+ const stageDir = `stage-${full[2]}`;
1034
+ const series = version.split('.')[0]; // v1 或 v4
1035
+ return { series, stageDir, fullStageId: stageId };
1036
+ }
1037
+
1038
+ // 短名格式:stage-{NN}
1039
+ const short = stageId.match(/^stage-(\d+)$/);
1040
+ if (short) {
1041
+ const stageDir = `stage-${short[1]}`;
1042
+ return {
1043
+ series: DEFAULT_SERIES,
1044
+ stageDir,
1045
+ fullStageId: `${DEFAULT_STAGE_VERSION}-${stageDir}`,
1046
+ };
1047
+ }
1048
+
1049
+ return null;
1050
+ ```
1051
+
1052
+ | Input | `series` | `stageDir` | `fullStageId` |
1053
+ | --- | --- | --- | --- |
1054
+ | `v1.0.0-stage-34` | `v1` | `stage-34` | `v1.0.0-stage-34` |
1055
+ | `v4-stage-04` | `v4` | `stage-04` | `v4-stage-04` |
1056
+ | `stage-01` | `v1` | `stage-01` | `v1.0.0-stage-01` |
1057
+ | `foo`, `v1`, `''`, `stage-`, `v1.0.0-stage-01-x` | — | — | `null` |
1058
+
1059
+ Rules:
1060
+
1061
+ - **Leading zeros preserved**: `stage-04` stays `stage-04` (no `parseInt` on the `NN` capture).
1062
+ `stage-4` and `stage-04` are **different** `stageDir` values.
1063
+ - `series` = first dot-segment of the version: `v1.0.0 → v1`, `v4 → v4`. Depth is unlimited
1064
+ (`v1.2.3.4` is accepted).
1065
+ - `normalizeStageId(x)` = `parseStageId(x)?.fullStageId ?? null`.
1066
+ - `stageIdToPlanDir(x)` = `` `plan/${series}/${stageDir}/` `` or `null`.
1067
+ - **Directory convention**: `.openfeel/plan/{series}/stage-{NN}/` — `{series}` is the major-version
1068
+ series (`v{MAJOR}`), `{stage}` is `stage-{NN}` without the version prefix. `flow.json` stageId is
1069
+ the authoritative identity (unique key); the directory name is a derived organisational unit.
1070
+
1071
+ ### 9.2 Double-key fallback (applies to every stage lookup)
1072
+
1073
+ ```ts
1074
+ const normalized = normalizeStageId(stageName) ?? stageName;
1075
+ const key = this.data.stages[normalized] ? normalized : stageName;
1076
+ ```
1077
+
1078
+ The normalised (full) id is preferred; if it is not a key, the **raw input** is used. This is
1079
+ repeated in `advanceStagePhase`, `resetStagePhase`, `findPhasePath`, `resolveCurrentPhase`,
1080
+ `resolveStageWorkflowMode`, `checkRemovable`, `removeStage`, `autoRepairInconsistency` and the CLI
1081
+ layer (`stageArg = normalizeStageId(options.stage) ?? options.stage`). Legacy data keyed by short
1082
+ names must keep working.
1083
+
1084
+ ### 9.3 `validateStageId`
1085
+
1086
+ ```ts
1087
+ if (!input || !input.trim()) {
1088
+ return { ok: false, reason: '阶段 ID 不能为空' };
1089
+ }
1090
+ if (!parseStageId(input)) {
1091
+ return { ok: false, reason: `非法阶段 ID:「${input}」(应为 stage-NN 或 vX.Y.Z-stage-NN)` };
1092
+ }
1093
+ return { ok: true };
1094
+ ```
1095
+
1096
+ CLI user-facing form (`common.stageIdInvalidTmpl` + `common.stageIdSuggestTmpl`):
1097
+
1098
+ ```
1099
+ {input} → 非法阶段 ID(应为 stage-NN 或 vX.Y.Z-stage-NN)
1100
+ 建议名称:{suggest}
1101
+ ```
1102
+
1103
+ `suggestStageId(projectPath, input)`: if `input` matches `/^(v\d+(?:\.\d+)*)$/`, that version is used;
1104
+ otherwise `DEFAULT_STAGE_VERSION` (`v1.0.0`). `NN` = `max(used) + 1` over **that series only**,
1105
+ collected from `flow.json.stages` keys ∪ `.openfeel/plan/{series}/stage-NN/` directory names, output
1106
+ as `` `${version}-stage-${String(nextSeq).padStart(2, '0')}` `` (empty set → `1` → `01`).
1107
+
1108
+ ### 9.4 Directory conflict
1109
+
1110
+ `findStageDirConflict(projectPath, stageId)` returns the *other* registered stageId that maps to the
1111
+ same `(series, stageDir)` pair (e.g. `v4-stage-04` conflicts with `v4.0.0-stage-04`). `addStage`
1112
+ throws:
1113
+
1114
+ ```
1115
+ 阶段目录冲突:'{stage}' 与 '{other}' 映射同一 (series, stageDir),请改用其它 stage-NN
1116
+ ```
1117
+
1118
+ (`StageDirConflictError`; `name = 'StageDirConflictError'`; exposed as `.stage` / `.other`.)
1119
+
1120
+ ### 9.5 `archived_versions` prefix rule — `isArchivedVersionOf`
1121
+
1122
+ ```ts
1123
+ export function isArchivedVersionOf(
1124
+ stageId: string,
1125
+ archivedVersions: readonly string[] | undefined,
1126
+ ): boolean {
1127
+ const parsed = parseStageId(stageId);
1128
+ // 短名输入经 parseStageId 归一化(fullStageId ≠ 原输入)→ 恒 false(REV-80-001)
1129
+ if (!parsed || parsed.fullStageId !== stageId) {
1130
+ return false;
1131
+ }
1132
+ // 版本段派生:剥离 `-{stageDir}` 后缀(与 versionOfStageId 同源技术)
1133
+ const suffix = `-${parsed.stageDir}`;
1134
+ if (!parsed.fullStageId.endsWith(suffix)) {
1135
+ return false;
1136
+ }
1137
+ const version = parsed.fullStageId.slice(0, -suffix.length);
1138
+ return Array.isArray(archivedVersions) ? archivedVersions.includes(version) : false;
1139
+ }
1140
+ ```
1141
+
1142
+ Rule: **explicit version prefix (full-id form) ∧ version segment ∈ `archivedVersions` → `true`;
1143
+ everything else `false`.**
1144
+
1145
+ Three consequences a reimplementation is likely to get wrong:
1146
+
1147
+ 1. **Short names are always `false`.** `stage-04` normalises to `v1.0.0-stage-04`, which is *not*
1148
+ equal to the input, so the `parsed.fullStageId !== stageId` guard rejects it. A short name must
1149
+ never be judged "archived" off the `DEFAULT_STAGE_VERSION` normalisation.
1150
+ 2. **The caller must pass the ORIGINAL input**, never a normalised product — passing
1151
+ `v1.0.0-stage-04` when the user typed `stage-04` changes the answer.
1152
+ 3. **Version segment derivation is suffix stripping**: `v1.1.7-stage-12` minus `-stage-12` =
1153
+ `v1.1.7`. `archivedVersions` comes from `flow.json.meta.archived_versions`; a missing field (or a
1154
+ non-array) is treated as `[]` → `false`. No `flow-archive/*` import is permitted here (would form
1155
+ a dependency cycle), which is why the derivation is re-implemented locally.
1156
+
1157
+ `planDirToStageId(projectPath, stageDir)` is the reverse map. Disambiguation when multiple stageIds
1158
+ match `*-stage-{NN}`: (1) prefer `pipeline.current.stage` if it is in the match set; (2) otherwise
1159
+ `matches.slice().sort().at(-1)` (lexicographically latest version prefix).
1160
+
1161
+ ### 9.6 `findStageStatusPath` — 3-level fallback
1162
+
1163
+ 1. Exact: `.openfeel/plan/{series}/{stageDir}/status.md`
1164
+ 2. Recursive: `fast-glob.sync('**/{stageDir}/status.md', { cwd: .openfeel/plan, onlyFiles: true, caseSensitiveMatch: true })` — first match
1165
+ 3. Legacy: `.openfeel/stages/{stageId}/status.md`
1166
+
1167
+ Returns an absolute path or `null`.
1168
+
1169
+ ---
1170
+
1171
+ ## 10. Invariants a reimplementation must preserve
1172
+
1173
+ ### 10.1 Revision bumping and atomic write ordering (`save()`)
1174
+
1175
+ Exact order:
1176
+
1177
+ 1. `if (!this.data) return;`
1178
+ 2. `this.data.meta ??= { version: '1.0', project: '', updated: '', revision: 0 };` (`??=` only fills a
1179
+ fully-missing `meta`, never overwrites existing fields)
1180
+ 3. `this.data.meta.updated = new Date().toISOString();`
1181
+ 4. Build a serialisable deep copy via `JSON.parse(JSON.stringify(this.data))` and **delete every
1182
+ `op.id`** — `op.id` is a runtime convenience field reconstructed from the `stages.{id}.ops` key on
1183
+ load; it is **never persisted**.
1184
+ 5. Acquire the project flow lock: `withFileLock(projectLockPath(this.projectPath, 'flow'), ...)`.
1185
+ 6. **Inside the lock**, read the on-disk revision (`readDiskRevision()`: missing file / corrupt /
1186
+ non-integer / absent → `0`).
1187
+ 7. If `diskRevision !== this.loadedRevision` → throw
1188
+ `FlowConcurrentModificationError(expected, actual)` with message
1189
+ `flow.json 并发冲突:期望 revision={expected},磁盘为 {actual}(已被其它进程修改)`
1190
+ (`name = 'FlowConcurrentModificationError'`; surfaced by the CLI as **exit code 2**).
1191
+ 8. `nextRevision = this.loadedRevision + 1`; set `serializable.meta.revision = nextRevision`.
1192
+ 9. `atomicWriteFileSync(this.filePath, JSON.stringify(serializable, null, 2) + '\n', { backup: true })`
1193
+ — **always** write a `.bak` of the previous version (the `.bak` is not re-overwritten after a
1194
+ successful write, so it always holds exactly the previous version). Trailing newline is part of
1195
+ the contract.
1196
+ 10. Only **after** a successful write: sync memory (`this.data.meta.revision = nextRevision`) and the
1197
+ load baseline (`this.loadedRevision = nextRevision`) so consecutive `save()` on the same instance
1198
+ does not self-conflict.
1199
+ 11. On any throw: `console.error('[ERROR] flow.json 保存失败: {msg}')` then rethrow.
1200
+
1201
+ Consequences:
1202
+
1203
+ - **The core never saves.** `advanceStagePhase` / `resetStagePhase` / `recordAttempt` mutate memory
1204
+ only; the **command layer** calls `save()`.
1205
+ - Revision is strictly monotonic per instance: `loadedRevision → loadedRevision + 1`, and memory
1206
+ follows only on success.
1207
+ - `setData()` (test-only injection) re-syncs `loadedRevision` from disk.
1208
+
1209
+ ### 10.2 `advanceStagePhase` side-effect ordering (exact)
1210
+
1211
+ ```
1212
+ 1. resolve + normalise stage key (double-key fallback); throw if missing
1213
+ 2. validate target phase (Schema → fuzzyCorrectPhase fallback)
1214
+ 3. if target === 'done': blocking-REV gate (throw) ← BEFORE any mutation
1215
+ 4. capture fromPhase = stage.phase
1216
+ 5. stage.phase = targetPhase
1217
+ 6. saveCheckpoint(key, targetPhase) ← best-effort, never blocks
1218
+ 7. if targetPhase ∈ {exec_running, review_pending, test_pending, archiving}: createLogSkeleton(key, targetPhase)
1219
+ 8. syncCurrentOp(key) ← single owner of pipeline.current
1220
+ 9. pipeline.phase = (allDone && stages non-empty) ? 'done' : 'active'
1221
+ 10. resumed = computeResumed(this.data.log, key, fromPhase, sessionId) ← BEFORE appendLog
1222
+ 11. appendLog({ action: 'advance_stage_phase', detail: {...} })
1223
+ 12. stage.status = mapPhaseToStageStatus(targetPhase, prevStatus)
1224
+ 13. if (!stage.stats || !stage.stats.start_time) startStage(key)
1225
+ 14. if (targetPhase === 'done' && prevStatus !== 'done'):
1226
+ endStage(key); syncRequirementStatus(this.data, key); return true
1227
+ return false
1228
+ ```
1229
+
1230
+ - Return value `true` ⇒ "this transition made the stage `done`"; the **caller must `save()` and then
1231
+ call `autoCommitOnDone(stageName)`** — the git commit must happen **after** `save()`, otherwise the
1232
+ commit would not contain the phase change.
1233
+ - `saveCheckpoint` and `createLogSkeleton` are both `try-catch` best-effort: a failure must never
1234
+ block the transition.
1235
+ - Step 10 must precede step 11: the resume scan walks the log **backwards** for the most recent
1236
+ `advance_stage_phase` / `reset_stage_phase` entry with `detail.stageName === stageKey &&
1237
+ detail.to === exitedPhase`. Writing the new entry first would make it scan itself.
1238
+
1239
+ ### 10.3 Log appending
1240
+
1241
+ ```ts
1242
+ appendLog(entry: LogEntry): void {
1243
+ const logEntry: LogEntry = { ...entry, time: entry.time || new Date().toISOString() };
1244
+ this.data.log.push(logEntry);
1245
+ }
1246
+ ```
1247
+
1248
+ - `time` is filled **only if falsy**; callers pass `time: ''` and rely on this.
1249
+ - Append-only; entries are never mutated or removed by the transition code (only `flow archive`
1250
+ removes log segments, and it does so by replacing the whole array).
1251
+ - `advance_stage_phase` detail, exact key set:
1252
+
1253
+ ```ts
1254
+ {
1255
+ stageName: key, // always the normalised/full stageId
1256
+ from: fromPhase,
1257
+ to: targetPhase,
1258
+ session_id: sessionId, // always present
1259
+ ...(resumed === null ? {} : { resumed }), // only when decidable
1260
+ ...(opts?.reviewTier === undefined ? {} : { review_tier: opts.reviewTier }), // ONLY when explicitly declared
1261
+ }
1262
+ ```
1263
+
1264
+ `review_tier` and `resumed` are **conditional**: omitting them keeps legacy entries byte-identical.
1265
+ `resumed` is only computed when the session id starts with `env:` or `project:`; `proc:` ids and
1266
+ entries without a trustworthy `session_id` yield `null` (field omitted, never guessed).
1267
+ - `reset_stage_phase` detail: `{ stageName, from, to, session_id, ...(resumed ? {resumed} : {}) }` —
1268
+ no `review_tier`.
1269
+ - `agent` = the `triggeredBy` argument, default `'flow-manager'`; the CLI passes `'cli'`. The
1270
+ hard-coded `'flow-manager'` must not be used for CLI-driven entries.
1271
+
1272
+ ### 10.4 Checkpoints
1273
+
1274
+ - Directory: `.openfeel/checkpoints/`; filename `{stageId}-{yyyyMMddTHHmmss}-{phase}.json`.
1275
+ - Timestamp format `yyyyMMdd'T'HHmmss` (local time, via `formatCheckpointTimestamp`).
1276
+ - Retention: `CHECKPOINT_MAX_KEEP = 20` **per stageId**; oldest removed past the cap.
1277
+ - Serialised with the same `op.id` stripping as `save()`; written atomically **without** the global
1278
+ lock (avoids putting a long serialisation in the critical section).
1279
+ - `listCheckpoints` sorts ascending (lexicographic on the timestamped filename) and matches both the
1280
+ normalised key prefix and the raw short-name prefix (legacy snapshots).
1281
+ - Snapshots are **not** written by `resetStagePhase`.
1282
+
1283
+ ### 10.5 Log skeletons
1284
+
1285
+ `SKELETON_PHASES = ['exec_running', 'review_pending', 'test_pending', 'archiving']` — entering any of
1286
+ these phases auto-creates the stage's log file skeleton (`createLogSkeleton`). The list is exactly
1287
+ these four; `review_failed` / `review_passed` / `test_failed` / `test_passed` / `scheme_*` /
1288
+ `plan_*` do not trigger it.
1289
+
1290
+ ### 10.6 `pipeline.current` single ownership and `pipeline.retry`
1291
+
1292
+ - `syncCurrentOp(stageName)` is the **only** function allowed to set `pipeline.current` during
1293
+ transitions. It points at the **first** op (in `stages[key].ops` insertion order) whose state is
1294
+ `pending` or `executing`, else sets `op` to `''` — it **never** leaves a stale op pointer.
1295
+ It performs no I/O and does not touch `pipeline.phase` / `pipeline.retry`.
1296
+ - `pipeline.retry` is **op-level retry accounting**, not a phase-transition backoff:
1297
+ - `recordAttempt(pass)` → `attempts += 1`, `state = 'done'`, `attempted_at = completed_at = now`,
1298
+ `pipeline.retry = 0`.
1299
+ - `recordAttempt(fail)` with `attempts < max_attempts` → `state = 'pending'`, `attempted_at = now`,
1300
+ `completed_at` untouched, `pipeline.retry += 1`, returns `{shouldRetry: true, shouldReplan: false}`.
1301
+ - `recordAttempt(fail)` with `attempts >= max_attempts` → `state = 'failed'`, both timestamps set,
1302
+ `pipeline.retry += 1`, returns `{shouldRetry: false, shouldReplan: true}`.
1303
+ - Legacy `advancePhase(opId, ...)` resets `pipeline.retry = 0` when the current stage/op pointer
1304
+ changes.
1305
+ - `flow attempt --result fail` with `shouldReplan === true` auto-advances to `scheme_pending`
1306
+ (`mgr.advancePhase(options.op, 'scheme_pending')` then `save()`), regardless of workflow mode —
1307
+ this is a known hard-coded full-mode target inside the fast-mode code path.
1308
+ - **No automatic phase-level backoff exists**: `review_failed` / `test_failed` looping is purely the
1309
+ transition table's outbound edges (§2.1, §3.3). Nothing counts how many times a stage has entered
1310
+ `review_failed`, and nothing expires those edges.
1311
+
1312
+ ### 10.7 Transition legality lives in the command layer
1313
+
1314
+ `advanceStagePhase` validates only the **phase enum** (plus the `done` REV gate). It **never**
1315
+ consults the transition table. All table-based legality is enforced by the command layer through
1316
+ `findPhasePath` (§6.3 gate 8–12), which is **skipped entirely by `--force`**. Therefore:
1317
+
1318
+ - `flow stage reset --to <any valid phase>` can jump anywhere in either direction.
1319
+ - `flow advance --force --to <any valid phase>` can jump anywhere.
1320
+ - Only these two paths consult the table: `flow advance` (without `--force`) and the read-only
1321
+ queries (`flow phases`, `flow mode`, `flow status --verbose`, `flow wizard`).
1322
+
1323
+ `findPhasePath` = BFS over `getValidTargets`, depth cap `PHASE_PATH_MAX_DEPTH = 8`:
1324
+
1325
+ - start === target → `{ path: [], reason: 'already-at-target' }`
1326
+ - multiple shortest-depth paths → `{ path: [], reason: 'ambiguous' }` (**refuse, never guess**)
1327
+ - depth exhausted → `{ path: [], reason: 'depth-exceeded' }`
1328
+ - exhausted queue → `{ path: [], reason: 'no-path' }`
1329
+ - unique shortest path → `{ path: [...targets excluding start], reason: 'ok' }`
1330
+ - Pure read-only: no disk write, no memory mutation. Mode is resolved **once** outside the BFS (same
1331
+ table for the whole search).
1332
+
1333
+ ### 10.8 Tier and table-purity invariants
1334
+
1335
+ - `pruneTransitionsByTier` must return **new objects and new arrays**; `FAST_TRANSITIONS` and
1336
+ `pipelineConfig` must never be mutated. `getPipelineTransitions` and `getDefaultTransitions` return
1337
+ fresh deep-ish copies for the same reason.
1338
+ - `getPipelinePhases()` returns a **copy** of `pipelineConfig.phases`.
1339
+ - The 15-value `PipelinePhase` domain is never modified by pruning, by mode, or by config.
1340
+ - `resolveTransitionsForMode` is the **single point** of table selection (validation, advance, path
1341
+ finding and display all go through it); `pruneTransitionsByTier` is the **single point** of pruning.
1342
+ A reimplementation must not add a second ordering or a second pruning spectrum.
1343
+
1344
+ ### 10.9 Reading/parsing invariants
1345
+
1346
+ - `flow.json` load: `stage.ops ??= {}`, `stage.deps ??= []`, and `op.id = opKey` for every op (guarded
1347
+ by `typeof === 'object' && !Array.isArray`). A parse failure yields `data = null` and
1348
+ `loadedRevision = 0` — it must **not** throw.
1349
+ - `pipeline.yaml` load: absent file → built-in full defaults; any `PipelineConfigSchema.parse` failure
1350
+ → built-in full defaults (silent, whole-file fallback); `fast_transitions` is parsed **independently**
1351
+ and never fails the whole config.
1352
+ - `meta.archived_versions` is **not** backfilled on load; readers derive `?? []`.
1353
+ - `requirements` is **not** backfilled on load; `getRequirements()` derives `?? []`.
1354
+ - `validate()` **mutates** in-memory data while reporting (`stage.phase = 'plan_pending'`, fuzzy
1355
+ corrections, `pipeline.phase` correction) — these are warnings, not errors, and they are not saved
1356
+ by `validate()` itself.
1357
+ - `fuzzyCorrectPhase` precedence: `phase_corrections` exact map → normalise
1358
+ (`trim().toLowerCase().replace(/[\s_-]+/g, '_').replace(/^_|_$/g, '')`) → exact enum →
1359
+ unique **prefix** (`p.startsWith(normalized + '_')`) → underscore-stripped equality → unique
1360
+ **contains** → unique **suffix** (`p.endsWith('_' + normalized) || p.endsWith(normalized)`) →
1361
+ hard-coded `extraCorrections` map → `null`. Multi-hit prefix/contains/suffix all return `null`
1362
+ (they do **not** pick the first). `extraCorrections` (after normalisation) includes
1363
+ `planned→plan_pending`, `planning→plan_pending`, `plan_done→plan_passed`, `plans_passed→plan_passed`,
1364
+ `scheme_planned→scheme_pending`, `scheme_done→scheme_passed`, `exec→exec_running`,
1365
+ `exec_pending→scheme_passed`, `running→exec_running`, `review→review_pending`,
1366
+ `review_done→review_passed`, `reviews_passed→review_passed`, `testing→test_pending`,
1367
+ `test→test_pending`, `test_done→test_passed`, `archive→archiving`, `archived→done`,
1368
+ `finished→done`, `completed→done`, `complete→done`, `end→done`.
1369
+
1370
+ ---
1371
+
1372
+ ## 11. CLI read-only vs writing surface (pipeline area)
1373
+
1374
+ ### 11.1 `flow phases` — read-only
1375
+
1376
+ Options: `--json`, `--mode <full|fast|general>`, `--stage <id>`.
1377
+
1378
+ Evaluation order: ① `--mode` domain check against `getConfigFieldLegalValues('workflow_mode')`
1379
+ (illegal → `字段 工作流模式 的取值非法:{value}(允许:full, fast, general)`, exit 1); ② `--stage`
1380
+ format check then existence check (`known = flow.json stage keys ∪ findStageStatusPath(...)`, both
1381
+ missing → stage-id error + suggestion, exit 1); ③ mode derivation =
1382
+ `explicitMode ?? mgr.resolveStageWorkflowMode(stageArg).value`; ④ table = `getPipelineTransitions(derivedMode)`
1383
+ (tier defaults to `full`); ⑤ diff vs `getDefaultTransitions(derivedMode)`.
1384
+
1385
+ **Human-readable output, exact sequence:**
1386
+
1387
+ ```
1388
+ OpenFeel 流水线 phase 自描述
1389
+ 合法 phase 列表: {N}
1390
+ - {phase}
1391
+ ...
1392
+ (blank)
1393
+ 当前模式:{mode} [来源: {source | --mode}]
1394
+ 提示:查看模式来源与切换:openfeel flow mode [--stage <id>]
1395
+ 审查档位:{tier}(模式派生自 {mode})
1396
+ 提示:审查档位仅裁剪审查关(全量 / 精简 / 免审);显式声明:openfeel flow advance --review-tier <tier>
1397
+ phase 流转映射(from → [to...]):
1398
+ {key} → [{t1, t2}]
1399
+ ...
1400
+ (blank)
1401
+ [检测到运行时转移表与内置默认存在差异:{detail}] ← only when a diff exists
1402
+ [提示:运行时 pipeline.yaml 含内置 15 个 phase 之外的 phase({phases});…] ← only when custom phases exist
1403
+ 提示:`openfeel flow advance --stage <id> --to <phase>` 推进
1404
+ ```
1405
+
1406
+ Diff `detail` = `; `-joined fragments:
1407
+ `缺失键 {keys}` / `新增键 {keys}` / `目标集不同 {keys}`.
1408
+
1409
+ Diff computation (`transitionsDiff`):
1410
+
1411
+ ```ts
1412
+ const missingKeys = Object.keys(defaults).filter((k) => !(k in transitions));
1413
+ const extraKeys = Object.keys(transitions).filter((k) => !(k in defaults));
1414
+ const changedKeys = Object.keys(defaults).filter(
1415
+ (k) => k in transitions && JSON.stringify(defaults[k]) !== JSON.stringify(transitions[k]),
1416
+ );
1417
+ const transitionsDiff = { missing: missingKeys, extra: extraKeys, changed: changedKeys };
1418
+ ```
1419
+
1420
+ Array comparison is `JSON.stringify` ⇒ **order-sensitive** (`['a','b']` ≠ `['b','a']` → `changed`).
1421
+
1422
+ ### 11.2 `flow phases --json` — exactly 7 keys, in this order
1423
+
1424
+ ```ts
1425
+ console.log(JSON.stringify({ schemaVersion: 1, phases, transitions, advanceAccepted: [...PIPELINE_PHASES], transitionsDiff, workflowMode: derivedMode, reviewTier: MODE_DERIVED_TIER[derivedMode] }, null, 2));
1426
+ ```
1427
+
1428
+ | # | Key | Value |
1429
+ | --- | --- | --- |
1430
+ | 1 | `schemaVersion` | `1` |
1431
+ | 2 | `phases` | `mgr.getPipelinePhases()` — **runtime** `pipeline.yaml` phases (may include custom phases) |
1432
+ | 3 | `transitions` | `getPipelineTransitions(derivedMode)` — unpruned (tier = `full`) |
1433
+ | 4 | `advanceAccepted` | `[...PIPELINE_PHASES]` — the **built-in 15**, i.e. `flow advance`'s whitelist |
1434
+ | 5 | `transitionsDiff` | `{ missing: string[], extra: string[], changed: string[] }` |
1435
+ | 6 | `workflowMode` | the mode this display is based on |
1436
+ | 7 | `reviewTier` | `MODE_DERIVED_TIER[workflowMode]` — **display only**, never the effective advance tier |
1437
+
1438
+ `phases` vs `advanceAccepted` are deliberately distinct: `phases` is the runtime existence view
1439
+ (custom phases allowed); `advanceAccepted` is the fixed 15-phase whitelist. Custom phases are shown
1440
+ but rejected by `flow advance` (the `flow.phases.customPhaseNote` hint says so).
1441
+
1442
+ ### 11.3 `flow mode` — read-only
1443
+
1444
+ Options: `--stage <id>`, `--json`.
1445
+
1446
+ `--json` payload: `{ schemaVersion: 1, stage, mode, source, modes: { full, fast, general } }` where
1447
+ each `modes[x]` = `getPipelineTransitions(x)` (unpruned) and `stage` is `''` when unset. No
1448
+ `isLoaded()` guard on the JSON path — it stays a single pure JSON document even when uninitialised.
1449
+ The human-readable path prints `流水线未初始化(flow.json 不存在)` (`common.noInit`) when `!isLoaded()`.
1450
+ No `save()`, no `appendLog`, no file writes at all.
1451
+
1452
+ Human lines:
1453
+
1454
+ ```
1455
+ 当前模式:{mode} [来源: {source}]
1456
+ 可达目标({stage} · {phase}):{targets} ← only when stage && phase known
1457
+ ```
1458
+
1459
+ ### 11.4 `flow advance` — writing
1460
+
1461
+ See §6.3/§6.5. Side effects, in order: optional `autoRepairInconsistency` + `save()`; per step
1462
+ `advanceStagePhase(...)` → `save()` → optional `autoCommitOnDone`; optional `MetricsStore.save()`
1463
+ once at the end; optional dirty-git warning box when `--to done || archivedAny` and not `--quiet`.
1464
+
1465
+ - `--quiet` silences non-error output (success line and git box), never errors.
1466
+ - Multi-step (path length > 1) prints `已推进:{stage} {from} → {to}` per step; a single step prints
1467
+ only the final `✓ 已推进: {stage} → {to}`.
1468
+ - Metrics: one `recordRun` per exited phase using `PHASE_AGENT_MAP[exitedPhase]`; skipped (with a
1469
+ `runtimeLog('debug', ...)` trace) when no baseline timestamp is found — a `0` value is never
1470
+ recorded.
1471
+
1472
+ ### 11.5 `flow stage reset` — writing
1473
+
1474
+ `flow stage reset <stageId> --to <phase> [--dry-run]`. Writes one `reset_stage_phase` audit entry,
1475
+ `save()`, and **nothing else** (no checkpoint, no archive commit).
1476
+
1477
+ ```
1478
+ ─── 复位预览(未写盘)───
1479
+ {stage}: {from} → {to}(status → {status})
1480
+ ```
1481
+ ```
1482
+ ✓ 已复位阶段 {stage}: {from} → {to}
1483
+ ```
1484
+ ```
1485
+ 阶段 {stage} 已处于 {phase},无需复位
1486
+ ```
1487
+
1488
+ Errors surface as `错误:{msg}` (`common.errorTmpl`) → exit 1; `isFlowConcurrentError` → exit 2.
1489
+
1490
+ ### 11.6 `flow wizard` — writing, TTY only
1491
+
1492
+ - Non-TTY: `flow.wizard.nonTtyHint` = `当前环境非交互式;请改用 flow advance 等非交互命令` to stderr,
1493
+ `process.exitCode = 1`, no interaction.
1494
+ - Interactive; calls `getAvailablePhases(currentStage)` with the **default tier `full`** — the wizard
1495
+ never offers a tier choice, and `general` is not offered as a workflow mode (choices are only
1496
+ `full` / `fast`).
1497
+ - First-run mode selection: asks once when the stage has no `工作流模式` field **and** the project has
1498
+ no explicit `defaults.workflow_mode`; writes the stage field into `status.md`
1499
+ (`insertStatusField`, `no-anchor` → warning, not fatal) **and** remembers it via
1500
+ `setConfigValue(cwd, 'workflow_mode', chosen)` (incremental write, comments preserved).
1501
+ - Advances via `advanceStagePhase(currentStage, targetPhase, 'cli')` then `save()` then
1502
+ `autoCommitOnDone` when it returned `true`.
1503
+
1504
+ ---
1505
+
1506
+ ## 12. Quick-reference constants
1507
+
1508
+ | Constant | Value |
1509
+ | --- | --- |
1510
+ | `PIPELINE_PHASES.length` | `15` |
1511
+ | `META_PHASES` | `['active','paused','done']` |
1512
+ | `REVIEW_TIERS` | `['full','lean','skip']` |
1513
+ | `TIER_PRUNED_PHASES` | `{ full: [], lean: ['scheme_review'], skip: ['plan_review','scheme_review'] }` |
1514
+ | `MODE_DERIVED_TIER` | `{ full: 'full', fast: 'lean', general: 'full' }` |
1515
+ | `TRANSITION_OR_SEPARATOR` | `'\|'` |
1516
+ | `FAST_TRANSITIONS` entry count | `9` (8 flow keys + `done`) |
1517
+ | Built-in FULL transition count | `16` (15 phase keys + 1 OR-key) |
1518
+ | Shipped `pipeline.yaml` transition count | `15` (no OR-key) |
1519
+ | `PHASE_PATH_MAX_DEPTH` | `8` |
1520
+ | `CHECKPOINT_MAX_KEEP` | `20` |
1521
+ | `STAGE_STATUS_VALUES` | `['planned','review_failed','review_passed','testing','archiving','done']` |
1522
+ | `EFFECTIVE_CONFIG_KEYS` | `['execution_mode','auto_advance','merge_mode','workflow_mode']` |
1523
+ | `DEFAULT_CONFIG` | `{ execution_mode:'manual', auto_advance:'enabled', merge_mode:'manual', workflow_mode:'fast' }` |
1524
+ | `DEFAULT_SERIES` / `DEFAULT_STAGE_VERSION` | `'v1'` / `'v1.0.0'` |
1525
+ | `SKELETON_PHASES` | `['exec_running','review_pending','test_pending','archiving']` |
1526
+ | `flow phases --json` key count | `7` |
1527
+
1528
+ ---
1529
+
1530
+ ## 13. Top reimplementation hazards (summary)
1531
+
1532
+ 1. **Mode-derived tier ≠ effective advance tier.** `fast` derives `lean` for *display*, but
1533
+ `flow advance` without `--review-tier` uses **`full`** (zero pruning). Wiring the derived tier into
1534
+ advance silently changes behavior.
1535
+ 2. **Pruning acts on inbound edges only.** A pruned phase keeps its outbound edges, so a stage
1536
+ already sitting in `plan_review` can still advance. Deleting the key would be wrong.
1537
+ 3. **Edge contraction is mandatory**, including the `!keyConds.includes(cascaded)` self-loop guard
1538
+ (`fast` + `skip` ⇒ `plan_pending → ['plan_passed']`). Filtering without contraction strands
1539
+ `plan_pending`.
1540
+ 4. **`test_passed` maps to `testing`, not `done`.** Mapping it to `done` lets
1541
+ `autoRepairInconsistency` rewrite the phase and truncate `test_passed → archiving`.
1542
+ 5. **The built-in full table has an extra OR-key the shipped `pipeline.yaml` lacks**
1543
+ (`'review_passed|test_passed'`). All matching keys contribute (union), so with the built-in table
1544
+ `review_passed` has two targets, with the runtime table only one. `transitionsDiff.missing`
1545
+ exposes the difference.
1546
+ 6. **`transitionsDiff.changed` is order-sensitive** (`JSON.stringify` comparison).
1547
+ 7. **No op-state gate on advance.** Advancing never requires ops to be `done`; the only op-count gate
1548
+ is `flow stage remove` (`checkRemovable`).
1549
+ 8. **`blocking !== false` means blocking.** Undefined must be treated as blocking, and `--force` can
1550
+ never bypass the `done` gate.
1551
+ 9. **`fast_transitions: {}` means "use the built-in fast table"**, not "empty pipeline". Only
1552
+ `mode === 'general'` expresses an empty table.
1553
+ 10. **`general` is a stage-level illegal value** (rejected by `stage set`, `plan stage add`,
1554
+ `config set`) but reachable via requirement `workflow_mode`; `--force` on `flow advance` does
1555
+ move a general-mode stage.
1556
+ 11. **A pruned/empty table yields `no-path`, and a general table additionally triggers the dedicated
1557
+ `generalNoPipeline` message** — the ordering of that branch matters.
1558
+ 12. **`isArchivedVersionOf` is always `false` for short names** and requires the *original* input.
1559
+ 13. **`save()` writes the `.bak` unconditionally and bumps revision only after the atomic write
1560
+ succeeds**; the core never saves.
1561
+ 14. **`op.id` must be stripped on every serialisation** (`save()` and `saveCheckpoint`), and
1562
+ `review_tier` / `resumed` log keys must be omitted unless explicitly present.
1563
+ 15. **The `done` git commit must happen after `save()`**, driven by the `true` return of
1564
+ `advanceStagePhase`.
1565
+ 16. **`flow phases` shows the unpruned table even when it reports `reviewTier: "lean"`.**