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.
- package/README.en.md +40 -74
- package/README.md +39 -62
- package/README.zh-CN.md +40 -74
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +90 -5
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +7 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/core/config.js +2 -2
- package/dist/core/dsh-profile.d.ts +93 -0
- package/dist/core/dsh-profile.d.ts.map +1 -0
- package/dist/core/dsh-profile.js +400 -0
- package/dist/core/dsh-profile.js.map +1 -0
- package/dist/core/i18n-data/en.d.ts.map +1 -1
- package/dist/core/i18n-data/en.js +21 -2
- package/dist/core/i18n-data/en.js.map +1 -1
- package/dist/core/i18n-data/zh-CN.d.ts.map +1 -1
- package/dist/core/i18n-data/zh-CN.js +21 -2
- package/dist/core/i18n-data/zh-CN.js.map +1 -1
- package/dist/core/setup.d.ts +12 -1
- package/dist/core/setup.d.ts.map +1 -1
- package/dist/core/setup.js +25 -10
- package/dist/core/setup.js.map +1 -1
- package/dist/core/template-loader.d.ts.map +1 -1
- package/dist/core/template-loader.js +18 -9
- package/dist/core/template-loader.js.map +1 -1
- package/dist/core/update.d.ts +2 -0
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +25 -7
- package/dist/core/update.js.map +1 -1
- package/dsh/ACTION-GUIDE.md +366 -0
- package/dsh/ADAPTER.md +65 -0
- package/dsh/README.md +264 -0
- package/dsh/cordis.patch.yml +26 -0
- package/dsh/docs/compat/commands.md +873 -0
- package/dsh/docs/compat/kb-skills.md +883 -0
- package/dsh/docs/compat/persistence.md +1013 -0
- package/dsh/docs/compat/pipeline.md +1565 -0
- package/dsh/docs/compat/req.md +1243 -0
- package/dsh/docs/compat/stage.md +1437 -0
- package/dsh/lib/adapters/cli.js +624 -0
- package/dsh/lib/adapters/dashboard.js +393 -0
- package/dsh/lib/adapters/workspace.js +150 -0
- package/dsh/lib/index.js +189 -0
- package/dsh/lib/surface/command.js +186 -0
- package/dsh/lib/surface/prompt.js +131 -0
- package/dsh/lib/tools/index.js +1245 -0
- package/dsh/lib/tools/shared.js +320 -0
- package/dsh/package.json +45 -0
- 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"`.**
|