openfeel 1.2.3 → 1.3.0
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 -63
- package/README.md +39 -51
- package/README.zh-CN.md +40 -63
- package/dist/commands/archive.d.ts.map +1 -1
- package/dist/commands/archive.js +13 -2
- package/dist/commands/archive.js.map +1 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +38 -2
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/flow.d.ts.map +1 -1
- package/dist/commands/flow.js +45 -12
- package/dist/commands/flow.js.map +1 -1
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +78 -39
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/model.d.ts.map +1 -1
- package/dist/commands/model.js +86 -8
- package/dist/commands/model.js.map +1 -1
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/plan.js +21 -1
- package/dist/commands/plan.js.map +1 -1
- package/dist/commands/roadmap.d.ts.map +1 -1
- package/dist/commands/roadmap.js +21 -2
- package/dist/commands/roadmap.js.map +1 -1
- 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/archive/merge.d.ts +16 -1
- package/dist/core/archive/merge.d.ts.map +1 -1
- package/dist/core/archive/merge.js +27 -4
- package/dist/core/archive/merge.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 +398 -0
- package/dist/core/dsh-profile.js.map +1 -0
- package/dist/core/errors.d.ts +8 -3
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +6 -3
- package/dist/core/errors.js.map +1 -1
- package/dist/core/i18n-data/en.d.ts.map +1 -1
- package/dist/core/i18n-data/en.js +32 -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 +32 -2
- package/dist/core/i18n-data/zh-CN.js.map +1 -1
- package/dist/core/plan/roadmap.d.ts +29 -0
- package/dist/core/plan/roadmap.d.ts.map +1 -1
- package/dist/core/plan/roadmap.js +95 -0
- package/dist/core/plan/roadmap.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 +38 -19
- 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 +45 -17
- 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,1437 @@
|
|
|
1
|
+
# OpenFeel — Agent Workflow Layer: Stages and Ops (Behavioral Specification)
|
|
2
|
+
|
|
3
|
+
Exact behavioural specification of the **stage / op layer** of OpenFeel: the `flow.json` stage and op
|
|
4
|
+
records, the on-disk stage directory layout, the op state machine, retry & attempt accounting,
|
|
5
|
+
checkpoint snapshots, stage dependencies, orphan reconciliation, and the `stage` / `plan stage` /
|
|
6
|
+
`flow stage` command surfaces.
|
|
7
|
+
|
|
8
|
+
Topic boundaries — this document deliberately does **not** re-derive the following; see
|
|
9
|
+
[`pipeline.md`](./pipeline.md):
|
|
10
|
+
|
|
11
|
+
| Topic | Owner document |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| phase vocabulary, transition tables, review tiers, `--force` scope | `pipeline.md` §1–§6 |
|
|
14
|
+
| `mapPhaseToStageStatus` full table | `pipeline.md` §7 |
|
|
15
|
+
| config cascade precedence, `workflow_mode` resolution layers | `pipeline.md` §8 |
|
|
16
|
+
| `parseStageId` / `normalizeStageId` / `validateStageId` / `findStageStatusPath` / `isArchivedVersionOf` | `pipeline.md` §9 |
|
|
17
|
+
| `save()` revision bumping, `advanceStagePhase` side-effect ordering | `pipeline.md` §10 |
|
|
18
|
+
|
|
19
|
+
All identifiers, field names, constants, file templates and user-visible strings below are copied
|
|
20
|
+
verbatim from source. Chinese message text is the `zh-CN` i18n value.
|
|
21
|
+
|
|
22
|
+
**Source of truth (read-only analysis of `C:\Users\Liuary\Dev\Mine\AI\OpenFeel`):**
|
|
23
|
+
|
|
24
|
+
| File | Role |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `src/core/flow-manager.ts` | `Op`, `OpState`, `Checkpoints`, `StageData`, `FlowData`; `registerStage`, `addStage`, `removeStage`, `checkRemovable`, `resetStagePhase`, `recordAttempt`, `setStageDeps`, `saveCheckpoint`, `listCheckpoints`, `previewRestore`, `restoreCheckpoint`, `findOrphanOps`, `reconcileOrphans`, `repair`, `healthCheck`, `StageDirConflictError` |
|
|
27
|
+
| `src/core/plan/stage.ts` | `Stage`, `StageSkeletonOverrides`, `addStage`, `ensureStageSkeleton`, `listStages`, `appendStatusTask`, `buildTaskLines`, `sanitizeTaskDesc` |
|
|
28
|
+
| `src/core/plan/path.ts` | `parseStageId`, `normalizeStageId`, `stageIdToPlanDir`, `validateStageId`, `suggestStageId`, `findStageDirConflict`, `planDirToStageId`, `findStageStatusPath`, `isArchivedVersionOf`, `DEFAULT_SERIES`, `DEFAULT_STAGE_VERSION` |
|
|
29
|
+
| `src/core/plan/scheme.ts` | `Scheme`, `createScheme`, `createSchemesFromTasks`, `removeScheme`, `registerSchemes`, `publishScheme`, `renameScheme`, `getScheme`, `listSchemes`, `defaultOpForRegister`, op file template |
|
|
30
|
+
| `src/core/plan/roadmap.ts` | `createRoadmap`, `showRoadmap` (`.openfeel/roadmap/v{ver}.md`) |
|
|
31
|
+
| `src/core/flow-manager.ts` (`Op`/`OpState`) + `src/core/config.ts` | `WORKFLOW_MODES`, `DEFAULT_CONFIG`, `STAGE_FIELD_BY_CONFIG_KEY`, `getConfigFieldLegalValues` |
|
|
32
|
+
| `src/commands/stage.ts` | `stage status`, `stage set`, `stage task`, `stage create` |
|
|
33
|
+
| `src/commands/plan.ts` | `plan stage add`, `plan stage list`, `plan scheme *` |
|
|
34
|
+
| `src/commands/flow.ts` | `flow stage add/set/remove/reset`, `flow attempt`, `flow retry`, `flow ops list`, `flow checkpoint list/restore`, `flow repair` |
|
|
35
|
+
| `src/core/artifact-graph/*`, `src/core/schema.ts` | `Artifact`, `Schema`, `ArtifactGraph` — the **separate** artifact DAG (see §12) |
|
|
36
|
+
| `.openfeel/flow.json`, `.openfeel/plan/**`, `.openfeel/checkpoints/**` | real on-disk data used for every example |
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 0. Two independent models — do not conflate
|
|
41
|
+
|
|
42
|
+
A reimplementation must keep these two models separate; they share vocabulary but no code path.
|
|
43
|
+
|
|
44
|
+
| | **Stage/Op model** (this document) | **Artifact model** (§12) |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| Persistence | `.openfeel/flow.json` + `plan/{series}/{stage}/` markdown | YAML `schema.yaml` files (`.openfeel/schemas/<name>/`) |
|
|
47
|
+
| Unit | stage (`v1.2.1-stage-92`), op (`op-001`) | artifact (arbitrary `id`) |
|
|
48
|
+
| Dependency | `stages[].deps` (stage→stage, strings) + `ops/deps.yaml` (op→op, human/AI-read only) | `Artifact.dependsOn` / `Artifact.requires` (machine-enforced DAG) |
|
|
49
|
+
| Topology | **no** ordering enforcement, **no** cycle detection for stage deps | Kahn topological sort, cycle → throw |
|
|
50
|
+
| Consumed by | `flow`, `plan`, `stage` commands, agents, health | `ArtifactGraph` class only (no CLI command in this repo) |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 1. Stage record schema — `flow.json` `stages[<fullStageId>]`
|
|
55
|
+
|
|
56
|
+
### 1.1 TypeScript
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
export interface StageData {
|
|
60
|
+
name: string;
|
|
61
|
+
phase: PipelinePhase; // 15-value enum, see pipeline.md §1.1
|
|
62
|
+
status: string; // coarse projection, see pipeline.md §7
|
|
63
|
+
deps: string[];
|
|
64
|
+
ops: Record<string, Op>; // key = LOCAL op id ("op-001")
|
|
65
|
+
stats?: StageStats; // optional, runtime-filled
|
|
66
|
+
workflow_mode?: WorkflowMode; // optional redundant copy (never authoritative)
|
|
67
|
+
requirement_id?: string | null; // optional; absent ≡ null
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
export interface StageStats { start_time: string; end_time: string; duration_ms: number }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 1.2 Field table
|
|
76
|
+
|
|
77
|
+
| Field | Type | Required | Written at creation | Written later by | Notes |
|
|
78
|
+
| --- | --- | --- | --- | --- | --- |
|
|
79
|
+
| `name` | `string` | yes | always | never | **Always identical to the object key** (the full stage id). Redundant by design. |
|
|
80
|
+
| `phase` | `PipelinePhase` | yes | `"plan_pending"` (all creation paths) | `advanceStagePhase`, `resetStagePhase`, `repair` | Sole authority; `status` is derived from it. |
|
|
81
|
+
| `status` | `string` | yes | `"planned"` | `advanceStagePhase` / `resetStagePhase` via `mapPhaseToStageStatus` | Writable set is `STAGE_STATUS_VALUES = ['planned','review_failed','review_passed','testing','archiving','done']` (used only by `stage set --status`). |
|
|
82
|
+
| `deps` | `string[]` | yes | `[]`, or `--deps` for `plan stage add` | `setStageDeps` (`flow stage set --deps`) | Fully overwritten (never merged). See §7. |
|
|
83
|
+
| `ops` | `Record<string, Op>` | yes | `{}` | `createScheme` / `registerSchemes` / implicit register / `removeScheme` / `repair --prune-orphans` | Read-side repaired to `{}` if missing (§1.5). |
|
|
84
|
+
| `stats` | `StageStats` | no | absent | `startStage` (first phase change / first `advanceStagePhase`), `endStage` (transition into `done`) | In-memory created as `{start_time:'', end_time:'', duration_ms:0}`. |
|
|
85
|
+
| `workflow_mode` | `WorkflowMode` | no | only by `plan stage add` (i.e. `addStage` in `plan/stage.ts`), and only when the stage did **not** already exist | `stage set --workflow-mode`, `config set workflow_mode --sync-stages` | **Redundant, never authoritative.** Authoritative read is the cascade `resolveStageWorkflowMode`. `healthCheck` item 10 warns on mismatch, never fails, never auto-fixes. |
|
|
86
|
+
| `requirement_id` | `string \| null` | no | absent | requirement commands | Absent ≡ `null`; readers must not inject. |
|
|
87
|
+
|
|
88
|
+
**Key rule:** the object key is the **full** stage id (`v1.0.0-stage-01`, `v4-stage-04`, …). Every core
|
|
89
|
+
lookup does "normalize, then double-key fallback": try `normalizeStageId(input)`, and if that key is
|
|
90
|
+
absent, retry the raw input (compat with legacy short-name keys). See `pipeline.md` §9.2.
|
|
91
|
+
|
|
92
|
+
### 1.3 Real record (verbatim from `.openfeel/flow.json`)
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
"v1.2.1-stage-92": {
|
|
96
|
+
"name": "v1.2.1-stage-92",
|
|
97
|
+
"phase": "done",
|
|
98
|
+
"status": "done",
|
|
99
|
+
"deps": ["v1.2.1-stage-86", "v1.2.1-stage-90", "v1.2.1-stage-91"],
|
|
100
|
+
"ops": {},
|
|
101
|
+
"workflow_mode": "full",
|
|
102
|
+
"stats": {
|
|
103
|
+
"start_time": "2026-10-15T02:49:08.280Z",
|
|
104
|
+
"end_time": "2026-10-15T04:47:42.076Z",
|
|
105
|
+
"duration_ms": 7113796
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Key order produced by `registerStage` then the `workflow_mode` write in `addStage`:
|
|
111
|
+
`name, phase, status, deps, ops, workflow_mode`; `stats` is appended on first phase change
|
|
112
|
+
(`startStage` mutates the existing object, so it lands last only because it is created later).
|
|
113
|
+
|
|
114
|
+
### 1.4 Creation defaults per path
|
|
115
|
+
|
|
116
|
+
| Creation path | `phase` | `status` | `deps` | `ops` | `workflow_mode` | `pipeline.current` |
|
|
117
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
118
|
+
| `FlowManager.registerStage(id, deps)` (used by `plan stage add`) | `plan_pending` | `planned` | arg (`[]` default) | `{}` | not written by `registerStage`; `addStage` (plan/stage.ts) sets it only for a brand-new key | untouched |
|
|
119
|
+
| `FlowManager.addStage(id, initialPhase='plan_pending')` (used by `flow stage add`, `stage create`) | arg | `planned` | `[]` | `{}` | not written | **`{ stage: id, op: '' }`** |
|
|
120
|
+
| `createScheme` implicit register (stage absent from `flow.json`) | `plan_pending` | `planned` | `[]` | `{}` then the new op | not written | untouched |
|
|
121
|
+
| `repair()` fill-in | `plan_pending` if `phase` missing | untouched | untouched | `{}` if missing/invalid | untouched | untouched |
|
|
122
|
+
|
|
123
|
+
Both `registerStage` and `addStage` are **idempotent for an existing key**: `registerStage` returns
|
|
124
|
+
silently (it does **not** update `deps`); `addStage` throws `Stage 'x' already exists`.
|
|
125
|
+
|
|
126
|
+
### 1.5 Read-side repair (`load()`)
|
|
127
|
+
|
|
128
|
+
`FlowManager.load()` mutates the parsed object before any consumer sees it:
|
|
129
|
+
|
|
130
|
+
- missing/nil `stage.ops` → `{}`
|
|
131
|
+
- missing/nil `stage.deps` → `[]`
|
|
132
|
+
- `op.id = <key>` injected into every op (**runtime-only convenience field**; not a disk field)
|
|
133
|
+
|
|
134
|
+
`save()` strips `id` from a deep clone before serialising, so it never reaches disk.
|
|
135
|
+
|
|
136
|
+
### 1.6 `save()` invariants (see `pipeline.md` §10.1 for the full ordering)
|
|
137
|
+
|
|
138
|
+
- `meta.updated = new Date().toISOString()`
|
|
139
|
+
- `meta.revision` incremented by exactly 1 under `flow.lock`
|
|
140
|
+
- optimistic concurrency: if the on-disk `revision` ≠ the loaded baseline, throw
|
|
141
|
+
`FlowConcurrentModificationError` (`flow.json 并发冲突:期望 revision=…,磁盘为 …(已被其它进程修改)`); the command layer maps this to **exit code 2**
|
|
142
|
+
- old file copied to `flow.json.bak` before the atomic write
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 2. Op record schema — `stages[<stage>].ops[<localOpId>]`
|
|
147
|
+
|
|
148
|
+
### 2.1 TypeScript
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
export type OpState = 'pending' | 'executing' | 'done' | 'failed' | 'draft';
|
|
152
|
+
|
|
153
|
+
export interface Checkpoints {
|
|
154
|
+
plan: string;
|
|
155
|
+
scheme: string;
|
|
156
|
+
exec: { attempts: number; self: string };
|
|
157
|
+
review: string;
|
|
158
|
+
test: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface Op {
|
|
162
|
+
id: string; // RUNTIME ONLY — equals the ops-map key; stripped by save()
|
|
163
|
+
title: string;
|
|
164
|
+
state: OpState;
|
|
165
|
+
assignee: string;
|
|
166
|
+
attempts: number;
|
|
167
|
+
max_attempts: number;
|
|
168
|
+
checkpoints: Checkpoints;
|
|
169
|
+
attempted_at?: string; // ISO; optional — legacy ops lack it; readers must not inject
|
|
170
|
+
completed_at?: string; // ISO; optional — set only on entry to a terminal state
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### 2.2 Field table
|
|
175
|
+
|
|
176
|
+
| Field | Type | Required on disk | Default at creation | Mutated by | Notes |
|
|
177
|
+
| --- | --- | --- | --- | --- | --- |
|
|
178
|
+
| *(map key)* | `string` | yes | `op-NNN` (3-digit zero-padded) | — | **Local** id, no stage prefix. Global reference form is `{stageId}.{op}`. |
|
|
179
|
+
| `id` | `string` | **NO** | set in memory from the key | `load()`/`getReadyOps()` | Never persisted. A reimplementation that writes it produces a diff-visible behaviour change. |
|
|
180
|
+
| `title` | `string` | yes | the `title` argument of `plan scheme create` (or `任务N:…` for `--from-tasks`) | `plan scheme rename --title` | Authority for `plan scheme list` is the file's first line `# op-NNN:{title}`; `flow.json.title` is a copy kept in sync by `rename()`. |
|
|
181
|
+
| `state` | `OpState` | yes | `pending` (or `draft` with `--draft`) | `recordAttempt`, `publishScheme` | See §4. |
|
|
182
|
+
| `assignee` | `string` | yes | `openfeel-executor` | **nothing** (write-once) | Present for schema compatibility; no code path ever updates it. |
|
|
183
|
+
| `attempts` | `number` | yes | `0` | `recordAttempt` only (`+= 1`) | **Never reset by any command.** |
|
|
184
|
+
| `max_attempts` | `number` | yes | `3` | **nothing** (no CLI flag) | Compared with `attempts` inside `recordAttempt`. |
|
|
185
|
+
| `checkpoints` | `Checkpoints` | yes | all-`pending` object (§5.1) | legacy `advancePhase` only | Mixed-type: 4 strings + `exec: {attempts, self}`. |
|
|
186
|
+
| `attempted_at` | ISO string | no | absent | `recordAttempt` (all three outcomes) | Set on **every** recorded attempt. |
|
|
187
|
+
| `completed_at` | ISO string | no | absent | `recordAttempt` on `pass` and on exhaustion only | Same instant as `attempted_at` for those two branches. |
|
|
188
|
+
|
|
189
|
+
### 2.3 Disk shape — real records from `.openfeel/flow.json` (`v1.2.0-stage-80`)
|
|
190
|
+
|
|
191
|
+
```json
|
|
192
|
+
"op-001": {
|
|
193
|
+
"title": "跨版本 deps 判定适配(单一口径 helper + 命令层两处校验点)(T1)",
|
|
194
|
+
"state": "pending",
|
|
195
|
+
"assignee": "openfeel-executor",
|
|
196
|
+
"attempts": 0,
|
|
197
|
+
"max_attempts": 3,
|
|
198
|
+
"checkpoints": {
|
|
199
|
+
"plan": "pending",
|
|
200
|
+
"scheme": "pending",
|
|
201
|
+
"exec": { "attempts": 0, "self": "pending" },
|
|
202
|
+
"review": "pending",
|
|
203
|
+
"test": "pending"
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Key order is exactly `title, state, assignee, attempts, max_attempts, checkpoints` (the object literal
|
|
209
|
+
in `syncToFlowJson` / `defaultOpForRegister` starts with `id`, which `save()` deletes).
|
|
210
|
+
|
|
211
|
+
### 2.4 Creation defaults — all three creators are byte-identical
|
|
212
|
+
|
|
213
|
+
`createScheme → syncToFlowJson`, `defaultOpForRegister` (`plan scheme register`), and the `load()`
|
|
214
|
+
repair path all produce the same literal:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
{
|
|
218
|
+
id: opId, title,
|
|
219
|
+
state: draft ? 'draft' : 'pending',
|
|
220
|
+
assignee: 'openfeel-executor',
|
|
221
|
+
attempts: 0, max_attempts: 3,
|
|
222
|
+
checkpoints: { plan:'pending', scheme:'pending', exec:{attempts:0,self:'pending'}, review:'pending', test:'pending' }
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`attempted_at` / `completed_at` are **omitted at creation** (legacy-compat: readers tolerate absence).
|
|
227
|
+
`exec.attempts` is initialised to `0` and **never incremented by any code path in the codebase** — it is
|
|
228
|
+
a reserved field. A conforming reimplementation must preserve it (default `0`, no writes) rather than
|
|
229
|
+
remove it.
|
|
230
|
+
|
|
231
|
+
### 2.5 Op registration side effects
|
|
232
|
+
|
|
233
|
+
`createScheme(projectPath, stage, title, {draft})`:
|
|
234
|
+
|
|
235
|
+
1. creates `.openfeel/plan/{series}/{stageDir}/ops/` (`mkdir -p`)
|
|
236
|
+
2. under lock `scheme-{stageDir}.lock`: allocates the sequence and atomically writes `ops/op-NNN.md`
|
|
237
|
+
3. `syncToFlowJson` (a **separate**, unlocked-then-`save()` step):
|
|
238
|
+
- if `flow.json` absent → returns silently (op file still created)
|
|
239
|
+
- if the stage key is absent → validates `validateStageId`, then `findStageDirConflict`; if either
|
|
240
|
+
check fails it **warns and skips** registration (op file remains → becomes a *file orphan*) — it
|
|
241
|
+
does **not** throw; on success it creates the stage record and calls `ensureStageSkeleton`
|
|
242
|
+
- writes `ops[opId]` with the §2.4 literal
|
|
243
|
+
- appends log `register_op` (agent `cli`) and, for `--draft`, `scheme_create` with `{draft:true}`
|
|
244
|
+
- `save()`; a concurrency failure warns `[WARN] op {opId} 已创建,但 flow.json 同步因并发冲突失败…`
|
|
245
|
+
4. `warnUnregisteredFiles` → stderr warning listing up to 5 file orphans of that stage
|
|
246
|
+
|
|
247
|
+
**Sequence allocation** (`nextSchemeSequence(fileSeqs, registeredSeqs)` then `reserveSequence`):
|
|
248
|
+
|
|
249
|
+
1. the used set is `ops/` file numbers ∪ `flow.json` `ops` key numbers (both scanned with
|
|
250
|
+
`/^op-(\d+)/`, so legacy `op-NNN_{title}.md` files and `op-NNN-verdict.md` side files occupy numbers)
|
|
251
|
+
2. start candidate = **smallest unused positive integer** starting at 1 (holes are back-filled, so
|
|
252
|
+
`op-001`, `op-003` present → next is `op-002`)
|
|
253
|
+
3. `reserveSequence` tries `candidate(seq) = 'op-' + String(seq).padStart(3,'0') + '.md'` from that start,
|
|
254
|
+
claiming it with `openSync(path, 'wx')` (**O_EXCL**) and incrementing on `EEXIST`, up to
|
|
255
|
+
`SEQUENCE_MAX_ATTEMPTS_DEFAULT = 1000`; then it throws
|
|
256
|
+
`序号分配失败:连续 1000 次候选均被占用(dir={opsDir})`
|
|
257
|
+
4. the claimed file is an **empty placeholder**; `createScheme` writes the template into it with
|
|
258
|
+
`atomicWriteFileSync`. A crash between claim and write leaves a 0-byte file that still occupies the
|
|
259
|
+
number (never reused, never renumbered downward)
|
|
260
|
+
|
|
261
|
+
`opIdOf(seq) = 'op-' + String(seq).padStart(3, '0')` — 3-digit minimum, so `op-1000` is 4 digits.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## 3. On-disk layout
|
|
266
|
+
|
|
267
|
+
### 3.1 Path resolution
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
.openfeel/plan/{series}/{stageDir}/
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
| Term | Rule | Examples |
|
|
274
|
+
| --- | --- | --- |
|
|
275
|
+
| `{series}` | **major version only**, `v{MAJOR}` | `v1`, `v4`, `v0` |
|
|
276
|
+
| `{stageDir}` | `stage-{NN}` with the **original digits preserved** (leading zeros not normalised) | `stage-01`, `stage-92`, `stage-092` |
|
|
277
|
+
| full stage id | `{version}-stage-{NN}` | `v1.0.0-stage-01`, `v1.2.1-stage-92`, `v4-stage-04` |
|
|
278
|
+
| short id | `stage-{NN}` → normalised to `v1.0.0-stage-{NN}` | `stage-01` → `v1.0.0-stage-01` |
|
|
279
|
+
|
|
280
|
+
`DEFAULT_SERIES = 'v1'`, `DEFAULT_STAGE_VERSION = 'v1.0.0'`.
|
|
281
|
+
|
|
282
|
+
> **Hazard.** The repository legitimately contains *version* sub-directories next to stage
|
|
283
|
+
> directories: `.openfeel/plan/v1/v1.2.1/` holds `plan.md` + `deps.yaml` (version-level plan), **not**
|
|
284
|
+
> a stage. `listStages()` and `listSchemes()` iterate *every* directory under `plan/{series}/`, so
|
|
285
|
+
> `plan stage list` prints `- v1.2.1 .openfeel/plan/v1/v1.2.1/` as if it were a stage. A
|
|
286
|
+
> reimplementation must reproduce this (no `stage-NN` filter) unless deliberately diverging.
|
|
287
|
+
|
|
288
|
+
### 3.2 Directory inventory (from real data)
|
|
289
|
+
|
|
290
|
+
A stage directory may contain exactly these entries; nothing else is created by the CLI:
|
|
291
|
+
|
|
292
|
+
```
|
|
293
|
+
.openfeel/plan/v1/stage-92/
|
|
294
|
+
├── overview.md # ensureStageSkeleton (idempotent, never overwritten)
|
|
295
|
+
├── status.md # ensureStageSkeleton (idempotent, never overwritten)
|
|
296
|
+
├── plan.md # written by the planner agent (free-form; not CLI-managed)
|
|
297
|
+
└── ops/
|
|
298
|
+
├── deps.yaml # op-dependency declaration (agent-written; CLI only seeds a placeholder)
|
|
299
|
+
├── op-001.md
|
|
300
|
+
├── op-002.md
|
|
301
|
+
├── op-003.md
|
|
302
|
+
└── op-004.md
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Legacy stage directories under `.openfeel/stages/{fullStageId}/` (e.g. `.openfeel/stages/v0.5.11-stage-01/`)
|
|
306
|
+
contain only `overview.md` + `status.md` and are **read-only fallback** targets.
|
|
307
|
+
|
|
308
|
+
**No `review/` or `test/` sub-directory exists** — neither in the code (no `join(..., 'review')`
|
|
309
|
+
anywhere) nor in real data. Review artefacts live outside the stage directory:
|
|
310
|
+
|
|
311
|
+
| Artefact | Real location |
|
|
312
|
+
| --- | --- |
|
|
313
|
+
| Code/plan review findings | `.openfeel/users/{username}/code_review/REV-{NN}.md` (per-user) |
|
|
314
|
+
| Stage review index/summary | `.openfeel/code_review/stage-{NN}.md`, `.openfeel/code_review/{index,log}.md` |
|
|
315
|
+
| Review records (`flow.json.reviews[]`) | `flow.json` top-level array, not a file |
|
|
316
|
+
| Agent run logs | `.openfeel/users/{username}/log/{YYYY-MM-DD}-{NNN}.md` |
|
|
317
|
+
| Stage timing metrics | `.openfeel/metrics.json` |
|
|
318
|
+
| Checkpoint snapshots | `.openfeel/checkpoints/*.json` (§5.2) |
|
|
319
|
+
| status.md backups | `.openfeel/tmp/status.{stageId}.bak` |
|
|
320
|
+
|
|
321
|
+
### 3.3 `status.md` — full structure and managed regions
|
|
322
|
+
|
|
323
|
+
Skeleton emitted by `ensureStageSkeleton` (exact bytes; `{…}` are substitutions, one trailing `\n`):
|
|
324
|
+
|
|
325
|
+
```markdown
|
|
326
|
+
# {fullStageId} 状态
|
|
327
|
+
|
|
328
|
+
- **执行模式**:{executionMode}
|
|
329
|
+
- **自动推进**:{autoAdvance}
|
|
330
|
+
- **工作流模式**:{workflowMode}
|
|
331
|
+
- **状态**:planned
|
|
332
|
+
- **当前责任 Agent**:user
|
|
333
|
+
- **上一责任 Agent**:none
|
|
334
|
+
- **更新时间**:{yyyy-MM-dd HH:mm}
|
|
335
|
+
|
|
336
|
+
## Worktree / Session
|
|
337
|
+
|
|
338
|
+
- **工作模式**:manual
|
|
339
|
+
- **分支名**:-
|
|
340
|
+
- **Session 名称**:-
|
|
341
|
+
- **合并状态**:not_started
|
|
342
|
+
- **清理策略**:manual
|
|
343
|
+
|
|
344
|
+
## 当前任务
|
|
345
|
+
|
|
346
|
+
{tasksBlock}
|
|
347
|
+
|
|
348
|
+
## 阻塞 / 暂停原因
|
|
349
|
+
|
|
350
|
+
无
|
|
351
|
+
|
|
352
|
+
## 状态记录
|
|
353
|
+
|
|
354
|
+
| 时间 | Agent | 状态变化 | 说明 |
|
|
355
|
+
|------|-------|----------|------|
|
|
356
|
+
| {yyyy-MM-dd HH:mm} | user | planned | 阶段已创建 |
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
- `{yyyy-MM-dd HH:mm}` = `new Date().toISOString().replace('T',' ').substring(0,16)` → **UTC**, minute
|
|
360
|
+
precision, no timezone marker. A local-time implementation silently diverges.
|
|
361
|
+
- `{tasksBlock}` = `buildTaskLines(tasks)` → `- [ ] 任务1:desc` lines joined by `\n` (numbering from 1,
|
|
362
|
+
contiguous), or the literal `> 待补充` when no initial tasks.
|
|
363
|
+
- `{executionMode}` ← `overrides.executionMode ?? resolveConfigDefaults().execution_mode` (default `manual`)
|
|
364
|
+
- `{autoAdvance}` ← `overrides.autoAdvance ?? resolveConfigDefaults().auto_advance` (default `enabled`)
|
|
365
|
+
- `{workflowMode}` ← `overrides.workflowMode ?? resolveStageWorkflowMode(fullStageId).value`
|
|
366
|
+
(status.md does not exist yet, so the `status.md` cascade layer cannot hit; result is
|
|
367
|
+
requirement → `config.yaml` → profile → builtin, builtin default **`fast`**)
|
|
368
|
+
|
|
369
|
+
Real example — `.openfeel/plan/v1/stage-92/status.md`:
|
|
370
|
+
|
|
371
|
+
```markdown
|
|
372
|
+
# v1.2.1-stage-92 状态
|
|
373
|
+
|
|
374
|
+
- **执行模式**:auto
|
|
375
|
+
- **自动推进**:enabled
|
|
376
|
+
- **工作流模式**:full
|
|
377
|
+
- **状态**:planned
|
|
378
|
+
- **当前责任 Agent**:user
|
|
379
|
+
- **上一责任 Agent**:none
|
|
380
|
+
- **更新时间**:2026-10-14 16:40
|
|
381
|
+
|
|
382
|
+
## Worktree / Session
|
|
383
|
+
|
|
384
|
+
- **工作模式**:manual
|
|
385
|
+
- **分支名**:-
|
|
386
|
+
- **Session 名称**:-
|
|
387
|
+
- **合并状态**:not_started
|
|
388
|
+
- **清理策略**:manual
|
|
389
|
+
|
|
390
|
+
## 当前任务
|
|
391
|
+
|
|
392
|
+
- [ ] 任务1:提速与并行拆分:审查档位 / 并行编排 / 归档轻量化
|
|
393
|
+
|
|
394
|
+
## 阻塞 / 暂停原因
|
|
395
|
+
|
|
396
|
+
无
|
|
397
|
+
|
|
398
|
+
## 状态记录
|
|
399
|
+
|
|
400
|
+
| 时间 | Agent | 状态变化 | 说明 |
|
|
401
|
+
|------|-------|----------|------|
|
|
402
|
+
| 2026-10-14 16:40 | user | planned | 阶段已创建 |
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Legacy `status.md` files (`.openfeel/stages/…`, written before the `工作流模式` field existed) omit that
|
|
406
|
+
line entirely; `insertStatusField` exists precisely to add it later.
|
|
407
|
+
|
|
408
|
+
#### Field parsing
|
|
409
|
+
|
|
410
|
+
`stage set` / `stage status` / `flow health` all parse the block with
|
|
411
|
+
`/^-\s*(?:\*\*)?(.+?)(?:\*\*)?[::]\s*(.*)$/gm` (key = group 1 trimmed, value = group 2 trimmed). Both
|
|
412
|
+
ASCII `:` and full-width `:` are accepted; bold markers are optional.
|
|
413
|
+
|
|
414
|
+
`showStageStatus` prints known keys in this fixed order, then any unknown keys:
|
|
415
|
+
|
|
416
|
+
```
|
|
417
|
+
执行模式, 自动推进, 状态, 当前责任 Agent, 上一责任 Agent, 更新时间,
|
|
418
|
+
工作模式, 分支名, Session 名称, 合并状态, 清理策略, 前置依赖, 依赖状态
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
(`前置依赖` / `依赖状态` are legacy-only and never written by current code.)
|
|
422
|
+
|
|
423
|
+
#### Managed regions — who writes what
|
|
424
|
+
|
|
425
|
+
| Region | Written by | Never written by |
|
|
426
|
+
| --- | --- | --- |
|
|
427
|
+
| `执行模式` | skeleton; `stage set --exec-mode`; `config set execution_mode --sync-stages` | `flow advance` |
|
|
428
|
+
| `自动推进` | skeleton; `stage set --auto-advance`; `config set auto_advance --sync-stages` | `flow advance` |
|
|
429
|
+
| `工作流模式` | skeleton; `stage set --workflow-mode`; `config set workflow_mode --sync-stages` | `flow advance` |
|
|
430
|
+
| `状态` | skeleton; `stage set --status`; `flow health --fix` (`reconcileStatusMd`) | `flow advance` (**deliberately** — `flow.json.stages[].status` is the projection, status.md is not touched by advance) |
|
|
431
|
+
| `当前责任 Agent` | skeleton (`user`); `stage set --review-agent` | — |
|
|
432
|
+
| `上一责任 Agent` | skeleton (`none`) only | — |
|
|
433
|
+
| `更新时间` | skeleton only | — |
|
|
434
|
+
| `## Worktree / Session` block | skeleton only | — |
|
|
435
|
+
| `## 当前任务` | skeleton; `stage task --add` (append); `stage task --done/--undone` (checkbox) | — |
|
|
436
|
+
| `## 阻塞 / 暂停原因` | skeleton (`无`) only | — |
|
|
437
|
+
| `## 状态记录` table | skeleton (one row) only; appended manually by agents | — |
|
|
438
|
+
|
|
439
|
+
**Every write is a targeted surgical replacement** — the rest of the file is preserved byte-for-byte
|
|
440
|
+
(CRLF aware in `insertStatusField`). Writes are atomic and taken under
|
|
441
|
+
`.openfeel/tmp/locks/status-{stageDir}.lock` (e.g. `.openfeel/tmp/locks/status-stage-35.lock`). A backup
|
|
442
|
+
`.openfeel/tmp/status.{stageId}.bak` is copied **only when a change is actually confirmed** (not on
|
|
443
|
+
no-op, not on failure, and — notably — not for `stage task --add`).
|
|
444
|
+
|
|
445
|
+
Lock file names (`projectLockPath(projectPath, name)` → `.openfeel/tmp/locks/{name}.lock`):
|
|
446
|
+
|
|
447
|
+
| Lock | Name | Guards |
|
|
448
|
+
| --- | --- | --- |
|
|
449
|
+
| flow | `flow` | `flow.json` read-modify-write + optimistic revision check |
|
|
450
|
+
| status | `status-{stageDir}` | per-stage `status.md` read-modify-write |
|
|
451
|
+
| scheme | `scheme-{stageDir}` | op sequence allocation + op file creation |
|
|
452
|
+
|
|
453
|
+
#### `appendStatusTask` algorithm (`stage task <id> --add <desc>`)
|
|
454
|
+
|
|
455
|
+
1. resolve path via `findStageStatusPath`; missing → throw `STATUS_NOT_FOUND`
|
|
456
|
+
2. find the header line `/^##\s*当前任务\s*$/m`; missing → throw `TASK_SECTION_MISSING`
|
|
457
|
+
3. `no = max(existing 任务{N} numbers) + 1` (regex `/^-\s*\[[ x]\]\s*任务(\d+)[::]/gm`); if none → `1`
|
|
458
|
+
4. body = lines between this header and the next `\n## ` (or EOF), each `trimEnd()`-ed, dropping blank
|
|
459
|
+
lines and the literal `> 待补充`
|
|
460
|
+
5. append `` - [ ] 任务{no}:{sanitizeTaskDesc(desc)} `` where `sanitizeTaskDesc` collapses internal
|
|
461
|
+
newlines to a single space and trims
|
|
462
|
+
6. write `prefix + '\n' + body.join('\n') + '\n' + suffix`
|
|
463
|
+
|
|
464
|
+
### 3.4 `overview.md`
|
|
465
|
+
|
|
466
|
+
Skeleton (`ensureStageSkeleton`, exact bytes, trailing `\n`):
|
|
467
|
+
|
|
468
|
+
```markdown
|
|
469
|
+
# {fullStageId}
|
|
470
|
+
|
|
471
|
+
## 目标
|
|
472
|
+
|
|
473
|
+
> 待补充
|
|
474
|
+
|
|
475
|
+
## 依赖
|
|
476
|
+
|
|
477
|
+
{depsText}
|
|
478
|
+
|
|
479
|
+
## 操作方案
|
|
480
|
+
|
|
481
|
+
> 待补充
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
`{depsText}` = `- {dep}` lines joined by `\n` when `deps` is non-empty, else the literal `无`.
|
|
485
|
+
`overview.md` is **never** rewritten after creation (not even when `deps` change via
|
|
486
|
+
`flow stage set --deps`), so it is a *historical* record, not a mirror of `flow.json.deps`.
|
|
487
|
+
|
|
488
|
+
Real example — `.openfeel/plan/v1/stage-92/overview.md`:
|
|
489
|
+
|
|
490
|
+
```markdown
|
|
491
|
+
# v1.2.1-stage-92
|
|
492
|
+
|
|
493
|
+
## 目标
|
|
494
|
+
|
|
495
|
+
> 待补充
|
|
496
|
+
|
|
497
|
+
## 依赖
|
|
498
|
+
|
|
499
|
+
- v1.2.1-stage-86
|
|
500
|
+
- v1.2.1-stage-90
|
|
501
|
+
- v1.2.1-stage-91
|
|
502
|
+
|
|
503
|
+
## 操作方案
|
|
504
|
+
|
|
505
|
+
> 待补充
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### 3.5 `ops/op-NNN*.md` — naming and content
|
|
509
|
+
|
|
510
|
+
Naming rules:
|
|
511
|
+
|
|
512
|
+
| Form | Status | Matching predicate used everywhere |
|
|
513
|
+
| --- | --- | --- |
|
|
514
|
+
| `op-NNN.md` | **current**; the only form `createScheme` writes | exact `f === `${opId}.md`` |
|
|
515
|
+
| `op-NNN_{title}.md` | legacy; never migrated, never renamed | `f.startsWith(`${opId}_`)` |
|
|
516
|
+
| `op-NNN-{suffix}.md` (e.g. real `op-001-verdict.md`) | agent-authored side file | **counts as a file orphan key** (`/^(op-\d+)/`) but is *not* found by `readOpTemplate` / `hasOpTemplateFile` |
|
|
517
|
+
|
|
518
|
+
The title is **no longer part of the filename** (it used to break on `/` in titles). The title's
|
|
519
|
+
authority for listing is the file's first line matched by `/^#\s*op-\d+[::]\s*(.+)$/m`; for legacy
|
|
520
|
+
names the filename is parsed instead (`op-NNN_{title}.md` → `{title}` with `_` → space); final fallback
|
|
521
|
+
is the bare filename.
|
|
522
|
+
|
|
523
|
+
Exact template (`generateSchemeTemplate`, trailing `\n`):
|
|
524
|
+
|
|
525
|
+
```markdown
|
|
526
|
+
# {opId}:{title}
|
|
527
|
+
|
|
528
|
+
- **阶段**:{fullStageId}
|
|
529
|
+
- **状态**:pending
|
|
530
|
+
- **前置**:无
|
|
531
|
+
- **负责 Agent**:openfeel-executor
|
|
532
|
+
- **最多重试**:3
|
|
533
|
+
|
|
534
|
+
## 目标
|
|
535
|
+
{title}
|
|
536
|
+
|
|
537
|
+
## 实施步骤
|
|
538
|
+
- [ ] 待补充
|
|
539
|
+
|
|
540
|
+
## 产出文件
|
|
541
|
+
- 待补充
|
|
542
|
+
|
|
543
|
+
## 自测清单
|
|
544
|
+
- [ ] 待补充
|
|
545
|
+
|
|
546
|
+
## 修正记录
|
|
547
|
+
| 次数 | 时间 | 问题 | 修正内容 |
|
|
548
|
+
|------|------|------|----------|
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Notes a reimplementation will get wrong:
|
|
552
|
+
|
|
553
|
+
- There is **no blank line** between `## 目标` and the title line.
|
|
554
|
+
- `- **状态**:pending` is emitted **even for `--draft`**, while `flow.json` records `state: "draft"`.
|
|
555
|
+
The file header is therefore *not* the state authority; `flow.json` is.
|
|
556
|
+
- `## 产出文件` is `- 待补充` (a plain bullet), while the two fillable sections use the checkbox form
|
|
557
|
+
`- [ ] 待补充` (`EMPTY_TEMPLATE_MARKER`).
|
|
558
|
+
- `最多重试:3` is documentary — the operative value is `flow.json` `max_attempts: 3`.
|
|
559
|
+
|
|
560
|
+
**Fill-state detection** (`isTemplateEmpty` / `detectFillState`, single source of truth):
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
export const EMPTY_TEMPLATE_MARKER = '- [ ] 待补充';
|
|
564
|
+
const EMPTY_TEMPLATE_LINE_RE = /(?:^|\n)[ \t]*-\s*\[\s*\]\s*待补充[ \t]*(?=\r?\n|$)/;
|
|
565
|
+
isTemplateEmpty(c) // line-anchored: inline mentions do NOT count
|
|
566
|
+
detectFillState(c) // 'empty' | 'partial' (has any unchecked `- [ ]`) | 'filled'
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
`null` content (file missing / unreadable / name without the `op-` prefix) is treated as `filled` by
|
|
570
|
+
`flow ops list` and skipped (not reported) by `healthCheck` item 9.
|
|
571
|
+
|
|
572
|
+
### 3.6 `ops/deps.yaml` — op-dependency declaration
|
|
573
|
+
|
|
574
|
+
This file is **not read by any CLI code path**. It is written by the `openfeel-schemer` agent and read by
|
|
575
|
+
the orchestrating agents. The CLI only seeds a placeholder via
|
|
576
|
+
`plan scheme create <stage> --from-tasks` when the file is absent:
|
|
577
|
+
|
|
578
|
+
```yaml
|
|
579
|
+
# {fullStageId} op 依赖声明(占位)
|
|
580
|
+
# 由 openfeel plan scheme create --from-tasks 机械生成;依赖关系请由 openfeel-schemer 补全
|
|
581
|
+
# 参见 kb/patterns.md #op 文件命名规范 + #deps.yaml 声明实际文件名
|
|
582
|
+
|
|
583
|
+
version: "1.1"
|
|
584
|
+
stage: {fullStageId}
|
|
585
|
+
|
|
586
|
+
ops: {}
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
The agent-authored form (real, abbreviated from `.openfeel/plan/v1/stage-92/ops/deps.yaml`) uses:
|
|
590
|
+
|
|
591
|
+
```yaml
|
|
592
|
+
version: "1.1"
|
|
593
|
+
stage: v1.2.1-stage-92
|
|
594
|
+
series: v1
|
|
595
|
+
generated_by: openfeel-schemer
|
|
596
|
+
generated_at: "2026-10-15"
|
|
597
|
+
|
|
598
|
+
cross_stage_constraints: # upstream / downstream notes, type: hard|soft, status: done
|
|
599
|
+
- upstream: v1.2.1-stage-86 / v1.2.1-stage-90 / v1.2.1-stage-91
|
|
600
|
+
type: hard
|
|
601
|
+
status: done
|
|
602
|
+
reason: "..."
|
|
603
|
+
|
|
604
|
+
ops:
|
|
605
|
+
op-001:
|
|
606
|
+
file: op-001.md
|
|
607
|
+
title: "..."
|
|
608
|
+
produces: [ ...file list... ]
|
|
609
|
+
deps: [] # first op
|
|
610
|
+
retry: { max: 3, on_fail: 回退 Feel 重议 }
|
|
611
|
+
notes: "..."
|
|
612
|
+
op-002:
|
|
613
|
+
file: op-002.md
|
|
614
|
+
deps:
|
|
615
|
+
- op: op-001
|
|
616
|
+
type: hard
|
|
617
|
+
reason: "..."
|
|
618
|
+
mutual_exclusions:
|
|
619
|
+
- group: [op-001, op-002, op-003]
|
|
620
|
+
reason: "..."
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Legacy stage-level variant, still present at `plan/v1/stage-33/deps.yaml` (note the different key and
|
|
624
|
+
the flat string-array form):
|
|
625
|
+
|
|
626
|
+
```yaml
|
|
627
|
+
ops:
|
|
628
|
+
op-001:
|
|
629
|
+
file: "ops/op-001.md"
|
|
630
|
+
depends_on: []
|
|
631
|
+
stage: "v1.0.0-stage-33"
|
|
632
|
+
type: hard
|
|
633
|
+
produces: [ ... ]
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
**Version-level** `plan/{series}/{version}/deps.yaml` carries the stage graph consumed by
|
|
637
|
+
`flow health`'s cycle check:
|
|
638
|
+
|
|
639
|
+
```yaml
|
|
640
|
+
stages:
|
|
641
|
+
v1.2.1-stage-86:
|
|
642
|
+
name: 可观测性改造
|
|
643
|
+
deps: []
|
|
644
|
+
priority: P0
|
|
645
|
+
est_ops: 3
|
|
646
|
+
produces_core: [ ... ]
|
|
647
|
+
notes: "..."
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
### 3.7 `plan.md`
|
|
651
|
+
|
|
652
|
+
Free-form planner output at `plan/{series}/{stage}/plan.md` (real file: 69 KB for `stage-92`). No CLI
|
|
653
|
+
code reads or writes it; it is referenced by ops via `对应计划` lines. Real header shape:
|
|
654
|
+
|
|
655
|
+
```markdown
|
|
656
|
+
# v1.2.1-stage-91 计划 — 提示词瘦身与上下文治理(主题四)
|
|
657
|
+
|
|
658
|
+
- **阶段**:`v1.2.1-stage-91`(…)
|
|
659
|
+
- **依赖**:**hard: `v1.2.1-stage-88` / …**
|
|
660
|
+
- **优先级**:P0 | **制定人**:openfeel-planner | **制定时间**:2026-10-15 | **修订轮**:修订 0(初稿)
|
|
661
|
+
- **基线**:**HEAD `3d25d76`**(…)
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
### 3.8 Roadmap (secondary)
|
|
665
|
+
|
|
666
|
+
`src/core/plan/roadmap.ts` manages `.openfeel/roadmap/v{version}.md` (version normalised by stripping a
|
|
667
|
+
leading `v`). Skeleton:
|
|
668
|
+
|
|
669
|
+
```markdown
|
|
670
|
+
# 分期大纲 v{ver}
|
|
671
|
+
|
|
672
|
+
## 目标
|
|
673
|
+
|
|
674
|
+
> 待补充 — 描述本版本的总体目标
|
|
675
|
+
|
|
676
|
+
## 阶段划分
|
|
677
|
+
|
|
678
|
+
> 待补充 — 列出各阶段(stage-01, stage-02, ...)及简要说明
|
|
679
|
+
|
|
680
|
+
## 里程碑
|
|
681
|
+
|
|
682
|
+
> 待补充 — 关键里程碑和预期完成时间
|
|
683
|
+
|
|
684
|
+
## 备注
|
|
685
|
+
|
|
686
|
+
> 待补充
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
`createRoadmap` is no-op-with-message when the file exists (`大纲已存在: v{ver}.md`);
|
|
690
|
+
`showRoadmap` with a missing version throws `错误:大纲文件不存在 — v{ver}.md` (core does not
|
|
691
|
+
`process.exit`), and without arguments lists `v*.md` filenames.
|
|
692
|
+
|
|
693
|
+
---
|
|
694
|
+
|
|
695
|
+
## 4. Op state machine
|
|
696
|
+
|
|
697
|
+
### 4.1 States
|
|
698
|
+
|
|
699
|
+
| State | Meaning | Counted as "ready"/current? | Counted in `opsCount`? |
|
|
700
|
+
| --- | --- | --- | --- |
|
|
701
|
+
| `draft` | created but unpublished (`--draft`); template may still be empty | **no** | **no** |
|
|
702
|
+
| `pending` | awaiting execution, or returned to the queue after a retryable failure | yes | yes |
|
|
703
|
+
| `executing` | *(reserved)* — recognised by every predicate, **never written by any code path** | yes | yes |
|
|
704
|
+
| `done` | terminal success | no | yes |
|
|
705
|
+
| `failed` | terminal failure after attempt exhaustion | no | yes |
|
|
706
|
+
|
|
707
|
+
`executing` is accepted by `getReadyOps`, `syncCurrentOp`, `recoverContext`, and the
|
|
708
|
+
`removeStage` current-pointer fallback. **No command, API, or repair path ever sets it.** An op can hold
|
|
709
|
+
`executing` only if a human edits `flow.json`. A reimplementation must keep the value in the union and
|
|
710
|
+
treat it exactly like `pending` in every predicate, but must not invent a transition into it.
|
|
711
|
+
|
|
712
|
+
### 4.2 Legal transitions
|
|
713
|
+
|
|
714
|
+
| From | To | Trigger (exact) | Fields updated | Also updated |
|
|
715
|
+
| --- | --- | --- | --- | --- |
|
|
716
|
+
| — | `draft` | `plan scheme create <stage> <title> --draft` | full creation literal (§2.4) | log `register_op`, `scheme_create{draft:true}` |
|
|
717
|
+
| — | `pending` | `plan scheme create` (no `--draft`); `plan scheme register`; implicit register; `createSchemesFromTasks` | full creation literal | log `register_op` |
|
|
718
|
+
| `draft` | `pending` | `plan scheme publish <stage> <op>` (requires the template to be non-empty) | `state` only | log `scheme_publish` |
|
|
719
|
+
| `pending` | `done` | `flow attempt --op X --result pass` | `state`, `attempts += 1`, `attempted_at`, `completed_at` | `syncCurrentOp(stage)`, `pipeline.retry = 0`, log `attempt_pass` + public log |
|
|
720
|
+
| `pending` | `pending` | `flow attempt --op X --result fail` **and** `attempts < max_attempts` after increment | `attempts += 1`, `attempted_at` (**`completed_at` untouched**) | `syncCurrentOp`, `pipeline.retry += 1`, log `attempt_fail_retry` |
|
|
721
|
+
| `pending` | `failed` | `flow attempt --op X --result fail` **and** `attempts >= max_attempts` after increment | `attempts += 1`, `attempted_at`, `completed_at` | `pipeline.retry += 1`, log `attempt_fail_exhausted`; **no** `syncCurrentOp` |
|
|
722
|
+
| `executing` | any of the above | same `recordAttempt` code path (the state is not special-cased) | as above | as above |
|
|
723
|
+
| `draft` | `draft` (refused) | `flow attempt` on a draft | **nothing** | log `attempt_refused_draft` (agent `openfeel-executor`); CLI prints the publish hint and exits **1** |
|
|
724
|
+
| `failed` | `done` / `pending` / `failed` | a subsequent `flow attempt` — `failed` is **not** absorbing | as per the `pass`/`fail` branches | as above |
|
|
725
|
+
| `done` | — | no transition out of `done` (only `--force` deletion of the registration) | — | — |
|
|
726
|
+
|
|
727
|
+
`recordAttempt` increments `attempts` **unconditionally first**, then branches. Therefore the retry gate
|
|
728
|
+
is `attempts < max_attempts` *after* the increment — with the default `max_attempts = 3` an op fails
|
|
729
|
+
`attempt #1` → `pending`, `#2` → `pending`, `#3` → `failed`.
|
|
730
|
+
|
|
731
|
+
### 4.3 Who performs what
|
|
732
|
+
|
|
733
|
+
| Actor | Paths |
|
|
734
|
+
| --- | --- |
|
|
735
|
+
| **CLI / core** | every state write (`draft`/`pending` at creation, `draft→pending` on publish, `done`/`failed`/`pending` on attempt) |
|
|
736
|
+
| **Agents** | may edit `ops/op-*.md` content, `plan.md`, `deps.yaml`, review files; **must not** hand-edit `flow.json` |
|
|
737
|
+
| **`repair()`** | never touches `op.state`; can only drop `ops` keys (`--prune-orphans`) |
|
|
738
|
+
| **`restoreCheckpoint`** | restores whatever `state` values the snapshot held (whole stage subtree or whole file) |
|
|
739
|
+
|
|
740
|
+
### 4.4 Command: `flow attempt`
|
|
741
|
+
|
|
742
|
+
```
|
|
743
|
+
openfeel flow attempt --op <stage>.<op> --result <pass|fail>
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
- both options `requiredOption`
|
|
747
|
+
- `--result` ∉ {`pass`,`fail`} → `错误:--result 必须为 pass 或 fail` + exit 1
|
|
748
|
+
- op state is `draft` → `{opId} 处于 draft(未发布),无法记录执行结果;请先运行 openfeel plan scheme publish {stage} {opId}` + exit 1 (defence-in-depth: core `recordAttempt` also refuses)
|
|
749
|
+
- **`pass` prints the success message even when the op does not exist** — `recordAttempt` returns
|
|
750
|
+
`{shouldRetry:false, shouldReplan:false}` for an unknown op and the CLI branches on `--result` first.
|
|
751
|
+
Exit code 0 either way. (Real behavioural quirk; preserve or deliberately deviate.)
|
|
752
|
+
- `pass` → `✓ {op} 执行成功`
|
|
753
|
+
- `fail` + `shouldRetry` → `⚠ {op} 执行失败,将重试(可重试)`
|
|
754
|
+
- `fail` + `shouldReplan` → `✗ {op} 重试耗尽,需要重新规划`, then `advancePhase(op, 'scheme_pending')`
|
|
755
|
+
(deprecated path, emits `[DEPRECATED] advancePhase() 已弃用…` on stderr) + a second `save()`, then
|
|
756
|
+
`→ 已自动回退到 scheme_pending,请重新规划方案`
|
|
757
|
+
- finally prints the pointer: `当前指针:{stage}.{op}` or `当前指针:{stage}.(无待执行 op)`
|
|
758
|
+
|
|
759
|
+
### 4.5 Command: `plan scheme publish`
|
|
760
|
+
|
|
761
|
+
```
|
|
762
|
+
openfeel plan scheme publish <stage> <opId>
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
Order of checks (each failure → exit 1):
|
|
766
|
+
|
|
767
|
+
1. stage / op not found → `未找到:阶段 {stage} 或操作方案 {opId}` (`reason: 'not-found'`)
|
|
768
|
+
2. `op.state !== 'draft'` → `{opId} 不是 draft 状态,无需发布` (`'not-draft'`)
|
|
769
|
+
3. template file unreadable → `not-found`
|
|
770
|
+
4. `isTemplateEmpty(content)` → `模板未填充,禁止发布:{opId}(请先补全「- [ ] 待补充」)` (`'empty-template'`)
|
|
771
|
+
5. success → `state = 'pending'`, log `scheme_publish`, save, `已发布 {opId}(draft → pending)`
|
|
772
|
+
|
|
773
|
+
---
|
|
774
|
+
|
|
775
|
+
## 5. Checkpoint model
|
|
776
|
+
|
|
777
|
+
Two unrelated things are both called "checkpoint".
|
|
778
|
+
|
|
779
|
+
### 5.1 Op-level `checkpoints` object
|
|
780
|
+
|
|
781
|
+
```ts
|
|
782
|
+
checkpoints: {
|
|
783
|
+
plan: 'pending', // string, phase-gated
|
|
784
|
+
scheme: 'pending', // string
|
|
785
|
+
exec: { attempts: 0, self: 'pending' }, // object — the only nested one
|
|
786
|
+
review: 'pending',
|
|
787
|
+
test: 'pending',
|
|
788
|
+
}
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
| Member | Value domain observed in code | Written by |
|
|
792
|
+
| --- | --- | --- |
|
|
793
|
+
| `plan`, `scheme`, `review`, `test` | `'pending'` \| `'passed'` \| `'failed'` | legacy `advancePhase` only |
|
|
794
|
+
| `exec.self` | `'pending'` \| `'running'` | legacy `advancePhase` (`exec_running` only) |
|
|
795
|
+
| `exec.attempts` | `0` only (§2.4) | nothing |
|
|
796
|
+
|
|
797
|
+
The only writer is the **deprecated** `FlowManager.advancePhase(opId, to, …)`, which is reachable solely
|
|
798
|
+
from `flow attempt` on exhaustion (`to = 'scheme_pending'`). Its rule:
|
|
799
|
+
|
|
800
|
+
```
|
|
801
|
+
checkpointKey = pipelineConfig.checkpoint_mapping[ phase.split('_')[0] ] // plan|scheme|exec|review|test
|
|
802
|
+
if checkpointKey === 'exec':
|
|
803
|
+
if phase === 'exec_running': op.checkpoints.exec.self = 'running' // else untouched
|
|
804
|
+
else:
|
|
805
|
+
phase.endsWith('_passed') ? 'passed'
|
|
806
|
+
: phase.endsWith('_failed') ? 'failed'
|
|
807
|
+
: 'pending'
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
With the current runtime `pipeline.yaml` (`checkpoint_mapping` = `{plan,scheme,exec,review,test,archive}`),
|
|
811
|
+
and the only live call site passing `scheme_pending`, the effective net effect is: **all four string
|
|
812
|
+
members reset to `'pending'`**; `exec.self` is never touched by it.
|
|
813
|
+
|
|
814
|
+
`hasCheckpointProgress` (used by `plan scheme remove`) treats progress as "touched" if **any** of the
|
|
815
|
+
four strings ≠ `'pending'`, or `exec.attempts ≠ 0`, or `exec.self ≠ 'pending'`.
|
|
816
|
+
|
|
817
|
+
### 5.2 Snapshot files — `.openfeel/checkpoints/`
|
|
818
|
+
|
|
819
|
+
| Property | Value |
|
|
820
|
+
| --- | --- |
|
|
821
|
+
| Directory | `.openfeel/checkpoints/` (created on demand) |
|
|
822
|
+
| File name | `{fullStageId}-{yyyyMMddTHHmmssSSS}-{phase}.json` |
|
|
823
|
+
| Timestamp | local-time components, **millisecond-precise** (`formatCheckpointTimestamp`) so same-second advances do not collide |
|
|
824
|
+
| Content | a full serialisation of `flow.json` at that instant (same serialisation as `save()`, `op.id` stripped), pretty-printed with 2 spaces + trailing `\n` |
|
|
825
|
+
| Written by | `advanceStagePhase` **only** (`saveCheckpoint(key, targetPhase)`, after the phase write and before log append) |
|
|
826
|
+
| Not written by | `resetStagePhase` (**deliberately**), `recordAttempt`, `save()`, `removeStage` |
|
|
827
|
+
| Retention | at most **20** files per stage (`CHECKPOINT_MAX_KEEP = 20`); on overflow the oldest by filename sort are unlinked (best-effort) |
|
|
828
|
+
| Failure mode | entirely swallowed — a failed snapshot never blocks an advance |
|
|
829
|
+
|
|
830
|
+
Real filenames:
|
|
831
|
+
|
|
832
|
+
```
|
|
833
|
+
v0.5.11-stage-01-20260807T204400057-review_pending.json
|
|
834
|
+
v1.0.0-stage-01-20260807T212838127-plan_passed.json
|
|
835
|
+
v1.0.0-stage-01-20260807T214842127-archiving.json
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
Note the timestamp has **17 digits in the time part** (`HHmmss` + 3-digit ms) and the phase is the
|
|
839
|
+
**target** phase.
|
|
840
|
+
|
|
841
|
+
### 5.3 `flow checkpoint list [stage]`
|
|
842
|
+
|
|
843
|
+
Read-only. Lists `*.json` in `.openfeel/checkpoints/` sorted ascending by filename; when `stage` is given
|
|
844
|
+
it filters by `normalizeStageId(stage)` **and also** by the raw input (legacy short-name snapshots), i.e.
|
|
845
|
+
`f.startsWith(key + '-') || f.startsWith(rawInput + '-')`. Output:
|
|
846
|
+
|
|
847
|
+
```
|
|
848
|
+
Checkpoint 快照 [<stage>]:
|
|
849
|
+
<file>
|
|
850
|
+
...
|
|
851
|
+
共 <n> 个快照
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
Empty → `(暂无 Checkpoint 快照)` / `(阶段 {stage} 暂无 Checkpoint 快照)` (exit 0).
|
|
855
|
+
|
|
856
|
+
### 5.4 `flow checkpoint restore <file> [--force] [--stage <id>] [--dry-run]`
|
|
857
|
+
|
|
858
|
+
Safety + semantics, in exact order:
|
|
859
|
+
|
|
860
|
+
1. `--dry-run` → `previewRestore`, **no `--force` required**:
|
|
861
|
+
- revision conflict (disk revision ≠ loaded revision) → stderr
|
|
862
|
+
`[WARN] flow.json 已被其它进程修改,拒绝从快照恢复(请重新加载后重试)` + **exit 1**
|
|
863
|
+
- invalid name / missing / bad JSON → `错误:无法从快照 {file} 恢复(文件不存在、名称非法或内容无效)` + exit 1
|
|
864
|
+
- `--stage` not present in the snapshot (after normalize + raw fallback) →
|
|
865
|
+
`错误:快照中不存在阶段 {stage}` + exit 1
|
|
866
|
+
- prints `Checkpoint 恢复预览(未写盘)` then per-stage
|
|
867
|
+
` {stage}: {fromPhase}({fromStatus}) → {toPhase}({toStatus})` or ` (无差异)`;
|
|
868
|
+
absent sides render as sentinels `(absent)` / `(removed)`. Exit 0.
|
|
869
|
+
2. without `--dry-run`, missing `--force` → `错误:恢复操作将覆盖当前 flow.json,请使用 --force 确认` + exit 1
|
|
870
|
+
3. `restoreCheckpoint`:
|
|
871
|
+
- filename safety: rejects any name containing `/`, `\`, or `..` (`invalid-name`)
|
|
872
|
+
- takes `flow.lock`; re-checks revision; on conflict prints the same `[WARN] …` and returns false → CLI prints
|
|
873
|
+
`错误:无法从快照 {file} 恢复…` + exit 1
|
|
874
|
+
- **full restore** (no `--stage`): the snapshot object is written verbatim (its own `stages`, `reviews`,
|
|
875
|
+
`log`, `pipeline`), with `meta.revision = diskRevision + 1`
|
|
876
|
+
- **selective restore** (`--stage`): base = current disk data; only `stages[stageKey]` is replaced
|
|
877
|
+
(its `status` **re-projected** from the snapshot's `phase` via `mapPhaseToStageStatus`); all other
|
|
878
|
+
stages and all other `pipeline` fields untouched; `pipeline.current` is synchronised **only if** the
|
|
879
|
+
current disk stage normalises to `stageKey` **and** the snapshot's current stage also normalises to
|
|
880
|
+
`stageKey` (then `{stage: stageKey, op: snapshot.pipeline.current.op ?? ''}`)
|
|
881
|
+
- writes with `backup: true` (so `flow.json.bak` = the pre-restore file), then reloads
|
|
882
|
+
4. success → `✓ 已从快照 {file} 恢复 flow.json(当前文件已备份为 flow.json.bak)`
|
|
883
|
+
|
|
884
|
+
Restoring an op whose registration no longer exists in `flow.json` is therefore possible in full mode, and
|
|
885
|
+
restoring a stage that exists only in the snapshot is possible in selective mode (the stage subtree is
|
|
886
|
+
inserted).
|
|
887
|
+
|
|
888
|
+
---
|
|
889
|
+
|
|
890
|
+
## 6. Retry semantics
|
|
891
|
+
|
|
892
|
+
Three independent counters. Getting their ownership right is the most commonly missed part.
|
|
893
|
+
|
|
894
|
+
| Counter | Location | Incremented by | Reset by | Meaning |
|
|
895
|
+
| --- | --- | --- | --- | --- |
|
|
896
|
+
| `op.attempts` | `flow.json` `stages[s].ops[o].attempts` | `recordAttempt` (`+= 1` on every recorded attempt, pass or fail) | **nothing** — never reset | Per-op lifetime attempt count. Compared against `max_attempts`. |
|
|
897
|
+
| `op.max_attempts` | same | nothing | nothing | Fixed at `3` at creation; no CLI flag, no config key. |
|
|
898
|
+
| `op.checkpoints.exec.attempts` | same | **nothing** | nothing | Reserved field; always `0`. |
|
|
899
|
+
| `pipeline.retry` | `flow.json` `pipeline.retry` | `recordAttempt` on fail (`+= 1`) | `recordAttempt` on pass (`= 0`); legacy `advancePhase` when the op pointer moves to a different `(stage, op)` (`= 0`) | Global retry counter, display-only (`flow status`). |
|
|
900
|
+
|
|
901
|
+
Rules:
|
|
902
|
+
|
|
903
|
+
- **Exhaustion is not a refusal.** `recordAttempt` has no early guard; an already-`failed` op can be
|
|
904
|
+
attempted again, `attempts` keeps growing past `max_attempts`, and every subsequent `fail` lands in the
|
|
905
|
+
`failed` branch again. A later `pass` still moves it to `done`.
|
|
906
|
+
- The only "exhausted" reporting is `flow retry`:
|
|
907
|
+
```
|
|
908
|
+
openfeel flow retry --op <stage>.<op>
|
|
909
|
+
```
|
|
910
|
+
read-only; prints `操作: …` / `状态: {state}` / `当前尝试次数: {attempts} / {max_attempts}` and, when
|
|
911
|
+
`attempts >= max_attempts`, the extra line `⚠ 重试次数已用尽`. Exit 0. Errors: opId without a `.` →
|
|
912
|
+
`opId 格式不正确,应为 stage-xx.op-xxx(如 stage-01.op-001)`; stage missing →
|
|
913
|
+
`错误:未找到阶段 {stage}`; op missing → `错误:未找到操作 {op}`; all exit 1.
|
|
914
|
+
- Exhaustion *does* have a side effect inside `flow attempt`: `shouldReplan: true` triggers
|
|
915
|
+
`advancePhase(op, 'scheme_pending')`, which moves the stage's phase back to re-planning (subject to the
|
|
916
|
+
transition table) and resets `pipeline.retry` if the pointer changed.
|
|
917
|
+
- `attempted_at` is written on **every** attempt; `completed_at` only when entering `done` or `failed`.
|
|
918
|
+
Both use the same timestamp value within one call (`op.completed_at = op.attempted_at`).
|
|
919
|
+
|
|
920
|
+
---
|
|
921
|
+
|
|
922
|
+
## 7. Dependencies (`stages[].deps`)
|
|
923
|
+
|
|
924
|
+
### 7.1 Meaning
|
|
925
|
+
|
|
926
|
+
`deps` is a **declarative, human/agent-facing** list of full stage ids this stage depends on. It is
|
|
927
|
+
**not** an execution gate.
|
|
928
|
+
|
|
929
|
+
Exhaustive list of consumers:
|
|
930
|
+
|
|
931
|
+
| Consumer | Behaviour |
|
|
932
|
+
| --- | --- |
|
|
933
|
+
| `plan stage add --deps` | write-time validation: every dep must normalise to a registered stage key, **or** be an *archived* dependency; else exit 1 with zero writes |
|
|
934
|
+
| `flow stage set --deps` | same validation, then overwrite write |
|
|
935
|
+
| `checkRemovable` / `removeStage` | refuses to remove a stage that other stages list in `deps` (unless `--force`) |
|
|
936
|
+
| `healthCheck` item 7 `悬空依赖` | warn-only scan; archived deps exempt |
|
|
937
|
+
| `overview.md` skeleton | rendered at creation time only |
|
|
938
|
+
| `flow overview` output | displays `deps: stage.deps ?? []` |
|
|
939
|
+
| `archive` summary | `- **依赖阶段**:{deps.join(', ')}` or `无` |
|
|
940
|
+
|
|
941
|
+
**Nothing enforces ordering.** `flow advance`, `phase-path` search, `findPhasePath`, and stage selection
|
|
942
|
+
never read `deps`. A stage can be advanced to `done` while its declared dependencies are untouched.
|
|
943
|
+
**No cycle detection exists for stage `deps`** — self-references and A↔B cycles are accepted silently by
|
|
944
|
+
both write paths and by health.
|
|
945
|
+
|
|
946
|
+
### 7.2 Write-time validation (`plan stage add`, `flow stage set`)
|
|
947
|
+
|
|
948
|
+
```
|
|
949
|
+
registered = Object.keys(flow.json.stages) // as-is, un-normalised
|
|
950
|
+
known = new Set(registered.map(k => normalizeStageId(k) ?? k))
|
|
951
|
+
for each d in input deps:
|
|
952
|
+
if known.has(normalizeStageId(d) ?? d) → OK (registered wins)
|
|
953
|
+
else if isArchivedVersionOf(d, meta.archived_versions) → OK, but prints an info line
|
|
954
|
+
else → invalid
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
- `isArchivedVersionOf` requires the **raw input** to carry an explicit version prefix *and* that version
|
|
958
|
+
to be in `meta.archived_versions`. Short names are **always false** (they normalise to the default
|
|
959
|
+
`v1.0.0`, which must not be inferred as archived). This is why the raw `d` is passed, never the
|
|
960
|
+
normalised value.
|
|
961
|
+
- Archived-but-OK deps print `依赖已归档(视为已满足):{deps}` and are stored verbatim.
|
|
962
|
+
- If `flow.json` is absent, `registered` is `[]`, so **any** non-empty `--deps` is invalid.
|
|
963
|
+
- `plan stage add` failure message: `依赖阶段不存在:{deps}(已注册阶段:{known})` + exit 1, **zero writes**
|
|
964
|
+
(not even the directory).
|
|
965
|
+
- `flow stage set` failure message: `依赖校验失败:以下阶段不存在 {deps}` + exit 1, zero writes.
|
|
966
|
+
- **`flow stage set` without `--deps` clears the list** (empty array is a legal, validated value).
|
|
967
|
+
- Order of gates in `plan stage add`: stage-id validity → `--exec-mode` domain → `--auto-advance` domain →
|
|
968
|
+
`--workflow-mode` domain → `--workflow-mode !== 'general'` → deps validation → write.
|
|
969
|
+
|
|
970
|
+
### 7.3 Reading rules (`healthCheck` item 7)
|
|
971
|
+
|
|
972
|
+
`known` is the normalised set of registered stage keys. For each dep `d`: skip if normalised `d` is
|
|
973
|
+
known; skip if `isArchivedVersionOf(d, archived_versions)`; otherwise record `"{stageId} → {d}"`.
|
|
974
|
+
Result is a single item:
|
|
975
|
+
|
|
976
|
+
- dangling → `status: 'warn'`, `检出 {n} 条悬空依赖:{first 5 joined by '; '}` (+ ` …` if more)
|
|
977
|
+
- clean → `status: 'pass'`, `未检测到悬空依赖`
|
|
978
|
+
|
|
979
|
+
Warning only — never affects `ok`/exit code.
|
|
980
|
+
|
|
981
|
+
### 7.4 Op-level dependencies
|
|
982
|
+
|
|
983
|
+
Two historical/present shapes in `ops/deps.yaml` (see §3.6). Neither is validated, ordered, or
|
|
984
|
+
cycle-checked by the CLI. The only **machine** dependency graph in the project is the artifact DAG
|
|
985
|
+
(§12) and the version-level `plan/{series}/{version}/deps.yaml` `stages:` map consumed by
|
|
986
|
+
`healthCheck` item 6.
|
|
987
|
+
|
|
988
|
+
### 7.5 `healthCheck` item 6 — `deps.yaml` cycle detection
|
|
989
|
+
|
|
990
|
+
Reads **`.openfeel/plan/deps.yaml`** (project-level; note: **not** the version-level file, and that path
|
|
991
|
+
does not exist in this repository, so the check silently returns). When present:
|
|
992
|
+
|
|
993
|
+
- `buildDepsGraph(root)`: for each entry whose value is an object → `graph[key] = node.deps.map(String)`
|
|
994
|
+
if `node.deps` is an array, else `graph[key] = [node.depends_on]` if `depends_on` is a **string**
|
|
995
|
+
(a `depends_on` **array**, as used by `stage-33/deps.yaml`, is therefore ignored)
|
|
996
|
+
- if the document has a `stages:` object, the graph is built from `stages`; otherwise from the root
|
|
997
|
+
- DFS three-colour cycle detection; references to unknown nodes are skipped (legal)
|
|
998
|
+
- a cycle → one `status: 'fail'` item per cycle: `循环依赖检测: {a → b → a}` (**this is the only
|
|
999
|
+
dependency-related way to make `flow health` fail**)
|
|
1000
|
+
- no cycle → `status: 'pass'`, `无循环依赖 ({n} 个节点)`; empty graph → `无依赖声明,无环`
|
|
1001
|
+
|
|
1002
|
+
---
|
|
1003
|
+
|
|
1004
|
+
## 8. Orphan reconciliation
|
|
1005
|
+
|
|
1006
|
+
### 8.1 The reconciliation function
|
|
1007
|
+
|
|
1008
|
+
`findOrphanOps(projectPath) → { keyOrphans: OrphanOp[]; fileOrphans: OrphanOp[] }` where
|
|
1009
|
+
`OrphanOp = { stage: string; opId: string }`.
|
|
1010
|
+
|
|
1011
|
+
Algorithm (`reconcileOrphans`, the single implementation shared by `repair` and `health`):
|
|
1012
|
+
|
|
1013
|
+
```
|
|
1014
|
+
for each (stageId, stage) in flow.json.stages:
|
|
1015
|
+
if parseStageId(stageId) === null: continue # non-standard key → skip, no false positives
|
|
1016
|
+
keys = Object.keys(stage.ops ?? {})
|
|
1017
|
+
opsDir = .openfeel/plan/{series}/{stageDir}/ops
|
|
1018
|
+
fileIds = { name.match(/^(op-\d+)/)[1] for name in readdirSync(opsDir) } # Set, includes op-NNN-*.md
|
|
1019
|
+
keyOrphans += [ {stageId, key} for key in keys if key not in fileIds ]
|
|
1020
|
+
fileOrphans += [ {stageId, fileId} for fileId in fileIds if fileId not in keys ]
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
- Both `op-NNN.md` and legacy `op-NNN_{title}.md` count as "file exists" — this is why
|
|
1024
|
+
`hasOpTemplateFile` / `readOpTemplate` / `findOpFileInDir` each try `f === '{op}.md' || f.startsWith('{op}_')`.
|
|
1025
|
+
- `op-NNN-{suffix}.md` files (real example `op-001-verdict.md`) match the `/^(op-\d+)/` scan and therefore
|
|
1026
|
+
register as the same `op-001` id in the file-id set; they are *not* usable as templates.
|
|
1027
|
+
- Missing `flow.json` or unparsable JSON → both lists empty (never throws).
|
|
1028
|
+
- Read-side `load()` normalisation (`ops: {}`, `deps: []`) means a damaged stage record yields
|
|
1029
|
+
file orphans rather than a crash.
|
|
1030
|
+
|
|
1031
|
+
**Real state of this repository:** every `v1.2.1-stage-86 … -94` stage has `"ops": {}` while its `ops/`
|
|
1032
|
+
directory holds 3–4 `op-*.md` files → those stages are pure file-orphan producers (e.g. `stage-92`:
|
|
1033
|
+
`op-001..op-004`). `v1.2.0-stage-78 … -85` are the only stages with registered ops. This is expected
|
|
1034
|
+
data, not corruption: `plan scheme register` was never run for the v1.2.1 stages.
|
|
1035
|
+
|
|
1036
|
+
### 8.2 Key orphans — repair command
|
|
1037
|
+
|
|
1038
|
+
```
|
|
1039
|
+
openfeel flow repair [--dry-run] [--backup] [--prune-orphans]
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
- `--backup` (before repair): copies `flow.json` → `flow.json.bak`, prints `已备份: flow.json.bak`
|
|
1043
|
+
(or `flow.json 不存在,无需备份`)
|
|
1044
|
+
- `--prune-orphans` **and not** `--dry-run`: deletes only `keyOrphans` from the in-memory document, appends
|
|
1045
|
+
a `prune_orphan_ops` log entry `{count, ops: ["{stage}.{opId}", …]}` (agent `cli`) and records the change
|
|
1046
|
+
`已清理 {n} 个键孤儿(文件未删除)`. **Files are never deleted.**
|
|
1047
|
+
- `fileOrphans` are **never** auto-removed, under any flag:
|
|
1048
|
+
`按裁定不提供自动清理(避免误删历史源码);请人工确认后处理`
|
|
1049
|
+
- Reporting (always, regardless of flags) — emitted only when at least one list is non-empty:
|
|
1050
|
+
```
|
|
1051
|
+
(blank line)
|
|
1052
|
+
孤儿 op 对账:
|
|
1053
|
+
键孤儿(有注册无文件):{items} # keyOrphans only, joined by ", "
|
|
1054
|
+
文件孤儿(有文件无注册): {n} # fileOrphans count (note the space after the colon)
|
|
1055
|
+
文件孤儿(有文件无注册):{first 5 joined by ", "}{' …' if more}
|
|
1056
|
+
按裁定不提供自动清理(避免误删历史源码);请人工确认后处理
|
|
1057
|
+
```
|
|
1058
|
+
and, when pruning actually ran, `已清理 {n} 个键孤儿(文件未删除)`
|
|
1059
|
+
- `RepairResult.orphans` is **undefined** whenever `repair` takes an early-return path (flow.json
|
|
1060
|
+
missing/recreated, unreadable, unparsable-and-rebuilt), so the orphan report is skipped in those cases
|
|
1061
|
+
even though a reconciliation would have found entries.
|
|
1062
|
+
- Repair never touches `op.state`, and bumps `meta.revision` by 1 on write (`recovered` cases write with
|
|
1063
|
+
`backup: false`, so the corrupted file is not copied over a good `.bak`).
|
|
1064
|
+
|
|
1065
|
+
### 8.3 File orphans — the repair command is `plan scheme register`
|
|
1066
|
+
|
|
1067
|
+
```
|
|
1068
|
+
openfeel plan scheme register <stage> [opId] [--dry-run]
|
|
1069
|
+
```
|
|
1070
|
+
|
|
1071
|
+
- no `opId` → register every file orphan of that stage, sorted ascending
|
|
1072
|
+
- with `opId` → only that one:
|
|
1073
|
+
- already registered → `noop` → `{opId} 已注册,无需补注册` (exit 0)
|
|
1074
|
+
- no matching file (or the file name is not `op-\d+`) → `skipped` → CLI prints
|
|
1075
|
+
`未找到:阶段 {stage} 或操作方案 {opId}` + **exit 1**
|
|
1076
|
+
- registration writes the §2.4 literal, taking `title` from `extractTitle` (content first line →
|
|
1077
|
+
legacy filename → bare filename), logs `register_op`, `save()` once at the end
|
|
1078
|
+
- `--dry-run` computes the same result with **zero writes** (revision unchanged)
|
|
1079
|
+
- output: `已补注册 {count} 个操作方案:{ops}(阶段 {stage})` / `[DRY-RUN] 将补注册 …` /
|
|
1080
|
+
`阶段 {stage} 无待补注册的操作方案`
|
|
1081
|
+
- stage not present in `flow.json` (`ok === false`) → `未找到:阶段 {stage} 或操作方案 {opId}` + exit 1
|
|
1082
|
+
|
|
1083
|
+
### 8.4 Health item 8
|
|
1084
|
+
|
|
1085
|
+
`healthCheck` adds **one** item when either list is non-empty:
|
|
1086
|
+
|
|
1087
|
+
```
|
|
1088
|
+
section: '孤儿操作方案' (flow.health.orphanOps)
|
|
1089
|
+
status: 'warn'
|
|
1090
|
+
message: '键孤儿 {n} 个、文件孤儿 {m} 个;运行 flow repair 查看详情(按裁定不提供自动清理)'
|
|
1091
|
+
+ ' [{stage}.{opId}; …(first 5, " …" if more)]' # only when n > 0
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
When both lists are empty **no item is emitted at all** (output stays terse). `warn` never affects
|
|
1095
|
+
`ok`/exit code; `--fix` never repairs orphans.
|
|
1096
|
+
|
|
1097
|
+
---
|
|
1098
|
+
|
|
1099
|
+
## 9. Stage lifecycle commands
|
|
1100
|
+
|
|
1101
|
+
There are **five** distinct entry points; they differ in what they write.
|
|
1102
|
+
|
|
1103
|
+
| Command | Directory + skeletons | `flow.json` registration | Deps validated | `workflow_mode` written |
|
|
1104
|
+
| --- | --- | --- | --- | --- |
|
|
1105
|
+
| `plan stage add <name>` | **yes** (`ensureStageSkeleton`, idempotent) | yes (`registerStage`) | yes | yes — **only if the key was new** |
|
|
1106
|
+
| `flow stage add <stageId>` | no | yes (`addStage`) | no (`deps: []`) | no |
|
|
1107
|
+
| `stage create <stageId>` | no | yes (`addStage`) | no | no (TTY-only deprecation notice on stderr) |
|
|
1108
|
+
| `plan scheme create <stage> <title>` | yes, implicitly, **only if the stage was unregistered** | yes, implicitly | no | no |
|
|
1109
|
+
| `plan scheme create --from-tasks` | via per-task `createScheme` | via `createScheme` | no | no |
|
|
1110
|
+
|
|
1111
|
+
### 9.1 `plan stage add <name> [--deps …] [--tasks …] [--exec-mode …] [--auto-advance …] [--workflow-mode …]`
|
|
1112
|
+
|
|
1113
|
+
Full-fidelity creation. Gate order (each failure exits **1** with zero writes):
|
|
1114
|
+
|
|
1115
|
+
1. `validateStageId(name)`; invalid → `{input} → 非法阶段 ID(应为 stage-NN 或 vX.Y.Z-stage-NN)` +
|
|
1116
|
+
`建议名称:{suggest}`. `suggestStageId` returns `{version}-stage-{NN}` where `NN = max(used)+1`
|
|
1117
|
+
padded to 2 digits, over `flow.json` keys **of that series** ∪ `plan/{series}/stage-NN` directories;
|
|
1118
|
+
`version` = the input when it *is* a version literal (`/^v\d+(?:\.\d+)*$/`), else `v1.0.0`.
|
|
1119
|
+
2. `--exec-mode` ∉ `['manual','auto']` → `字段 执行模式 的取值非法:{value}(允许:manual, auto)`
|
|
1120
|
+
3. `--auto-advance` ∉ `['disabled','enabled']` → same template with `自动推进` / `disabled, enabled`
|
|
1121
|
+
4. `--workflow-mode` ∉ `['full','fast','general']` → same template with `工作流模式`
|
|
1122
|
+
5. `--workflow-mode === 'general'` → `general(通用模式)仅限需求级——阶段级 / 部署级不支持;阶段请使用 fast / full。`
|
|
1123
|
+
(**checked after** the domain check, so `general` produces this message, not the invalid-value one)
|
|
1124
|
+
6. deps validation (§7.2) → `依赖阶段不存在:{deps}(已注册阶段:{known})`
|
|
1125
|
+
7. `addStage(projectPath, name, deps?, tasks?, overrides?)`:
|
|
1126
|
+
- `ensureStageSkeleton` — creates the directory, `overview.md`, `status.md` **only when absent**
|
|
1127
|
+
(existing files are never overwritten, so a re-run cannot clobber content; the return value
|
|
1128
|
+
`created` says whether anything was written)
|
|
1129
|
+
- if `flow.json` is loaded: `existed = !!stages[fullStageId]`; `registerStage(fullStageId, deps ?? [])`;
|
|
1130
|
+
if `!existed` set `stages[fullStageId].workflow_mode = overrides.workflowMode ?? resolveStageWorkflowMode(fullStageId).value`;
|
|
1131
|
+
`save()`
|
|
1132
|
+
- **Because `registerStage` is idempotent and returns early on an existing key, re-running
|
|
1133
|
+
`plan stage add` on an existing stage does NOT update `deps`.** Use `flow stage set --deps`.
|
|
1134
|
+
8. `StageDirConflictError` → `阶段目录冲突:{stage} 与 {other} 映射同一目录` + exit 1 (§10).
|
|
1135
|
+
Concurrency conflict → exit **2**.
|
|
1136
|
+
9. success → `已创建阶段: {name}`
|
|
1137
|
+
|
|
1138
|
+
`--deps` / `--tasks` accept both space-separated and comma-separated values (each token is split again on
|
|
1139
|
+
the single character `,`, then trimmed, empties dropped).
|
|
1140
|
+
|
|
1141
|
+
**Uninitialised project:** `plan stage add` does **not** require `flow.json`. `ensureStageSkeleton` still
|
|
1142
|
+
creates the directory and both skeletons (falling back to `resolveConfigDefaults` for the initial field
|
|
1143
|
+
values), the registration block is skipped, and the command exits 0 with `已创建阶段: {name}`. The stage
|
|
1144
|
+
therefore exists on disk but not in `flow.json` until `flow stage add` / `plan scheme register` is run.
|
|
1145
|
+
|
|
1146
|
+
### 9.2 `flow stage add <stageId>` / `stage create <stageId>`
|
|
1147
|
+
|
|
1148
|
+
Registration only — no directory, no skeletons. `stage create` prints the deprecation notice
|
|
1149
|
+
|
|
1150
|
+
```
|
|
1151
|
+
[deprecated] `openfeel stage create` 已弃用;请改用 `openfeel flow stage add <stageId>`(仅注册)或 `openfeel plan stage add <name>`(完整入口)
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
on **stderr**, and only when `process.stdout.isTTY` (silent in CI / piped output). Both:
|
|
1155
|
+
`validateStageId` → `mgr.addStage(stageId)` → `save()`. `addStage` also sets
|
|
1156
|
+
`pipeline.current = {stage: stageId, op: ''}`. Success: `✓ 已创建阶段: {stage} → plan_pending`.
|
|
1157
|
+
Errors funnel through `handleAddStageError`: concurrency → exit 2; dir conflict → exit 1; else generic.
|
|
1158
|
+
Both require `flow.json` to be loadable; otherwise `流水线未初始化(flow.json 不存在)` + exit 1.
|
|
1159
|
+
|
|
1160
|
+
### 9.3 `stage set <stageId> [--status] [--exec-mode] [--auto-advance] [--workflow-mode] [--review-agent]`
|
|
1161
|
+
|
|
1162
|
+
Manipulates **status.md fields only** (`flow.json` is touched only for the `workflow_mode` redundancy).
|
|
1163
|
+
|
|
1164
|
+
- missing `status.md` (3-level `findStageStatusPath`) → `错误:未找到阶段 "{stageId}" 的 status.md 文件` +
|
|
1165
|
+
`请确认阶段 ID 是否正确(如 v4-stage-04)` + exit 1
|
|
1166
|
+
- no option at all → `请至少提供一个字段选项(--status/--exec-mode/--auto-advance/--workflow-mode/--review-agent)` + exit 1
|
|
1167
|
+
- **fixed write order**: `状态` → `执行模式` → `自动推进` → `工作流模式` → `当前责任 Agent`
|
|
1168
|
+
- value domains: `状态` ∈ `STAGE_STATUS_VALUES`; `执行模式` ∈ `{manual, auto}`; `自动推进` ∈
|
|
1169
|
+
`{enabled, disabled}`; `工作流模式` ∈ `{full, fast, general}`; `--review-agent` must be non-empty after trim
|
|
1170
|
+
- `--workflow-mode general` → `general(通用模式)仅限需求级——阶段级 / 部署级不支持;阶段请使用 fast / full。`
|
|
1171
|
+
+ exit 1 **before** any write (**this is the per-stage "general is illegal" rule**; `fast`/`full` are the
|
|
1172
|
+
only legal per-stage values). Note the check is on the literal string `'general'`, so a bogus value
|
|
1173
|
+
instead reaches the domain check and yields `字段 工作流模式 的取值非法:…`
|
|
1174
|
+
- any domain violation → `字段 {field} 的取值非法:{value}(允许:{a} / {b})` (**separator is `' / '` here**,
|
|
1175
|
+
unlike `plan stage add`, which joins with `', '`) + exit 1, zero writes
|
|
1176
|
+
(validation of **all** jobs happens before **any** write)
|
|
1177
|
+
- per-field write protocol: **probe first, then write**
|
|
1178
|
+
- `not-found` → for `工作流模式` only, switch to *insert* semantics:
|
|
1179
|
+
field already present (race) → `backup` + replace; anchor found (`自动推进` line, else `执行模式` line)
|
|
1180
|
+
→ insert `- **工作流模式**:{value}` immediately after the anchor, preserving the anchor line's EOL,
|
|
1181
|
+
then `backup` + write, print `✓ 已在 {stageId} 的 status.md 插入「{field}」字段:{value}`;
|
|
1182
|
+
no anchor → `{stageId}:status.md 缺少「{field}」插入锚点(「自动推进」/「执行模式」行均缺失),未写入` + exit 1
|
|
1183
|
+
- `not-found` for any other field → `错误:在 {stageId} 的 status.md 中未找到「{field}」字段` + exit 1
|
|
1184
|
+
- `unchanged` → `字段 {field} 已是目标值,未做变更` (exit 0, **no `.bak` produced**)
|
|
1185
|
+
- `will-change` → `backup` then surgical replace, print `✓ 已更新 {stageId} {field}: → {value}`
|
|
1186
|
+
- if a later job fails after earlier successes → `已写入:{done};失败于:{field}` then the specific error, exit 1
|
|
1187
|
+
- `工作流模式` success additionally calls `syncWorkflowModeRedundancy`: if the stage is registered, set
|
|
1188
|
+
`stages[key].workflow_mode = value` and `save()` (unregistered stages are skipped silently)
|
|
1189
|
+
- **contradiction to preserve:** `stage set --status` writes status.md only, while `flow advance` writes
|
|
1190
|
+
`flow.json.stages[].status` only. The two can diverge; `flow health` item 2 reports the divergence as a
|
|
1191
|
+
warn and `flow health --fix` realigns **status.md ← flow.json** (`reconcileStatusMd`).
|
|
1192
|
+
|
|
1193
|
+
### 9.4 `stage task <stageId> [taskNo] --done|--undone` / `--add <desc>`
|
|
1194
|
+
|
|
1195
|
+
- `--add` with `--done`/`--undone` → `错误:--add 与 --done/--undone 互斥` + exit 1
|
|
1196
|
+
- `--add` alone → `appendStatusTask` (§3.3) → `已追加任务 {no}:{desc}`; `TASK_SECTION_MISSING` →
|
|
1197
|
+
`status.md 缺少任务小节,请先运行 openfeel plan stage add 生成骨架` + exit 1
|
|
1198
|
+
- neither `--add` nor a `taskNo` → `请提供任务编号或使用 --add "<描述>"` + exit 1
|
|
1199
|
+
- `--done === --undone` → `错误:必须指定 --done 或 --undone(二者互斥)` + exit 1
|
|
1200
|
+
- non-integer or `< 1` task number → `错误:任务编号必须为正整数` + exit 1
|
|
1201
|
+
- checkbox toggle: `backup` first, then regex `^(- \[)([ x])(\] 任务{N}[::].*)$` (multiline, global);
|
|
1202
|
+
no match → `错误:在 {stageId} 的 status.md 中未找到「任务{N}」` + exit 1
|
|
1203
|
+
- success: `✓ 已勾选 {stageId} 任务{N}` / `○ 已取消勾选 {stageId} 任务{N}` (exit 0, idempotent)
|
|
1204
|
+
|
|
1205
|
+
### 9.5 `flow stage set <stageId> [--deps …]`
|
|
1206
|
+
|
|
1207
|
+
Deps-only overwrite. Gate order: `validateStageId` → manager loaded → normalised deps → §7.2 validation
|
|
1208
|
+
(`依赖校验失败:以下阶段不存在 {deps}`) → target must exist (`{input} → 非法阶段 ID(…)`) → `setStageDeps`
|
|
1209
|
+
(log `stage_deps_set`, `save()`). Success: `已更新阶段 {stage} 的依赖:{deps}` or `…:无`.
|
|
1210
|
+
**Omitting `--deps` clears the list.** Concurrency → exit 2.
|
|
1211
|
+
|
|
1212
|
+
### 9.6 `flow stage remove <stageId> [--force] [--dry-run] [--purge]`
|
|
1213
|
+
|
|
1214
|
+
`checkRemovable` (read-only), evaluated in this order:
|
|
1215
|
+
|
|
1216
|
+
1. ops non-empty → `阶段 '{stage}' 仍有 {opCount} 个未归档的 op;如需强制移除此阶段,请加 --force`
|
|
1217
|
+
2. `pipeline.current.stage === stage` → `阶段 '{stage}' 是当前活跃阶段;如需强制移除,请加 --force(将自动回退 current)`
|
|
1218
|
+
3. other stages reference it in `deps` → `阶段 '{stage}' 被其它阶段依赖:{list};如需强制移除(将产生悬空依赖),请加 --force`
|
|
1219
|
+
|
|
1220
|
+
`--force` skips all three (still reporting `opCount` / `isCurrent` / `referencing`).
|
|
1221
|
+
|
|
1222
|
+
Command flow:
|
|
1223
|
+
|
|
1224
|
+
- stage not registered → `阶段不存在:{stage}` + exit 1 (checked before `--dry-run`)
|
|
1225
|
+
- `--dry-run` → prints the block
|
|
1226
|
+
```
|
|
1227
|
+
─── Dry-run 预览 ───
|
|
1228
|
+
阶段: {stage}
|
|
1229
|
+
ops: {opCount}
|
|
1230
|
+
current: {current}{ ' ←' if isCurrent }
|
|
1231
|
+
引用者 (deps): {list | 无}
|
|
1232
|
+
```
|
|
1233
|
+
then, if `!ok`, the reason via the generic error template + exit 1; else
|
|
1234
|
+
`✓ 预期可移除(未写盘);去掉 --dry-run 正式执行` (exit 0)
|
|
1235
|
+
- `--purge`:
|
|
1236
|
+
- non-TTY **and** no `--force` → `删除目录需交互确认;非 TTY 环境请显式加 --force` + exit 1
|
|
1237
|
+
- TTY **and** no `--force` → interactive confirm (default **no**); declined → `已取消`, exit 0
|
|
1238
|
+
- transaction order (**important**): `removeStage` mutates memory (deregisters the stage, computes
|
|
1239
|
+
`purgeTarget` **without deleting**) → `save()` → only then `rmSync(purgeTarget, {recursive:true, force:true})`.
|
|
1240
|
+
A failed `save()` therefore never leaves a deleted directory behind a live registration.
|
|
1241
|
+
- `removeStage` side effects:
|
|
1242
|
+
- `delete stages[stageKey]`
|
|
1243
|
+
- if `pipeline.current.stage === stageKey`: fall back to the **first remaining key in insertion order
|
|
1244
|
+
whose `phase !== 'done'`** (with `op` = its first `pending`/`executing` op, else `''`); if none exist
|
|
1245
|
+
→ `pipeline.current = {stage: '', op: ''}`
|
|
1246
|
+
- **dangling references are NOT cleaned up** — other stages keep the removed id in `deps`
|
|
1247
|
+
- log `remove_stage` with `{stageId, purgeTarget, referencing, snapshot:{phase, status, deps, opKeys}}`
|
|
1248
|
+
- messages: `✓ 已移除阶段: {stage}`, plus `✓ 已删除对应 plan 目录` when purged
|
|
1249
|
+
|
|
1250
|
+
### 9.7 `flow stage reset <stageId> --to <phase> [--dry-run]`
|
|
1251
|
+
|
|
1252
|
+
Deliberate phase rewind/forward, **bypassing the transition table** (any legal `PipelinePhase` is
|
|
1253
|
+
accepted), but not the `done` gate.
|
|
1254
|
+
|
|
1255
|
+
- `--to` is a `requiredOption`
|
|
1256
|
+
- `to` not in `PIPELINE_PHASES` → `非法 phase '{to}'` (**no fuzzy correction**, unlike `advance`) + exit 1
|
|
1257
|
+
- `to === 'done'` reuses the blocking-REV gate:
|
|
1258
|
+
`无法复位到 done:存在 {n} 个未解决的阻塞 REV:\n {id}: {title} (priority={priority})` + exit 1.
|
|
1259
|
+
`--force` does **not** exist on `reset`, so it cannot be bypassed at all.
|
|
1260
|
+
- idempotence: `changed = (from !== to) || (status !== projectedStatus)`; when unchanged → prints
|
|
1261
|
+
`阶段 {stage} 已处于 {phase},无需复位` (exit 0) and writes nothing
|
|
1262
|
+
- `--dry-run` → `─── 复位预览(未写盘)───` + ` {stage}: {from} → {to}(status → {status})`; never writes,
|
|
1263
|
+
never requires confirmation
|
|
1264
|
+
- on write: `phase = to`; `status = mapPhaseToStageStatus(to, oldStatus)`; log `reset_stage_phase`
|
|
1265
|
+
`{stageName, from, to, session_id, resumed?}` (agent `flow-manager`); `syncRequirementStatus`;
|
|
1266
|
+
`syncCurrentOp` **only if** `pipeline.current.stage` normalises to this stage; `pipeline.phase`
|
|
1267
|
+
recomputed (`allDone && stages.length>0 ? 'done' : 'active'`)
|
|
1268
|
+
- **`reset` does not write a checkpoint snapshot** and **does not trigger the archive git commit**
|
|
1269
|
+
- success: `✓ 已复位阶段 {stage}: {from} → {to}`; concurrency → exit 2
|
|
1270
|
+
|
|
1271
|
+
### 9.8 `stage status [stageId]`
|
|
1272
|
+
|
|
1273
|
+
Read-only. Without an argument it globs `plan/**/status.md`, derives the id by reverse-mapping the
|
|
1274
|
+
directory name (`planDirToStageId`, which prefers `pipeline.current.stage` among matches, else the
|
|
1275
|
+
lexicographically greatest version prefix), and prints
|
|
1276
|
+
`发现 {n} 个阶段` then ` {icon} {stageId} → {status}` where the icon is `✅` done / `🔧 coding` /
|
|
1277
|
+
`📋 planned` / two spaces otherwise, then a hint line. Empty → `未发现任何阶段(.openfeel/plan/ 中无 status.md 文件)`.
|
|
1278
|
+
With an argument it prints the §3.3 managed-field block plus tasks and a blocking reason
|
|
1279
|
+
(`阻塞原因: {reason}`, suppressed for `无`/empty). Missing stage → `错误:未找到阶段 "{stageId}" 的 status.md 文件`
|
|
1280
|
+
+ `请确认阶段 ID 是否正确(如 v4-stage-04)` + exit 1.
|
|
1281
|
+
|
|
1282
|
+
---
|
|
1283
|
+
|
|
1284
|
+
## 10. `StageDirConflictError`
|
|
1285
|
+
|
|
1286
|
+
### 10.1 Definition
|
|
1287
|
+
|
|
1288
|
+
```ts
|
|
1289
|
+
export class StageDirConflictError extends Error {
|
|
1290
|
+
readonly stage: string; // the id being registered
|
|
1291
|
+
readonly other: string; // the already-registered id occupying the same (series, stageDir)
|
|
1292
|
+
constructor(stage: string, other: string) {
|
|
1293
|
+
super(`阶段目录冲突:'${stage}' 与 '${other}' 映射同一 (series, stageDir),请改用其它 stage-NN`);
|
|
1294
|
+
this.name = 'StageDirConflictError';
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
### 10.2 When it fires
|
|
1300
|
+
|
|
1301
|
+
The condition is **`parseStageId(a).series === parseStageId(b).series && .stageDir === .stageDir` for two
|
|
1302
|
+
*different* keys** — i.e. two distinct full ids that map to the same directory. Canonical example:
|
|
1303
|
+
|
|
1304
|
+
```
|
|
1305
|
+
flow.json already has "v4-stage-04" → .openfeel/plan/v4/stage-04/
|
|
1306
|
+
now registering "v4.0.0-stage-04" → .openfeel/plan/v4/stage-04/ ⇒ conflict
|
|
1307
|
+
```
|
|
1308
|
+
|
|
1309
|
+
Both ids are individually *valid*; only the collision is illegal. Detection is
|
|
1310
|
+
`findStageDirConflict(projectPath, stageId)`, which reads `flow.json` from disk and returns the first
|
|
1311
|
+
(other) colliding key, or `null` when there is no conflict, the input is unparseable, `flow.json` is
|
|
1312
|
+
missing, or `flow.json` is unreadable (never blocks on IO failure).
|
|
1313
|
+
|
|
1314
|
+
Thrown from exactly two places:
|
|
1315
|
+
|
|
1316
|
+
| Thrower | Reached from |
|
|
1317
|
+
| --- | --- |
|
|
1318
|
+
| `FlowManager.registerStage` | `plan stage add` (via `addStage` in `plan/stage.ts`) |
|
|
1319
|
+
| `FlowManager.addStage` | `flow stage add`, `stage create` |
|
|
1320
|
+
|
|
1321
|
+
`registerStage` returns early on an **existing** key, so re-registering the same id never conflicts.
|
|
1322
|
+
`createScheme`'s implicit registration does **not** throw — it warns and skips (§2.5), leaving the op
|
|
1323
|
+
file as a file orphan.
|
|
1324
|
+
|
|
1325
|
+
### 10.3 Rendering and remedy
|
|
1326
|
+
|
|
1327
|
+
The CLI does not print `err.message`; it matches the error *type* and renders the i18n template:
|
|
1328
|
+
|
|
1329
|
+
```
|
|
1330
|
+
阶段目录冲突:{stage} 与 {other} 映射同一目录
|
|
1331
|
+
```
|
|
1332
|
+
|
|
1333
|
+
then `process.exit(1)`. (`plan stage add` interpolates `err.stage` / `err.other` directly; `flow stage
|
|
1334
|
+
add` and `stage create` go through `handleAddStageError`, same text.) Concurrency conflicts in the same
|
|
1335
|
+
code paths exit **2** instead.
|
|
1336
|
+
|
|
1337
|
+
**Remedy:** use a different `stage-NN` (`suggestStageId` proposes the next free number for the series),
|
|
1338
|
+
or reference the stage by the id already registered (`v4-stage-04`, not `v4.0.0-stage-04`). There is no
|
|
1339
|
+
merge/rename command: the two ids are not reconcilable, because the directory is derived from
|
|
1340
|
+
`(series, stageDir)` and carries no version prefix.
|
|
1341
|
+
|
|
1342
|
+
### 10.4 Two non-conflicts that look like conflicts
|
|
1343
|
+
|
|
1344
|
+
Both are legitimate and must **not** raise the error:
|
|
1345
|
+
|
|
1346
|
+
| Case | Why it is fine |
|
|
1347
|
+
| --- | --- |
|
|
1348
|
+
| Directory already exists on disk, but no `flow.json` key maps to it | Conflict detection reads only `flow.json` keys; an unregistered directory is not an occupant. `ensureStageSkeleton` is idempotent and simply reuses it. |
|
|
1349
|
+
| `stage-05` vs `stage-005` | `parseStageId` preserves the captured digits verbatim (no `parseInt`), so `stageDir` differs (`stage-05` ≠ `stage-005`) and the paths differ. Two *different* directories, no error — even though a human considers them the same stage. |
|
|
1350
|
+
|
|
1351
|
+
Conversely, `v1.0.0-stage-05` and `v1.0.1-stage-05` **do** conflict: both normalise to
|
|
1352
|
+
`series = 'v1'`, `stageDir = 'stage-05'`, hence the same directory `plan/v1/stage-05/`. The version
|
|
1353
|
+
prefix is not part of the directory, which is precisely why two ids can collide.
|
|
1354
|
+
|
|
1355
|
+
---
|
|
1356
|
+
|
|
1357
|
+
## 11. Cross-cutting invariants and the traps most likely to be missed
|
|
1358
|
+
|
|
1359
|
+
1. **`op.id` is not persisted.** It is injected on load and stripped on save. Writing it changes the file.
|
|
1360
|
+
2. **`ops` map keys are local (`op-001`), stage keys are full (`v1.2.1-stage-92`).** Global op reference
|
|
1361
|
+
is `"{stageId}.{opId}"`; `parseOpId` splits on the **last** `.` so version dots do not confuse it.
|
|
1362
|
+
3. **`executing` is declared but unreachable.** Recognised by every predicate, written by none.
|
|
1363
|
+
4. **`attempts` is never reset; `max_attempts` is never configurable; `checkpoints.exec.attempts` is
|
|
1364
|
+
never incremented.** Exhaustion is reported, not refused.
|
|
1365
|
+
5. **`recordAttempt` loses `syncCurrentOp` in the exhausted branch.** After the final failure the pointer
|
|
1366
|
+
keeps aiming at the now-`failed` op until something else triggers a sync (`advancePhase('scheme_pending')`
|
|
1367
|
+
from `flow attempt`, or the next `advanceStagePhase`). Reproduce deliberately or fix deliberately.
|
|
1368
|
+
6. **`flow attempt --result pass` on a nonexistent op prints a success message and exits 0.**
|
|
1369
|
+
7. **`更新时间` in `status.md` is UTC** (derived from `toISOString()`), minute precision, no `Z`.
|
|
1370
|
+
`stats.start_time` / `attempted_at` / `completed_at` are full UTC ISO strings with `Z`.
|
|
1371
|
+
8. **`plan stage add` is not an upsert.** For an existing stage it silently ignores `--deps` and never
|
|
1372
|
+
rewrites `overview.md` / `status.md` / `workflow_mode`.
|
|
1373
|
+
9. **`--draft` writes `- **状态**:pending` into the op file while `flow.json` says `draft`.**
|
|
1374
|
+
10. **Reset and advance differ in two ways that look like bugs:** `advance` fuzzy-corrects an illegal
|
|
1375
|
+
phase and snapshots a checkpoint; `reset` rejects an illegal phase outright and writes no snapshot.
|
|
1376
|
+
11. **`stage set` writes `status.md`; `flow advance` writes `flow.json.stages[].status`.** They are
|
|
1377
|
+
independent; `flow health` reports divergence and `flow health --fix` syncs only the `状态` field,
|
|
1378
|
+
only status.md ← flow.json, never the reverse.
|
|
1379
|
+
12. **`plan stage list` lists version sub-directories as if they were stages** (§3.1 hazard).
|
|
1380
|
+
13. **`flow ops list --stage <short>` only works for `v1.0.0-*` stages**: the filter compares the strict
|
|
1381
|
+
normalised id (`stage-92` → `v1.0.0-stage-92`), with no double-key fallback, so `v1.2.1-stage-92`
|
|
1382
|
+
is not matched.
|
|
1383
|
+
14. **`deps` never gates anything.** No ordering, no cycle detection, no completeness check before
|
|
1384
|
+
`done`; only dangling-reference validation at write time and warn-only reporting afterwards.
|
|
1385
|
+
15. **`flow health` can fail (`fail`, non-zero exit) only via** invalid `pipeline.phase`, a `current`
|
|
1386
|
+
pointing at a missing stage/op, an illegal `stage.phase`, a `config.yaml` / `pipeline.yaml` parse or
|
|
1387
|
+
schema error, or a `deps.yaml` cycle. Everything stage/op-lifecycle related is `warn`.
|
|
1388
|
+
16. **Checkpoint retention is 20 per stage, enforced by filename sort, best-effort** — and snapshot
|
|
1389
|
+
writes are entirely swallowed on failure, so a missing snapshot is never an error.
|
|
1390
|
+
17. **`restore` re-projects `status` from the snapshot's `phase`** in selective mode (and re-bases
|
|
1391
|
+
`meta.revision` to `diskRevision + 1` in both modes), so a restored stage never carries an
|
|
1392
|
+
inconsistent `status`.
|
|
1393
|
+
|
|
1394
|
+
---
|
|
1395
|
+
|
|
1396
|
+
## 12. Appendix — the artifact model (separate; not stage/op)
|
|
1397
|
+
|
|
1398
|
+
For completeness, since `src/core/artifact-graph/*` lives next to the plan code but is a distinct model.
|
|
1399
|
+
|
|
1400
|
+
```ts
|
|
1401
|
+
// src/core/schema.ts
|
|
1402
|
+
const DependencySchema = z.object({
|
|
1403
|
+
artifact: z.string(),
|
|
1404
|
+
type: z.enum(['hard', 'soft']).default('hard'),
|
|
1405
|
+
description: z.string().optional(),
|
|
1406
|
+
});
|
|
1407
|
+
const ArtifactSchema = z.object({
|
|
1408
|
+
id: z.string(),
|
|
1409
|
+
description: z.string().optional(),
|
|
1410
|
+
generates: z.string(), // glob of the produced file(s)
|
|
1411
|
+
template: z.string().optional(),
|
|
1412
|
+
requires: z.array(DependencySchema).default([]),
|
|
1413
|
+
dependsOn: z.array(z.string()).default([]), // shorthand, always hard
|
|
1414
|
+
instruction: z.string().optional(),
|
|
1415
|
+
metadata: z.record(z.string(), z.unknown()).default({}),
|
|
1416
|
+
});
|
|
1417
|
+
const SchemaSchema = z.object({
|
|
1418
|
+
name: z.string(),
|
|
1419
|
+
version: z.string().default('1.0'),
|
|
1420
|
+
description: z.string().optional(),
|
|
1421
|
+
artifacts: z.array(ArtifactSchema),
|
|
1422
|
+
}).superRefine(/* duplicate artifact id → issue */);
|
|
1423
|
+
```
|
|
1424
|
+
|
|
1425
|
+
- `collectHardDeps(artifact)` = `dependsOn` (all hard) ∪ `requires` where `type !== 'soft'`, de-duplicated
|
|
1426
|
+
- `new ArtifactGraph(schema)` throws
|
|
1427
|
+
`Artifact "{id}" 依赖了不存在的 artifact "{dep}"` for an unknown dependency target (**constructor-time**)
|
|
1428
|
+
- `getBuildOrder()` = Kahn BFS with a lexicographically sorted queue for stable output; if not all nodes
|
|
1429
|
+
are emitted it throws `检测到循环依赖,以下 artifact 陷入循环:{remaining}` (comma-joined)
|
|
1430
|
+
- `getNextArtifacts(completed)` = artifacts whose hard deps ⊆ `completed`, ordered by build order;
|
|
1431
|
+
`getBlocked(completed)` returns `{blocked:[{artifactId, missingDeps, description?}], ready, completed, total, done}`;
|
|
1432
|
+
`isComplete(completed)` is **vacuously true for an empty schema**
|
|
1433
|
+
- `detectCompletedArtifacts(schema, basePath)` = fast-glob each `generates` pattern; any hit ⇒ completed
|
|
1434
|
+
- `resolveSchema(name, projectPath)` search order: `.openfeel/schemas/{name}/schema.yaml` →
|
|
1435
|
+
`~/.openfeel/schemas/{name}/schema.yaml` → `node_modules/openfeel/schemas/{name}/schema.yaml`;
|
|
1436
|
+
otherwise throws `找不到 Schema "{name}": 已搜索路径 {paths}`
|
|
1437
|
+
- No CLI command in this repository consumes `ArtifactGraph`; the stage/op layer never reads `schemas/`.
|