openfeel 1.2.4 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.en.md +40 -74
  2. package/README.md +39 -62
  3. package/README.zh-CN.md +40 -74
  4. package/dist/commands/setup.d.ts.map +1 -1
  5. package/dist/commands/setup.js +90 -5
  6. package/dist/commands/setup.js.map +1 -1
  7. package/dist/commands/update.d.ts.map +1 -1
  8. package/dist/commands/update.js +7 -2
  9. package/dist/commands/update.js.map +1 -1
  10. package/dist/core/config.js +2 -2
  11. package/dist/core/dsh-profile.d.ts +93 -0
  12. package/dist/core/dsh-profile.d.ts.map +1 -0
  13. package/dist/core/dsh-profile.js +400 -0
  14. package/dist/core/dsh-profile.js.map +1 -0
  15. package/dist/core/i18n-data/en.d.ts.map +1 -1
  16. package/dist/core/i18n-data/en.js +21 -2
  17. package/dist/core/i18n-data/en.js.map +1 -1
  18. package/dist/core/i18n-data/zh-CN.d.ts.map +1 -1
  19. package/dist/core/i18n-data/zh-CN.js +21 -2
  20. package/dist/core/i18n-data/zh-CN.js.map +1 -1
  21. package/dist/core/setup.d.ts +12 -1
  22. package/dist/core/setup.d.ts.map +1 -1
  23. package/dist/core/setup.js +25 -10
  24. package/dist/core/setup.js.map +1 -1
  25. package/dist/core/template-loader.d.ts.map +1 -1
  26. package/dist/core/template-loader.js +18 -9
  27. package/dist/core/template-loader.js.map +1 -1
  28. package/dist/core/update.d.ts +2 -0
  29. package/dist/core/update.d.ts.map +1 -1
  30. package/dist/core/update.js +25 -7
  31. package/dist/core/update.js.map +1 -1
  32. package/dsh/ACTION-GUIDE.md +366 -0
  33. package/dsh/ADAPTER.md +65 -0
  34. package/dsh/README.md +264 -0
  35. package/dsh/cordis.patch.yml +26 -0
  36. package/dsh/docs/compat/commands.md +873 -0
  37. package/dsh/docs/compat/kb-skills.md +883 -0
  38. package/dsh/docs/compat/persistence.md +1013 -0
  39. package/dsh/docs/compat/pipeline.md +1565 -0
  40. package/dsh/docs/compat/req.md +1243 -0
  41. package/dsh/docs/compat/stage.md +1437 -0
  42. package/dsh/lib/adapters/cli.js +624 -0
  43. package/dsh/lib/adapters/dashboard.js +393 -0
  44. package/dsh/lib/adapters/workspace.js +150 -0
  45. package/dsh/lib/index.js +189 -0
  46. package/dsh/lib/surface/command.js +186 -0
  47. package/dsh/lib/surface/prompt.js +131 -0
  48. package/dsh/lib/tools/index.js +1245 -0
  49. package/dsh/lib/tools/shared.js +320 -0
  50. package/dsh/package.json +45 -0
  51. package/package.json +9 -2
@@ -0,0 +1,1013 @@
1
+ # OpenFeel — Persistence, Indexing & Version Management
2
+
3
+ **Behavioral specification for a byte-compatible reimplementation.**
4
+ Analyzed from `C:\Users\Liuary\Dev\Mine\AI\OpenFeel` (v1.2.1 tree, read-only).
5
+
6
+ Source anchors (all paths relative to the analyzed project root):
7
+
8
+ | Area | File |
9
+ |---|---|
10
+ | Atomic write primitive | `src/core/fs/atomic-write.ts` |
11
+ | Cross-process file lock | `src/core/fs/file-lock.ts` |
12
+ | Tolerant read helper | `src/core/fs/safe-read.ts` |
13
+ | Atomic sequence allocation | `src/core/fs/sequence.ts` |
14
+ | `flow.json` owner | `src/core/flow-manager.ts` |
15
+ | Version archive (schema) | `src/core/flow-archive/archive-file.ts` |
16
+ | Version archive (IO + index) | `src/core/flow-archive/archive-ops.ts` |
17
+ | Version archive (grouping/split) | `src/core/flow-archive/grouping.ts` |
18
+ | Version archive (health) | `src/core/flow-archive/archive-health.ts` |
19
+ | Version archive (CLI) | `src/commands/flow.ts` (`flow archive …`) |
20
+ | Stage summary archive | `src/core/archive/merge.ts`, `src/commands/archive.ts` |
21
+ | Metrics | `src/core/metrics.ts` |
22
+ | Workspace audit log | `src/core/public-logger.ts` |
23
+ | CLI runtime log | `src/core/runtime-log.ts`, `src/core/session.ts` |
24
+ | Workspace identity | `src/core/workspace/identity.ts` |
25
+ | Workspace directories | `src/core/workspace/structure.ts` |
26
+ | Update state | `src/core/update-state.ts` |
27
+ | Locked error rendering | `src/commands/shared/errors.ts` |
28
+
29
+ Conventions in this document: **MUST** / **MUST NOT** are conformance requirements. Strings given in `zh` / `en` are the exact user-visible i18n texts; `{name}` marks an interpolation slot.
30
+
31
+ ---
32
+
33
+ ## 1. The `.openfeel/` directory
34
+
35
+ Root: `<project>/.openfeel/`. Everything is UTF-8, no BOM.
36
+
37
+ ### 1.1 Created eagerly by `init` (`createWorkspace`, `src/core/workspace/structure.ts`)
38
+
39
+ `WORKSPACE_DIRS` = `plan`, `kb`, `dev`, `log`, `code_review`, `bugs`, `users`, `tmp`, `manual`, `update_conflicts`.
40
+ `DEV_SUB_DIRS` = `note`, `current_archive` (under `dev/`).
41
+
42
+ `createWorkspace()` `mkdir -p`s the root and each of the above, returning the relative paths it actually created (`.openfeel/`, `.openfeel/plan/`, …). Already-present directories are silently skipped.
43
+
44
+ | Path | Owner | Purpose |
45
+ |---|---|---|
46
+ | `.openfeel/` | all | Workspace root. |
47
+ | `.openfeel/flow.json` | `FlowManager` | **Single source of truth** for pipeline state (see §4). |
48
+ | `.openfeel/flow.json.bak` | `save()`/restore/repair | Byte copy of the **previous** `flow.json` (see §4.5). |
49
+ | `.openfeel/flow.json.v4.0.bak` | `FlowManager.migrate()` | One-shot pre-migration backup for the legacy-format migration only. |
50
+ | `.openfeel/.info.json` | `workspace/identity.ts` | Workspace identity: `{user, lang}` (see §11). |
51
+ | `.openfeel/config.yaml` | `core/config.ts` | Project config (`defaults.*`). Created by `init`; **never overwritten** if present. |
52
+ | `.openfeel/pipeline.yaml` | `FlowManager.loadPipelineConfig()` | Optional pipeline override (phases/transitions/checkpoint_mapping, optional `fast_transitions`, `phase_corrections`). **Not created by `init`**; absent ⇒ built-in defaults. |
53
+ | `.openfeel/models.template.yaml` | `openfeel model` | Generated export of the `models:` section. Optional. |
54
+ | `.openfeel/update_state.json` | `core/update-state.ts` | Per-project hash/conflict ledger for `openfeel update` (see §11.3). Git-ignored. |
55
+ | `.openfeel/flow-archive/` | `flow-archive/archive-ops.ts` | **Version archive**: `{version}.json` per archived version (§6). **Not** in `WORKSPACE_DIRS`; created on demand. |
56
+ | `.openfeel/checkpoints/` | `FlowManager.saveCheckpoint()` | `{stageId}-{yyyyMMddTHHmmssSSS}-{phase}.json` snapshots, 20 kept per stage. Created on demand. |
57
+ | `.openfeel/log/` | `public-logger.ts`, stage archive | Team-level audit log + `index.md` + `log.md` + `archive-{stage}.md` (§5). |
58
+ | `.openfeel/plan/` | `core/plan/*` | `{series}/{stageDir}/` with `status.md`, `ops/op-NNN.md`, `deps.yaml` (at `plan/deps.yaml`), `index.md`, `plan_index.md`, `plan_log.md`. |
59
+ | `.openfeel/roadmap/` | `core/plan/roadmap.ts` | `{version}.md` roadmaps. Created on demand (**not** in `WORKSPACE_DIRS` despite the `init.ts` comment). |
60
+ | `.openfeel/kb/` | `workspace/knowledge.ts` | Knowledge base (`index.md`, `architecture.md`, `patterns.md`, `setup.md`, `troubleshooting.md`). |
61
+ | `.openfeel/dev/` | `init` + agents | `dev_core.md`, `current.md`, `decisions.md`, `note/`, `current_archive/`. |
62
+ | `.openfeel/code_review/` | agents | `index.md`, `log.md`, `stage-NN.md`. |
63
+ | `.openfeel/bugs/` | agents | `index.md`, `archive.md`, `cli.md`, `config.md`, … |
64
+ | `.openfeel/users/` | agents + `flow-manager.ts:1245` | Per-user private area: `users/{username}/log/`. Git-ignored. |
65
+ | `.openfeel/tmp/` | FS layer | Scratch. Holds `locks/*.lock`, `session.json`, `status.{stageId}.bak`, misc fixtures. Git-ignored. |
66
+ | `.openfeel/manual/` | docs | User manual mirror. |
67
+ | `.openfeel/update_conflicts/` | `core/update.ts` | Conflict copies produced by `update`. Git-ignored. |
68
+ | `.openfeel/stages/` | **legacy** | Pre-`plan/` stage layout; read-only fallback (`plan/path.ts:321`). Never written. |
69
+ | `.openfeel/adapters/` | repo docs | Documentation-only (`README.md`); no CLI code reads it. |
70
+ | `.openfeel/metrics.json` | `core/metrics.ts` | Agent performance metrics (see §10). **Not** created by `init`; written on first `recordRun`+`save`. |
71
+
72
+ ### 1.2 Lock files
73
+
74
+ | Scope | Path | Names used |
75
+ |---|---|---|
76
+ | Project | `<project>/.openfeel/tmp/locks/{name}.lock` | `flow`, `log`, `flow-archive`, `kb`, `update-state`, `status-{stageDir}`, `scheme-{stageDir}`, `dev-last-{username}` |
77
+ | Global | `~/.openfeel/locks/{name}.lock` | `global-config`, `global-update-state`, `runtime-log` |
78
+
79
+ ### 1.3 Other global paths (`src/core/global-paths.ts`)
80
+
81
+ `~/.openfeel/config.json` (global pref: `{lang, projects}`), `~/.openfeel/update_state.json`, `~/.openfeel/update_infos.md`, `~/.openfeel/schemas/`, `~/.openfeel/backup/`, `~/.openfeel/cli/logs/`, `~/.config/openfeel/profile.yaml`, `~/.config/opencode/…`, `~/.claude/…`.
82
+
83
+ ---
84
+
85
+ ## 2. Atomic write primitive (`src/core/fs/atomic-write.ts`)
86
+
87
+ ### 2.1 Temp file naming
88
+
89
+ ```
90
+ buildTempName(filePath):
91
+ dir = dirname(filePath)
92
+ name = basename(filePath)
93
+ rand = randomBytes(6).toString('hex') // exactly 12 lowercase hex chars
94
+ return join(dir, `.${name}.${process.pid}.${rand}.tmp`)
95
+ ```
96
+
97
+ Example for `<prj>/.openfeel/flow.json` with pid `12345`: `<prj>/.openfeel/.flow.json.12345.a1b2c3d4e5f6.tmp`.
98
+
99
+ Properties a reimplementation must preserve: **same directory as the target** (guarantees same-volume rename), **leading dot** (hidden), **PID + 12 random hex** (multi-process uniqueness), **`.tmp` suffix**.
100
+
101
+ ### 2.2 Signature
102
+
103
+ ```ts
104
+ interface AtomicWriteOptions {
105
+ encoding?: BufferEncoding; // default 'utf-8' (ignored for Buffer content)
106
+ fsync?: boolean; // default true
107
+ backup?: boolean; // default false → copy old target to `${filePath}.bak` BEFORE writing
108
+ }
109
+
110
+ atomicWriteFileSync(filePath: string, content: string | Buffer, options?: AtomicWriteOptions): void
111
+ atomicWriteJson(filePath: string, obj: unknown, options?: AtomicWriteOptions): void
112
+ ```
113
+
114
+ ### 2.3 Algorithm (exact order)
115
+
116
+ 1. `mkdirSync(dirname(filePath), {recursive: true})`.
117
+ 2. If `backup === true` **and** `existsSync(filePath)`: `copyFileSync(filePath, filePath + '.bak')`. Any failure is **swallowed** (must not block the write). This is a plain copy, not atomic; no fsync. Because it happens before the rename, `.bak` always holds the *previous* on-disk version and is never touched again after a successful write.
118
+ 3. `tmpPath = buildTempName(filePath)`.
119
+ 4. `fd = openSync(tmpPath, 'w')`; in `try`: `writeFileSync(fd, content, encoding)`, then if `fsync` → `fsyncSync(fd)`; in `finally`: `closeSync(fd)`.
120
+ 5. `renameSync(tmpPath, filePath)` — replaces the target atomically (same directory ⇒ same volume).
121
+ 6. On **any** throw from steps 4–5: best-effort `unlinkSync(tmpPath)` (its own failure is ignored), then re-throw the original error.
122
+
123
+ `atomicWriteJson` = `atomicWriteFileSync(p, JSON.stringify(obj, null, 2) + '\n', opts)`.
124
+
125
+ ### 2.4 Byte-compatibility guarantees
126
+
127
+ * Content is written **verbatim**: no EOL normalization (`\r\n` stays `\r\n`), no BOM insertion, no character trimming. All "pretty" formatting is the *caller's* `JSON.stringify(x, null, 2) + '\n'`.
128
+ * 2-space indent, LF newlines, one trailing `\n`, no trailing whitespace. That is the single serialization format used by `flow.json`, `.bak`, checkpoints, `flow-archive/*.json`, `metrics.json`, `.info.json`, `update_state.json`, `~/.openfeel/config.json`.
129
+ * No directory fsync is performed anywhere ⇒ after a hard power loss the directory entry for the rename may not be durable. Do not claim crash-durability beyond the file data.
130
+
131
+ ### 2.5 Windows caveats
132
+
133
+ * `renameSync` maps to `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`: an existing read-only target yields `EPERM`/`EACCES`, an existing target held open by another process without `FILE_SHARE_DELETE` yields `EPERM`/`EBUSY`. **There is no retry loop** — the write throws and the temp file is removed.
134
+ * `fsyncSync` = `FlushFileBuffers`.
135
+ * `copyFileSync` for `.bak` can itself fail with `EBUSY` if `flow.json` is open elsewhere; failure is swallowed (no `.bak` for that write).
136
+ * Long paths: the temp name is longer than the target (`.<name>.<pid>.<12hex>.tmp` = target + 22 chars). Projects near `MAX_PATH` with `flow.json` can fail on the temp file rather than the target.
137
+ * Antivirus/indexers briefly opening the just-written temp file can make the immediate `rename` fail; since there is no retry, the operation fails and must be retried by the caller.
138
+
139
+ ---
140
+
141
+ ## 3. File lock protocol (`src/core/fs/file-lock.ts`)
142
+
143
+ Advisory (cooperative), synchronous, cross-process, **non-reentrant**.
144
+
145
+ ### 3.1 Constants
146
+
147
+ | Constant | Value | Meaning |
148
+ |---|---|---|
149
+ | `LOCK_TIMEOUT_MS_DEFAULT` | `5000` | Total acquisition budget. |
150
+ | `LOCK_STALE_MS_DEFAULT` | `3000` | Age beyond which a lock file is considered stale. |
151
+ | `LOCK_INITIAL_BACKOFF_MS_DEFAULT` | `10` | First sleep. |
152
+ | `LOCK_MAX_BACKOFF_MS_DEFAULT` | `500` | Backoff ceiling. |
153
+
154
+ Rationale recorded in the source: measured P99 critical section ≈ 8.14 ms on NTFS, so 3000 ms ≈ 368×P99; `staleMs < timeoutMs` so a crashed holder's residue can be reclaimed inside one wait window. Heartbeat renewal is explicitly rejected (the event loop is blocked during synchronous critical sections).
155
+
156
+ ### 3.2 Lock file content
157
+
158
+ `openSync(lockPath, 'wx')` (O_CREAT|O_EXCL) succeeds ⇒ we own it. Then a **compact** JSON (no whitespace) is written and the fd closed:
159
+
160
+ ```json
161
+ {"pid":12345,"time":1730000000000,"token":"9f8e7d6c5b4a3928"}
162
+ ```
163
+
164
+ `token = randomBytes(8).toString('hex')` (16 hex chars). `time` = `Date.now()`. There is **no** trailing newline, **no** indentation.
165
+
166
+ ### 3.3 Acquisition loop
167
+
168
+ ```
169
+ mkdirSync(dirname(lockPath), {recursive:true})
170
+ token = randomBytes(8).toString('hex')
171
+ deadline = Date.now() + timeoutMs
172
+ backoff = initialBackoffMs
173
+ loop:
174
+ try fd = openSync(lockPath,'wx'); write {pid,time:Date.now(),token}; closeSync(fd); break
175
+ catch e:
176
+ if e.code !== 'EEXIST' → throw e // permission/ENOENT/etc. surface immediately
177
+ tryReclaimStale(lockPath, staleMs) // best-effort, result ignored
178
+ if Date.now() >= deadline → throw new Error(`获取文件锁超时(${timeoutMs}ms):${lockPath}`)
179
+ jitter = 1 + (Math.random() - 0.5) * 0.4 // ∈ [0.8, 1.2)
180
+ sleepSync(min(backoff, maxBackoffMs) * jitter) // Atomics.wait on a SharedArrayBuffer (blocking, 0% CPU)
181
+ backoff = min(backoff * 2, maxBackoffMs)
182
+ ```
183
+
184
+ `sleepSync` uses `Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)` — a true synchronous block, not a busy loop.
185
+
186
+ The timeout message is hard-coded Chinese, **not** i18n'd.
187
+
188
+ ### 3.4 Stale-lock reclamation
189
+
190
+ ```
191
+ tryReclaimStale(lockPath, staleMs):
192
+ mtimeMs = statSync(lockPath).mtimeMs // throw → return false (already gone)
193
+ if Date.now() - mtimeMs <= staleMs → return false
194
+ stalePath = `${lockPath}.stale.${pid}.${randomBytes(4).toString('hex')}` // 8 hex
195
+ try renameSync(lockPath, stalePath) catch → return false // ENOENT = someone else won; EPERM/EBUSY = Windows holder alive
196
+ try unlinkSync(stalePath) catch → ignore
197
+ return true
198
+ ```
199
+
200
+ The `rename` is the atomic claim: exactly one concurrent process wins; losers see `ENOENT` and fall back to the retry loop. Reclaimed residue has a unique name so it never blocks later acquisitions.
201
+
202
+ ### 3.5 Release
203
+
204
+ In a `finally` around the critical section: re-read the lock file as JSON; **only if** `info.token === token` do `unlinkSync(lockPath)` (failure ignored). This prevents a process that overran its TTL from deleting a *newer* holder's lock.
205
+
206
+ ### 3.6 Conformance requirements & caveats
207
+
208
+ * **Non-reentrant.** Re-entering `withFileLock` for the same `lockPath` in the same process sees its own lock as `EEXIST`, cannot reclaim it (fresh mtime), and spins until `timeoutMs` → throws. Never nest the same lock name.
209
+ * **Never nested across features.** `flow` and `flow-archive` are deliberately separate lock names and MUST NOT be held simultaneously by one code path ("裁定 P"): the archive file is written under `flow-archive`, then `flow.json` is saved under `flow` — sequentially, never nested.
210
+ * **Readers do not lock.** `load()` / `readDiskRevision()` / archive reads / `flow archive list|show` take no lock at all; consistency comes from the atomic rename plus the revision CAS (§4.4).
211
+ * Any critical section longer than `staleMs` (3000 ms) risks having its lock stolen. All current critical sections are single small file read-modify-writes.
212
+ * Windows: (a) deleting an open lock file can fail → the lock lingers for up to 3 s until reclamation; (b) renaming a lock file held open by another process fails `EPERM`/`EBUSY` → treated as "not stale", retry until timeout; (c) a backwards system-clock jump makes `Date.now() - mtimeMs` small, postponing reclamation.
213
+ * Global locks exist so that *different projects* on one machine serialise cross-project writes (`~/.openfeel/config.json`, `~/.openfeel/update_state.json`, the CLI runtime log).
214
+
215
+ ---
216
+
217
+ ## 4. `flow.json` contract
218
+
219
+ ### 4.1 Path and top-level shape
220
+
221
+ `<project>/.openfeel/flow.json`
222
+
223
+ ```jsonc
224
+ {
225
+ "meta": {
226
+ "version": "1.0",
227
+ "project": "OpenFeel",
228
+ "updated": "2026-10-15T07:34:06.404Z", // ISO-8601 UTC, ms precision
229
+ "revision": 1187, // optional; absent ≡ 0
230
+ "archived_versions": ["v1.0.0", "v1.1.0"] // optional; absent ≡ []
231
+ },
232
+ "pipeline": {
233
+ "phase": "active", // MetaPhase: active | paused | done
234
+ "current": { "stage": "v1.2.1-stage-93", "op": "" },
235
+ "retry": 0
236
+ },
237
+ "stages": {
238
+ "v1.2.0-stage-78": {
239
+ "name": "v1.2.0-stage-78",
240
+ "phase": "done", // PipelinePhase enum
241
+ "status": "done",
242
+ "deps": [],
243
+ "ops": {
244
+ "op-001": {
245
+ "title": "…",
246
+ "state": "pending", // pending | executing | done | failed | draft
247
+ "assignee": "openfeel-executor",
248
+ "attempts": 0,
249
+ "max_attempts": 3,
250
+ "checkpoints": {
251
+ "plan": "pending", "scheme": "pending",
252
+ "exec": { "attempts": 0, "self": "pending" },
253
+ "review": "pending", "test": "pending"
254
+ },
255
+ "attempted_at": "…", // optional
256
+ "completed_at": "…" // optional
257
+ }
258
+ },
259
+ "stats": { "start_time": "…", "end_time": "…", "duration_ms": 4799534 }, // optional
260
+ "workflow_mode": "full", // optional redundant field
261
+ "requirement_id": null // optional
262
+ }
263
+ },
264
+ "reviews": [
265
+ {
266
+ "id": "REV-78-001",
267
+ "op": "v1.2.0-stage-78.op-001", // "<stageId>.<opId>" or "<stageId>"
268
+ "status": "open", // open | resolved | closed
269
+ "priority": "high", // high | medium | low
270
+ "title": "…",
271
+ "filed_by": "openfeel-reviewer",
272
+ "filed_at": "…",
273
+ "canAutoFix": true, // optional
274
+ "autoFixDetail": "…", // optional
275
+ "blocking": true // optional; absent ⇔ blocking
276
+ }
277
+ ],
278
+ "requirements": [], // optional; absent ≡ [], NOT repaired on load
279
+ "log": [
280
+ { "time": "…", "agent": "cli", "action": "advance_stage_phase",
281
+ "detail": { "stageName": "v1.2.0-stage-78", "from": "exec_running", "to": "review_pending" } }
282
+ ]
283
+ }
284
+ ```
285
+
286
+ **`requirements` ordering note:** the key is written only when set, so it typically appears after `reviews`/`log`. A reimplementation must not force a key that is absent.
287
+
288
+ **`op.id` is runtime-only.** On disk an `ops` entry never carries `id`; the map key is authoritative. `load()` injects `op.id = <map key>`; `save()`, `saveCheckpoint()` and `repair()` delete `id` from the deep-cloned snapshot before serializing. A reimplementation MUST NOT persist `id`.
289
+
290
+ ### 4.2 Serialization format
291
+
292
+ `JSON.stringify(obj, null, 2) + '\n'` written through `atomicWriteFileSync`.
293
+
294
+ * 2-space indentation, no tabs.
295
+ * LF only (JSON.stringify never emits other EOLs; content is not normalized).
296
+ * Exactly one trailing newline; no trailing spaces.
297
+ * **Key order = JS insertion order of the in-memory object**, which for a loaded file equals the parse order of the file. Implementations must therefore preserve object key order across load→save round-trips instead of re-sorting. The canonical *new-file* order is `meta, pipeline, stages, reviews, log`; inside `meta`: `version, project, updated, revision, archived_versions`; inside `pipeline`: `phase, current{stage,op}, retry`.
298
+ * Empty objects/arrays serialize as `{}` / `[]` (JSON.stringify semantics — no multi-line empty containers).
299
+ * Non-finite numbers become `null`; `undefined` values are dropped.
300
+
301
+ ### 4.3 Default template (`defaultFlowData()`, also `FlowManager.initFlow`)
302
+
303
+ ```json
304
+ {
305
+ "meta": { "version": "1.0", "project": "OpenFeel", "updated": "<now ISO>", "revision": 0 },
306
+ "pipeline": { "phase": "active", "current": { "stage": "-", "op": "init" }, "retry": 0 },
307
+ "stages": {},
308
+ "reviews": [],
309
+ "log": []
310
+ }
311
+ ```
312
+
313
+ `initFlow(projectPath)`: `mkdir -p .openfeel`; **if `flow.json` exists, return without touching it**; otherwise write the template with `revision: 0` via `atomicWriteFileSync` — **no lock** (first creation, no concurrent readers) and no `.bak`.
314
+
315
+ ### 4.4 Revision / optimistic concurrency (CAS)
316
+
317
+ State: `FlowManager.loadedRevision: number` (private), initialised `0`.
318
+
319
+ `extractRevision(data)` = `typeof data.meta.revision === 'number' && Number.isInteger(...) ? revision : 0`.
320
+ `readDiskRevision()` = read + `JSON.parse` the file and apply the same rule; **file missing, unreadable, or unparsable ⇒ `0`**.
321
+
322
+ `load()` sets `loadedRevision = extractRevision(parsed)` (i.e. the CAS baseline is the revision observed **at construction / last successful write**).
323
+
324
+ `save()` — the only routine write path:
325
+
326
+ ```
327
+ if (!this.data) return
328
+ this.data.meta ??= { version:'1.0', project:'', updated:'', revision:0 } // only if meta is entirely missing
329
+ this.data.meta.updated = new Date().toISOString() // mutates live object before cloning
330
+ serializable = JSON.parse(JSON.stringify(this.data)) // deep clone
331
+ for each stage, for each op: delete op.id
332
+
333
+ withFileLock(`.openfeel/tmp/locks/flow.lock`, () => {
334
+ diskRevision = readDiskRevision()
335
+ if (diskRevision !== this.loadedRevision)
336
+ throw new FlowConcurrentModificationError(this.loadedRevision, diskRevision)
337
+ nextRevision = this.loadedRevision + 1
338
+ serializable.meta.revision = nextRevision
339
+ content = JSON.stringify(serializable, null, 2) + '\n'
340
+ atomicWriteFileSync(filePath, content, { backup: true })
341
+ this.data.meta.revision = nextRevision
342
+ this.loadedRevision = nextRevision
343
+ })
344
+ catch (err) { console.error(`[ERROR] flow.json 保存失败: ${err.message}`); throw err }
345
+ ```
346
+
347
+ **Semantics a reimplementation must match:**
348
+
349
+ * Revision is a **monotonically increasing integer**, bumped by exactly `+1` on **every** successful `flow.json` write. There is no reset, no wrap, no "content hash".
350
+ * The CAS compares **exact equality** of `loadedRevision` against the on-disk integer. A file with a missing/invalid `revision` counts as `0`, so the first write of a legacy file goes `0 → 1`.
351
+ * The comparison and the write happen **inside the lock**, so the pattern is read-modify-write under an advisory lock **plus** an optimistic re-read (belt and braces).
352
+ * A reader that wants to write MUST adopt the same protocol: read the file, remember its integer `revision` as `expected`, perform the whole read-modify-write under `flow.lock`, re-read `revision`, and abort if it changed.
353
+ * An instance may `save()` repeatedly: after each success both the live object and `loadedRevision` are updated, so the next `save()` from the same process is not a false conflict.
354
+ * The in-memory `meta.updated` is set even if the write subsequently fails (the object is mutated before the lock).
355
+
356
+ **Conflict error**
357
+
358
+ ```ts
359
+ class FlowConcurrentModificationError extends Error {
360
+ readonly expectedRevision: number;
361
+ readonly actualRevision: number;
362
+ message = `flow.json 并发冲突:期望 revision=${expected},磁盘为 ${actual}(已被其它进程修改)`;
363
+ name = 'FlowConcurrentModificationError';
364
+ }
365
+ function isFlowConcurrentError(err): boolean // instanceof OR name === 'FlowConcurrentModificationError'
366
+ ```
367
+
368
+ The name-based fallback exists to survive duplicated module instances / `instanceof` failures.
369
+
370
+ **Command-layer rendering** (`src/commands/shared/errors.ts`, `EXIT_CONCURRENT = 2`):
371
+
372
+ ```ts
373
+ console.error(t('common.concurrentConflict', lang, {expected, actual})); process.exit(2);
374
+ ```
375
+
376
+ * zh: `[并发冲突] flow.json 已被其它进程修改(期望 revision={expected},磁盘={actual});本次修改未写入,请重新执行该命令重试。`
377
+ * en: `[concurrent conflict] flow.json was modified by another process (expected revision={expected}, on-disk={actual}); this change was not written, please retry.`
378
+
379
+ Commands that route through `handleConcurrentConflict`: `flow advance`, `flow stage …` writes, `plan stage add`/`flow stage add` (via `handleAddStageError`), `flow archive <version>`, and checkpoint restore (which prints a warning instead, see §4.7).
380
+
381
+ **Other `revision` writers**
382
+
383
+ | Writer | Rule | CAS? |
384
+ |---|---|---|
385
+ | `save()` | `loadedRevision + 1` | ✅ |
386
+ | `restoreCheckpoint()` | `diskRevision + 1` (rebase onto disk; still inside the lock) | ✅ |
387
+ | `repair()` (write-back) | `(typeof meta.revision === 'number' ? meta.revision : 0) + 1` — evaluated on the **parsed disk object** | ❌ (lock only ⇒ last-writer-wins) |
388
+ | `FlowManager.initFlow()` | literal `0` | n/a (create-only) |
389
+ | `flow migrate` | writes through `save()` afterwards | ✅ |
390
+
391
+ ### 4.5 `.bak` behaviour
392
+
393
+ * Produced **only** by `atomicWriteFileSync(..., {backup:true})`, i.e. `save()` and `restoreCheckpoint()`; `repair()` passes `backup: !recovered` (when recovering from `.bak`, the existing `.bak` is preserved rather than overwritten with the corrupt file).
394
+ * Path is always `<file>.bak` (`.openfeel/flow.json.bak`).
395
+ * It is a plain byte copy of the file **as it was immediately before this write**.
396
+ * After a successful write the `.bak` is **never** touched again — it always holds exactly the previous generation, not a rolling history.
397
+ * `.bak` creation failure never blocks the write.
398
+ * `flow repair --backup` additionally does a plain `copyFileSync(flow.json, flow.json.bak)` *before* repairing.
399
+ * `.bak` is the recovery source for a corrupt `flow.json` inside `repair()` (§4.6) — nothing else reads it.
400
+
401
+ ### 4.6 Load-time tolerance: repaired vs fatal
402
+
403
+ `load()` (called from the constructor and after every successful direct write):
404
+
405
+ ```
406
+ if (!existsSync(filePath)) { data = null; loadedRevision = 0; return }
407
+ try {
408
+ data = JSON.parse(readFileSync(filePath,'utf-8'))
409
+ loadedRevision = extractRevision(data)
410
+ for each [_, stage] of Object.entries(data.stages ?? {}) {
411
+ stage.ops ??= {} // absent only
412
+ stage.deps ??= [] // absent only
413
+ if ops is a non-array object: for each [opKey, op] of entries: op.id = opKey
414
+ }
415
+ } catch { data = null; loadedRevision = 0 } // silent, no console output
416
+ ```
417
+
418
+ **Tolerated silently at load (mutated in memory):**
419
+
420
+ * Missing `stages` (the whole map is simply skipped — no crash).
421
+ * `stage.ops` absent → `{}`; `stage.deps` absent → `[]`.
422
+ * `op.id` absent / wrong → overwritten from the map key.
423
+ * `stage.ops` being `null`/an array → left as-is (the `typeof` guard skips the `id` injection).
424
+ * `meta.revision` missing/non-integer → treated as `0`.
425
+
426
+ **Not repaired at load — handled only by `validate()` / `health` / `repair()`:**
427
+
428
+ * `meta` / `meta.version` / `meta.project` / `meta.updated` missing.
429
+ * `pipeline` / `pipeline.current` / `pipeline.retry` missing or `phase` not a `MetaPhase`.
430
+ * `stages` not an object, or `ops` not an object, or an illegal `PipelinePhase`.
431
+ * `reviews` / `log` not arrays.
432
+
433
+ **Fatal (whole instance unloaded):**
434
+
435
+ * File unreadable, or any `JSON.parse` failure ⇒ `data = null`, `loadedRevision = 0`. No exception escapes the constructor. `isLoaded()` then returns `false`, and commands print `common.errorNoInit` and exit `1`:
436
+ * zh: `错误:flow.json 未初始化,请先运行 openfeel init`
437
+ * en: `Error: flow.json not initialized, please run openfeel init first`
438
+ * Validation is *never* fatal: `validate()` returns `{valid, errors[], warnings[]}` where errors are Chinese strings (`meta.version 缺失`, `pipeline.phase 缺失`, `stages 不是有效对象`, `reviews 不是有效数组`, `log 不是有效数组`, …). `validate()` **does** mutate in-memory phases via fuzzy correction and pushes a matching warning.
439
+
440
+ `repair(dryRun, {pruneOrphans})` — the explicit healing path, in order:
441
+
442
+ 1. `flow.json` missing → `initFlow`, change `flow.json 不存在,已创建默认 flow.json` (dry-run reports only).
443
+ 2. Read fails → `无法读取 flow.json`, `fixed:false`.
444
+ 3. `JSON.parse` fails → try `flow.json.bak`; on success `recovered:true`, change `flow.json 解析失败,已从 .bak 恢复`.
445
+ 4. Both corrupt → dry-run reports `flow.json 和 .bak 均已损坏(dry-run 模式下不自动重建)`; otherwise `unlinkSync(flow.json)` + `initFlow`, change `flow.json 和 .bak 均已损坏,已重建默认 flow.json`.
446
+ 5. No `.bak` → dry-run reports `flow.json 解析失败且无 .bak(dry-run 模式下不自动重建)`; otherwise unlink + `initFlow`, change `flow.json 解析失败且无 .bak,已重建默认 flow.json`.
447
+ 6. Field repair: whole `meta`; `meta.version`/`project`/`updated`/`revision`; whole `pipeline`; `pipeline.current`; `pipeline.retry`; `pipeline.phase` (fuzzy `MetaPhase`, worst case `active`); `stages` type; per-stage `phase` (fuzzy `PipelinePhase`, worst case `plan_pending`, message `… 无法修正,使用默认值 'plan_pending'`); per-stage `ops` → `{}`; `reviews` → `[]`; `log` → `[]`. Each emits a Chinese `changes[]` entry.
448
+ 7. `pruneOrphans` (opt-in, never in dry-run): delete `flow.json` op keys that have no `ops/op-NNN*.md` file. **Files are never deleted.** Appends `{time, agent:'cli', action:'prune_orphan_ops', detail:{count, ops:['stage.op-001', …]}}` to `flowData.log` (the parsed copy, not `this.data`).
449
+ 8. Write back when `(modified || recovered) && !dryRun`: `meta.revision = (number? : 0) + 1`; deep clone; delete `op.id`; `JSON.stringify(…, null, 2)+'\n'`; `withFileLock('flow')` → `atomicWriteFileSync(…, {backup: !recovered})`; then `this.load()` to resync `data` + `loadedRevision`. A write failure yields change `修复后写入失败: {msg}` and `fixed:false`.
450
+
451
+ `orphan` reconciliation rule (used by both `repair` and `health`): op files match `/^(op-\d+)/` under `.openfeel/plan/{series}/{stageDir}/ops/`, so both `op-001.md` and `op-001_*.md` count as present. Non-parseable stage ids are skipped.
452
+
453
+ ### 4.7 Checkpoints and restore (same serialization, different CAS baseline)
454
+
455
+ * Directory `.openfeel/checkpoints/`; filename `{stageId}-{yyyyMMddTHHmmssSSS}-{phase}.json` (millisecond timestamp, local time, no `Z`), e.g. `v1.1.1-stage-01-20260926T140355939-done.json`.
456
+ * Content = same serializer, `op.id` stripped, `meta.revision` copied from the source snapshot (not bumped).
457
+ * Written via `atomicWriteFileSync` with **no lock** and full `try/catch` (failure silently ignored — snapshots must never block a phase advance).
458
+ * Retention: 20 per stage (`CHECKPOINT_MAX_KEEP`); after each save, oldest files for that stage (ascending filename) are deleted until ≤ 20.
459
+ * Filename safety on restore: reject names containing `/`, `\` or `..`; require existence; require parseable content.
460
+ * `restoreCheckpoint(filename, {stage?, dryRun?})`:
461
+ * `dryRun` → pure preview, zero writes, reports `conflicts` by comparing `readDiskRevision() !== loadedRevision`.
462
+ * Otherwise, under `flow.lock`: if `readDiskRevision() !== loadedRevision` ⇒ `conflict = true`, no write, then `console.warn(flow.manager.snapshotRestoreRefused)` (`[WARN] flow.json 已被其它进程修改,拒绝从快照恢复(请重新加载后重试)`), return `false`.
463
+ * Else build the target data (full = snapshot; `stage` = disk base with that one stage subtree replaced), set `meta.revision = diskRevision + 1`, write with `{backup:true}`, then `load()`. Return `true`. Any thrown error ⇒ `false`.
464
+
465
+ ---
466
+
467
+ ## 5. Logs
468
+
469
+ Three **independent** log systems; do not conflate them.
470
+
471
+ ### 5.1 `flow.json` `log[]` — pipeline audit trail
472
+
473
+ Shape: `{time: ISO8601, agent: string, action: string, detail: object}`.
474
+
475
+ `appendLog(entry)`:
476
+
477
+ ```
478
+ if (!this.data) return
479
+ this.data.log.push({ ...entry, time: entry.time || new Date().toISOString() })
480
+ ```
481
+
482
+ * **Append-only, in insertion order.** Callers may pass `time: ''` to auto-stamp.
483
+ * **No maximum length, no trimming, no rotation, no dedupe, no sorting.** `flow.json` grows until a version archive moves entries out (§6). Observed live file: 462 log entries.
484
+ * `detail` is always an object (possibly `{}`). Known `detail` keys: `stageName`, `from`, `to`, `opId`, `session_id`, `review_tier`, `version`, `stages`/`reviews`/`log` counts, `archivePath`, `opsCount`, `reviewsCount`, `knowledgeCount`, …
485
+ * Notable `action` values: `advance_stage_phase`, `reset_stage_phase`, `archive_stage`, `archive_flow_version`, `prune_orphan_ops`, `auto_fix_review`, `review_added`, `review_update`, `review_remove`, `attempt_pass`/`attempt_fail_*`, `stage_completed`.
486
+ * Readers: `flow log --last N` (default 10) prints `data.log.slice(-N)` with a `{n}` title; `flow status` prints `日志总数: data.log.length` (= `flow.status.logTotal`); cross-session resume detection scans backwards for the last `advance_stage_phase`/`reset_stage_phase` with `detail.stageName === stageKey && detail.to === exitedPhase`, then compares `detail.session_id` — a `proc:`-prefixed id on either side makes the result undeterminable (`resumed` omitted).
487
+ * Persisted only through `save()`; `appendLog` itself never writes.
488
+
489
+ ### 5.2 Workspace audit log files — `.openfeel/log/` (`public-logger.ts`)
490
+
491
+ **Path:** `.openfeel/log/{yyyy}/{MM}/{dd}/{yyyy-mm-dd}-{username}-{NNN}.md` (nested layout = the only one written since v1.1.2).
492
+
493
+ **Sequence:** `NNN` is a 1-based, 3-digit, per-day counter, allocated atomically by `reserveSequence` (`openSync(path,'wx')` empty-file占位, `EEXIST` ⇒ increment; up to 1000 attempts). Candidate start = `max(parsed NNN of files matching \`${date}-${username}-\`) + 1`. Only filenames are parsed — never file contents — so a crashed placeholder still claims its number.
494
+
495
+ **Lock:** `withFileLock(.openfeel/tmp/locks/log.lock)` wraps *allocate + write file + update all three indexes* in one critical section.
496
+
497
+ **File content template** (`lines.join('\n')`, ending with a single `\n`):
498
+
499
+ ```
500
+ # {fileName without .md}
501
+
502
+ - **时间**:{now.toISOString()}
503
+ - **操作者**:{username}
504
+ - **事件类型**:{状态变更|审查事件|测试事件|里程碑}
505
+ - **操作**:{action}
506
+ [- **关联操作**:{opId}]
507
+ [- **变更前**:{from}]
508
+ [- **变更后**:{to}]
509
+
510
+ ## 补充信息
511
+ - **{key}**:{String(value)}
512
+ ```
513
+
514
+ (`## 补充信息` block only when `extra` has ≥1 key; `milestone` merges `{title, ...extra}` and takes `extra.action` as the action, default `milestone`.)
515
+
516
+ **Indexes maintained in the same critical section:**
517
+
518
+ * `{day}/day_index.md` — header `# {yyyy-mm-dd}\n\n## 日志列表\n| 文件 | 操作者 | 描述 |\n|------|--------|------|` then one row per file. Appends are idempotent (`content.includes(fileName)` ⇒ no-op); trailing whitespace stripped before appending.
519
+ * `.openfeel/log/index.md` — "date index". Header `# 日志索引\n\n## 日期索引\n\n| 日期 | 摘要 |\n|------|------|\n`. Adds a row per (date, description) for the nested layout plus one row per **legacy flat** directory `{yyyy-mm-dd}/` (layout tags `(嵌套)` / `(历史扁平)`). Idempotent per row; missing rows are inserted at the end of the existing table.
520
+ * `.openfeel/log/log.md` — "recent 30". Header `# 最近日志\n\n| 文件 | 用户 | 描述 |\n|------|------|------|\n`; the new row is inserted **immediately after the `|------|------|------|` separator** (newest first); then *all* lines starting with `| [` are counted and only the first **30** are kept (older rows dropped). A file whose separator is missing is reinitialised.
521
+
522
+ **Failure policy:** the entire operation is wrapped in `try/catch` → `console.warn(t('flow.log.writeFailedTmpl'))` = `[WARN] 公共日志写入失败: {err}`. Audit logging never blocks the pipeline.
523
+
524
+ **Layout compatibility:** legacy flat directories `.openfeel/log/{yyyy-mm-dd}/` are read-only — scanned by `updateRootIndex`, never migrated, never deleted, requires no `day_index.md`.
525
+
526
+ **Username source:** `.openfeel/.info.json` `user` (string, non-empty) else `git config user.name` else `'unknown'`. Loaded once per `PublicLogger` singleton (cached per `projectPath`).
527
+
528
+ ### 5.3 CLI runtime log — `~/.openfeel/cli/logs/` (`runtime-log.ts`)
529
+
530
+ **Path:** `~/.openfeel/cli/logs/openfeel-{YYYY-MM-DD}.log` (local date, one file per day, never rotated or cleaned).
531
+
532
+ **Line format (exact):**
533
+
534
+ ```
535
+ [{ISO8601}][{LEVEL}][{pid}] {message}\n
536
+ ```
537
+
538
+ `LEVEL` ∈ `DEBUG|INFO|WARN|ERROR` (uppercased). Appended with `appendFileSync(..., {encoding:'utf-8'})` under `withFileLock(~/.openfeel/locks/runtime-log.lock)`.
539
+
540
+ **The three canonical lines:**
541
+
542
+ ```
543
+ … [INFO] cli start: {argv.join(' ')} [project={process.cwd()}] [session={sessionId}]
544
+ … [INFO] cli done exit={code} [duration_ms={ms}] [project={process.cwd()}] [session={sessionId}]
545
+ … [DEBUG] metrics skip (no baseline): stage={stage} phase={phase} (debug only)
546
+ ```
547
+
548
+ * `duration_ms` = `Date.now() - PROCESS_START_MS`, where `PROCESS_START_MS` is captured at **module load** (includes subsequent module resolution + command execution; excludes node bootstrap).
549
+ * `project` = `process.cwd()` verbatim (so it may contain spaces; the `[key=value]` bracket form keeps parsing unambiguous).
550
+ * `session` = session id, see below.
551
+ * `cli done` is written at most once: a module-level `cliDoneLogged` flag is set **before** the write (so `--no-log` still records "done happened"); the `exit` handler is the single writer, and `SIGINT`→`process.exit(130)` / `SIGTERM`→`process.exit(143)` merely preserve the interrupt semantics.
552
+
553
+ **Session id** (`src/core/session.ts`), memoised per project, three-level chain:
554
+
555
+ 1. `env:` + `CLAUDE_CODE_SESSION_ID` (if non-empty after trim);
556
+ 2. `project:` + `session_id` from `<project>/.openfeel/tmp/session.json` (shape `{session_id, source:'env', started_at}`; written best-effort when the env value is found);
557
+ 3. `proc:` + first 8 chars of a `randomUUID()` — **never persisted**.
558
+
559
+ Only `env:` / `project:` ids participate in cross-session resume detection; `proc:` short-circuits to "unknown".
560
+
561
+ **Enable / disable / level (resolved from env + argv, re-evaluated on `installRuntimeLog`):**
562
+
563
+ | Knob | Effect |
564
+ |---|---|
565
+ | `--no-log`, `OPENFEEL_LOG=0`, `OPENFEEL_NO_LOG=1` | disable entirely |
566
+ | `--debug`, `OPENFEEL_DEBUG=1` | `minLevel = 'debug'` (default `'info'`) |
567
+ | `--log-file <p>` / `--log-file=<p>` / `OPENFEEL_LOG_FILE` | override path (absolute-resolved) |
568
+
569
+ Library-side default is **disabled** (`config === null` ⇒ every call is a no-op); only `bin/openfeel.js` calls `installRuntimeLog`. Every write is best-effort: any failure (disk, permission, lock timeout) is swallowed.
570
+
571
+ ---
572
+
573
+ ## 6. Version archive — `flow archive <version>`
574
+
575
+ Purpose: shard a **finished version's** stages/reviews/logs **out of `flow.json`** into a per-version archive file, then compact `pipeline`.
576
+
577
+ ### 6.1 Archive file schema (`archive-file.ts`)
578
+
579
+ Path: `<project>/.openfeel/flow-archive/{version}.json` — `version` includes the leading `v`, e.g. `v1.1.7.json`.
580
+
581
+ ```jsonc
582
+ {
583
+ "version": "v1.1.1", // canonical, leading v
584
+ "stages": { "v1.1.1-stage-01": { /* StageData verbatim */ } },
585
+ "reviews": [ /* ReviewItem verbatim */ ],
586
+ "log": [ /* LogEntry verbatim */ ],
587
+ "counts": { "stages": 1, "reviews": 0, "log": 12 },
588
+ "schemaVersion": 1,
589
+ "archived_at": "2026-10-11T19:32:01.256Z"
590
+ }
591
+ ```
592
+
593
+ * `ARCHIVE_SCHEMA_VERSION = 1`. Written **always** as `1`; read tolerantly (unknown values are not rejected).
594
+ * **Key order on disk is exactly as above** — `version, stages, reviews, log, counts, schemaVersion, archived_at` — because the command assembles `{...split.archive, schemaVersion, archived_at}` where `split.archive` is `{version, stages, reviews, log, counts}`. A reimplementation must reproduce this order to be byte-identical.
595
+ * `counts` is redundant metadata; on read, if any of the three numbers is missing, all three are recomputed from the actual lengths (`withCountsFallback`, read-time only, never written back).
596
+ * Same serializer everywhere: `JSON.stringify(file, null, 2) + '\n'`.
597
+ * Write is `atomicWriteFileSync(..., {backup:false})` under the **separate** lock `flow-archive` (`.openfeel/tmp/locks/flow-archive.lock`), after `mkdirSync(flow-archive, {recursive:true})`.
598
+ * Overwriting an existing file is the intended, idempotent "re-archive" path.
599
+
600
+ ### 6.2 Version string rules
601
+
602
+ * Valid version: `isValidArchiveVersion(s)` ⇔ `s.length > 0` and `versionOfStageId(`${s}-stage-00`) === s`, i.e. the probe stage id parses (`/^(v\d+(?:\.\d+)*)-stage-(\d+)$/`) and the version segment round-trips. Net effect: `v` + dot-separated numbers, e.g. `v1`, `v1.1`, `v1.0.0`, `v4`. Empty strings, path separators, `stage-01`, `V1.0.0` (uppercase) are invalid.
603
+ * `normalizeArchiveVersion(input)`: `trim()` → drop **one** leading `v` if present → prepend `v` → validate. So `1.1.7` → `v1.1.7`, `v1.1.7` → `v1.1.7`, `vv1.1.7` → invalid (`null`), `""` → `null`. Invalid ⇒ CLI exit 1, **zero writes**.
604
+ * `versionOfStageId(stageId)` = `parseStageId(stageId)` then strip the `-{stageDir}` suffix. Short names (`stage-01`) normalise to `v1.0.0-stage-01` ⇒ version `v1.0.0` (`DEFAULT_STAGE_VERSION`).
605
+
606
+ ### 6.3 Sharding rules (`splitForArchive`, pure)
607
+
608
+ Given `version`:
609
+
610
+ | Section | Moves to the archive iff | Stays in `flow.json` |
611
+ |---|---|---|
612
+ | `stages` | `versionOfStageId(stageId) === version` | everything else, incl. unparseable stage ids |
613
+ | `reviews` | `versionOfReviewOp(review.op) === version` | everything else |
614
+ | `log` | `versionOfLogEntry(entry) === version` | everything else |
615
+
616
+ * `versionOfReviewOp(op)`: strip a trailing `.op-NNN` via `/^(.+)\.(op-\d+)$/` (dot-anchored, because a stageId may itself contain dots), then `versionOfStageId` on the remainder. Empty/non-string ⇒ `null`.
617
+ * `versionOfLogEntry(entry)`: reads **only** `detail.stageName`. `detail.stageId`, `detail.stage`, etc. are deliberately ignored. Missing / non-string / unparseable ⇒ `null` ⇒ the entry **stays** in `flow.json`.
618
+ * Order is preserved on both sides (object key order / array order).
619
+ * Groups are computed with stable insertion order (`groupStagesByVersion`); unparseable stage ids never form a group and are therefore never archivable.
620
+ * All moved objects are the original references (shallow) — a reimplementation may deep-copy, but must not mutate them.
621
+
622
+ `meta.archived_versions` is **not** touched by `splitForArchive`.
623
+
624
+ ### 6.4 The gate
625
+
626
+ `flow archive <version>` has exactly two options: `--dry-run`, `--json`. **There is no `--force`.**
627
+
628
+ Evaluation order (each failure ⇒ exit code 1, zero writes):
629
+
630
+ 1. **Init gate** — `flow.json` not loaded: JSON `{schemaVersion:1, error:'no-init'}`; text `common.errorNoInit`.
631
+ 2. **Version syntax gate** — `normalizeArchiveVersion` returns `null`:
632
+ * JSON `{schemaVersion:1, error:'invalid-version', input:<raw input>}`
633
+ * zh: `{input} → 非法版本号(应为 vX.Y[.Z] 形式,如 v1.1.7)`
634
+ * en: `{input} → invalid version (expected vX.Y[.Z], e.g. v1.1.7)`
635
+ 3. **Existence gate** — `listArchivableCandidates(data).find(c => c.version === version)` is `undefined` (version absent from the active `stages` keys, i.e. never existed **or already archived**):
636
+ * JSON `{schemaVersion:1, error:'version-not-active', version}`
637
+ * zh: `版本 {version} 未注册于活跃 flow.json(不存在或已归档);拒绝归档(零写盘)`
638
+ * en: `Version {version} is not registered in the active flow.json (missing or already archived); archive refused (zero writes)`
639
+ 4. **Gate** — rejected if **either** condition holds:
640
+ * **all stages of that version must be `phase === 'done'`**, with the empty-set guard: `allDone = stageIds.length > 0 && stageIds.every(id => data.stages[id]?.phase === 'done')` (an empty group is never "all done", though in practice step 3 already excluded it);
641
+ * **no blocking open review** in that version's scope, using the single repository-wide predicate
642
+ `r.blocking !== false && r.status === 'open'` (absent `blocking` **means blocking**) and the same version-scope resolution as the split (`versionOfReviewOp(r.op) === version`).
643
+ * JSON: `{schemaVersion:1, error:'gate-rejected', version, notDone:[stageIds…], blockingOpen:[reviewIds…]}`
644
+ * Text output, in this order:
645
+ ```
646
+ ▶ if notDone.length > 0:
647
+ zh: 版本 {version} 存在未完成阶段({n} 个):
648
+ en: Version {version} has unfinished stages ({n}):
649
+ then one line per stage: " - {stageId} [{phase}]" (flow.archive.gateItemTmpl = ' - {item}')
650
+ ▶ if blockingOpen.length > 0:
651
+ zh: 版本 {version} 存在阻塞 open 审查({n} 条):
652
+ en: Version {version} has blocking open reviews ({n}):
653
+ then one line per review: " - {id}: {title} (priority={priority})"
654
+ ▶ always last:
655
+ zh: 归档判据未满足,拒绝执行(零写盘)
656
+ en: Archive gate not satisfied, refused (zero writes)
657
+ ```
658
+ * exit code 1.
659
+
660
+ **One-line rule:** *a version is archivable ⇔ it still has stages in `flow.json` ∧ every one of those stages is `phase==='done'` ∧ it has no review with `status==='open'` and `blocking!==false`.*
661
+
662
+ ### 6.5 Write order and effects (non-dry-run)
663
+
664
+ ```
665
+ ① writeArchiveFile(project, {version, stages, reviews, log, counts, schemaVersion:1, archived_at:now})
666
+ → lock `flow-archive`; mkdir; atomic write; backup:false
667
+ → on failure: JSON {error:'archive-write-failed', version} / common.errorTmpl; exit 1; memory untouched, flow.json untouched
668
+ ② data.stages = remainder.stages
669
+ data.reviews = remainder.reviews
670
+ data.log = remainder.log
671
+ ③ added = addArchivedVersion(data, version) // meta.archived_versions, idempotent, in-memory only
672
+ ④ disposed = applyArchivePipelineDisposition(data, version) // in-memory only
673
+ ⑤ mgr.appendLog({time:'', agent:'cli', action:'archive_flow_version',
674
+ detail:{version, stages, reviews, log}}) // counts, not the entries
675
+ ⑥ mgr.save() → revision+1, .bak updated, atomic write under the `flow` lock
676
+ → FlowConcurrentModificationError ⇒ handleConcurrentConflict ⇒ exit 2
677
+ (archive file already written; re-running is idempotent)
678
+ ```
679
+
680
+ The invariant is **archive file first, `flow.json` second**: if the process dies between ① and ⑥, the archive exists but `flow.json` still has the entries — a re-run is idempotent (overwrite). The reverse order is never allowed.
681
+
682
+ **Pipeline disposition** (`applyArchivePipelineDisposition`, after stages were removed):
683
+
684
+ * `currentCleared = versionOfStageId(data.pipeline.current.stage) === version`; when true, `pipeline.current = {stage:'', op:''}` (both cleared).
685
+ * `pipeline.phase` is recomputed over the **remaining** stages: `allDone = keys.length > 0 && every(s.phase === 'done')` ⇒ `'done'`, else `'active'`. Empty set ⇒ `'active'` (never `'done'`).
686
+ * `pipeline.retry` is untouched.
687
+
688
+ **`meta.archived_versions` maintenance** (`addArchivedVersion`):
689
+
690
+ * Pure in-memory; if `data.meta` is missing it is first set to `{version:'1.0', project:'', updated:'', revision:0}`.
691
+ * `getArchivedVersions` returns a **copy** (`[...list]`); a non-array/absent value reads as `[]`.
692
+ * Append **only if not already present**; returns `added: false` for a duplicate (⇒ the CLI prints the "re-archived" message instead of "archived").
693
+ * **Nothing ever removes an entry.** No `removeArchivedVersion` exists; the list is the monotonically growing version index.
694
+ * Persisted only by step ⑥'s `save()`.
695
+
696
+ ### 6.6 `flow archive list`
697
+
698
+ Read-only: no lock, no `save()`, no `appendLog()`, **does not even read `flow.json`**.
699
+
700
+ * Versions come from the **filesystem**: `readdirSync(flow-archive)`, keep `*.json`, strip `.json`, keep names passing `isValidArchiveVersion`, **sort lexicographically** (`['v1.0.0','v1.1.0','v1.1.1','v1.1.10','v1.1.2', …]` — *not* semantic version order). Missing dir ⇒ `[]`.
701
+ * Each version's `counts` is re-read via `readArchiveFile` (corrupt ⇒ `null`).
702
+ * `--json`: `{"schemaVersion":1,"versions":[{"version":"v1.1.7","counts":{"stages":1,"reviews":0,"log":12}}]}`. (`--json` on the child also honours `--json` on the parent command.)
703
+ * Text: empty ⇒ `暂无归档版本(.openfeel/flow-archive/ 为空或不存在)` / `No archived versions (.openfeel/flow-archive/ is empty or missing)`; else title `已归档版本:` / `Archived versions:` then per row
704
+ ` + {version}:阶段 {stages} / 审查 {reviews} / 日志 {log}` (` + {version}: stages {stages} / reviews {reviews} / log {log}`), or ` + {version}:(不可读,文件损坏或非法)` (` + {version}: (unreadable, file is corrupt or invalid)`).
705
+
706
+ `readArchiveFile(project, version)` tolerance: invalid version ⇒ `null` (no throw); missing ⇒ `null`; JSON parse error ⇒ `null`; valid JSON that is `null`/array/scalar ⇒ `null`; unknown keys are **preserved** (the object is not rebuilt); missing `counts` ⇒ computed on the fly.
707
+
708
+ ### 6.7 `flow archive show <version>`
709
+
710
+ * Normalises the version; invalid ⇒ JSON `{schemaVersion:1, error:'invalid-version', input, available}` else text `{input} → 非法版本号…`; exit 1.
711
+ * Not found ⇒ JSON `{schemaVersion:1, error:'not-found', version, available}` else
712
+ `归档版本 {version} 不存在;可用版本:{versions}` (with `(无)` / `(none)` when none); exit 1.
713
+ * `--json` ⇒ the **entire `ArchiveFile` verbatim** (its own `schemaVersion`/`archived_at`, full `log` and `reviews`), pretty-printed with 2 spaces.
714
+ * Text: `归档版本 {version}(archived_at: {at})` → `阶段 {stages} / 审查 {reviews} / 日志 {log}` → `阶段:` → one ` - {stage} [{phase}]` per stage (or `(无)`/`(none)`) → `(完整 log / reviews 请用 --json 查看)`.
715
+
716
+ ### 6.8 `--dry-run`
717
+
718
+ Pure in-memory rehearsal; **four write sites untouched**: no archive file, no `addArchivedVersion`, no `appendLog`, no `save()`.
719
+
720
+ * Gates 1–4 still run identically (so a rejected gate still exits 1).
721
+ * The pipeline disposition is computed on a **deep-copied** view (`pipeline` and `pipeline.current` cloned) so the live object is never mutated.
722
+ * Output: `[dry-run] 将归档 {version}:阶段 {stages} / 审查 {reviews} / 日志 {log}`, then `pipeline 处置:current={current},phase={phase}`, then `(预览模式:未写入任何文件)`; `--json` ⇒ `{schemaVersion:1, version, stages, reviews, log, dryRun:true}`.
723
+
724
+ ### 6.9 Archive consistency health (`archive-health.ts`, consumed by `flow health`)
725
+
726
+ Two warn-only items (never `fail`, never affect the exit code; skipped by `--quick` and when `flow.json` is not loaded):
727
+
728
+ 1. **`归档一致性`** — `meta.archived_versions` ↔ archive directory, bidirectional diff.
729
+ * both diffs empty: `pass`, `归档索引与归档文件一致({n} 个版本)`.
730
+ * otherwise `warn`, listing up to 5 each:
731
+ `归档索引与文件不一致:索引有文件缺 [{…}];文件有索引缺 [{…}](可经 flow archive list / flow archive show 复核)`.
732
+ 2. **`归档残留`** — a stage still present in `flow.json.stages` that (a) has a version prefix in `archived_versions` **and** (b) whose stage id is present in that version's archive snapshot `stages` keys (proving it was archived). Non-candidates do not read archive files; an unreadable snapshot ⇒ cannot prove ⇒ not reported. Produces one `warn`:
733
+ `flow.json 仍含已归档版本阶段:{…up to 5}(可能由 checkpoint restore 带回或重复归档残留;须重新执行 flow archive <version> 收敛)`.
734
+
735
+ Related dependency semantics: a `deps` entry is treated as *satisfied* when it carries an explicit version prefix whose version ∈ `archived_versions` (`isArchivedVersionOf`); short names never qualify (they normalise to `v1.0.0` by default and are explicitly rejected: `parsed.fullStageId !== stageId ⇒ false`).
736
+
737
+ ---
738
+
739
+ ## 7. Stage summary archive — `openfeel archive <stage>`
740
+
741
+ A completely different feature: a Markdown *summary* of one stage, written into the log directory. **It does not remove anything from `flow.json` and never touches `flow-archive/`.**
742
+
743
+ `archiveStage(projectPath, stageName)` (`src/core/archive/merge.ts`):
744
+
745
+ 1. `new FlowManager(projectPath)`; if not loaded ⇒ return `null`; if `data` falsy ⇒ `null`.
746
+ 2. Resolve the stage: `key = normalizeStageId(stageName) ?? stageName`; `stage = data.stages[key] ?? data.stages[stageName]`; missing ⇒ `null`. (Short names are normalised; the raw input is a fallback key.)
747
+ 3. `opsCount = Object.values(stage.ops).length`.
748
+ 4. Related reviews: `r.op.lastIndexOf('.')` split (so a stageId containing dots is handled) → `opStage = r.op.substring(0, dotIdx)`; keep when `opStage === key || opStage === stageName`. Reviews with no `.` never match. `reviewsCount = relatedReviews.length`.
749
+ 5. `knowledgeExtracts` = related reviews with `status ∈ {'closed','resolved'}` mapped to `[${r.id}] ${r.title}`.
750
+ 6. Markdown summary written to `.openfeel/log/archive-{stageName}.md` (**the raw argument as given**, e.g. `archive-stage-06.md`; not normalised) via `atomicWriteFileSync` — **no lock**, byte-for-byte:
751
+
752
+ ```markdown
753
+ # 归档摘要 — {stageName}
754
+
755
+ - **归档时间**:{ISO}
756
+ - **阶段名称**:{stageName}
757
+ - **阶段状态**:{stage.status}
758
+ - **依赖阶段**:{deps.join(', ') | 无}
759
+
760
+ ## 操作产出
761
+
762
+ | ID | 标题 | 状态 | 尝试次数 |
763
+ |----|------|------|----------|
764
+ | {op.id || (无)} | {op.title} | {op.state} | {op.attempts}/{op.max_attempts} |
765
+ …
766
+
767
+ ## 审查记录
768
+
769
+ | ID | 标题 | 状态 | 优先级 |
770
+ |----|------|------|--------|
771
+ | {r.id} | {r.title} | {r.status} | {r.priority} |
772
+ …
773
+ ```
774
+
775
+ (Empty tables fall back to `| (无) | - | - | - |`; the file ends with a trailing newline from the template.)
776
+
777
+ 7. `mgr.appendLog({time:'', agent:'openfeel-archiver', action:'archive_stage', detail:{stageName, opsCount, reviewsCount, knowledgeCount, archivePath}})` — note `stageName` is the raw argument.
778
+ 8. `mgr.save()` ⇒ **revision + 1**, `.bak` updated. A concurrent conflict here surfaces as the standard `common.concurrentConflict` + exit 2 (via the generic catch in `commands/archive.ts` it is actually rendered as `common.errorTmpl` + exit 1, since `archive.ts` only catches and prints `common.errorTmpl`).
779
+ 9. If `knowledgeExtracts.length > 0`: `initKnowledgeBase(projectPath)` then `addKnowledgeEntry(projectPath, 'patterns', entry, '归档自阶段 {stageName}')` for each extract (`kb/` write, own `kb` lock).
780
+ 10. Returns `{summary, opsCount, reviewsCount, knowledgeExtracts}`.
781
+
782
+ CLI (`src/commands/archive.ts`) prints:
783
+
784
+ ```
785
+ ✓ 阶段已归档: {stage} // archive.okTmpl
786
+ 操作数: {opsCount} // archive.opsCount
787
+ 审查条目数: {reviewsCount} // archive.reviewsCount
788
+ 知识提取: // only when non-empty
789
+ - {entry}
790
+ 归档文件: {cwd}/.openfeel/log/archive-{stage}.md
791
+ ```
792
+
793
+ `null` result ⇒ `错误:归档失败,请确认阶段 "{stage}" 存在且 flow.json 已初始化。` + `process.exit(1)`; a thrown error ⇒ `common.errorTmpl` + exit 1.
794
+
795
+ **Verdict: `openfeel archive <stage>` leaves `flow.json` structurally unchanged except for one appended `log` entry (+ revision bump).**
796
+
797
+ ---
798
+
799
+ ## 8. The two archive features compared
800
+
801
+ | Dimension | **Version archive** — `flow archive <version>` | **Stage summary archive** — `openfeel archive <stage>` |
802
+ |---|---|---|
803
+ | Command | `openfeel flow archive <version>` (subcommand of `flow`) | `openfeel archive <stage>` (top-level) |
804
+ | Goal | Compact `flow.json` by sharding a finished version out | Produce a human-readable Markdown recap of one stage |
805
+ | Output artifact | `.openfeel/flow-archive/{version}.json` (structured JSON) | `.openfeel/log/archive-{stage}.md` (Markdown) |
806
+ | Schema | `ArchiveFile` (`schemaVersion:1`, `version`, `archived_at`, `stages`, `reviews`, `log`, `counts`) | none — free-form Markdown |
807
+ | Granularity | **all** stages/reviews/logs of a version prefix | **one** stage (short or full id) |
808
+ | Mutates `flow.json` | **Yes** — removes the moved `stages`/`reviews`/`log` entries, updates `meta.archived_versions`, recomputes `pipeline.current`/`pipeline.phase` | **No** — only appends one `log` entry |
809
+ | `flow.json` revision | +1 (via `save()`) | +1 (via `save()`) |
810
+ | Preconditions | version present in active `stages`; **all** its stages `phase==='done'`; **no** blocking open REV | `flow.json` loaded **and** the stage exists (no phase/done/review check) |
811
+ | Refusal | existence gate + gate, exit 1, **zero writes**, no `--force` | `null` ⇒ exit 1 with `archive.errorArchiveFailedTmpl` |
812
+ | Idempotent? | Yes — same-version re-archive overwrites the file and skips the index append (`added:false`) | Yes — overwrites the same `.md`, appends another log entry |
813
+ | Locks | `flow-archive` (file) then `flow` (save) — never held simultaneously | `flow` (via `mgr.save()`), plus `kb` if knowledge is extracted |
814
+ | `--force` | **none** | none |
815
+ | Dry-run | `--dry-run` (zero writes) | none |
816
+ | JSON output | `--json` on archive / list / show | none |
817
+ | Read-back | `flow archive list`, `flow archive show <version>` | read the Markdown file |
818
+ | Consistency check | `flow health` items `归档一致性` / `归档残留` | none |
819
+ | Side effects | pipeline disposition (current cleared if it belonged to the version) | knowledge extraction into `kb/patterns.md` |
820
+ | Ordering invariant | archive file written **before** `flow.json` is modified | `flow.json` saved **before** `kb/` writes |
821
+
822
+ ---
823
+
824
+ ## 9. `metrics.json` (`src/core/metrics.ts`)
825
+
826
+ ### 9.1 Schema (on disk)
827
+
828
+ Path: `<project>/.openfeel/metrics.json` (same dir as `flow.json`).
829
+
830
+ ```jsonc
831
+ {
832
+ "agents": {
833
+ "openfeel-executor": {
834
+ "agentName": "openfeel-executor",
835
+ "totalRuns": 9,
836
+ "successfulRuns": 9,
837
+ "failedRuns": 0,
838
+ "totalRetries": 0,
839
+ "totalDurationMs": 13989829,
840
+ "avgDurationMs": 1554425
841
+ }
842
+ // … one entry per agent name
843
+ }
844
+ }
845
+ ```
846
+
847
+ * **No `schemaVersion` on disk.** No timestamp, no run history, no per-run records — only monotonically accumulating counters per agent.
848
+ * Serialized `JSON.stringify(data, null, 2) + '\n'`, atomic write, **no lock** ("low concurrency, not a consistency-critical path").
849
+ * **No retention policy, no trimming, no rotation.** Entries accumulate forever; one entry per distinct `agentName`.
850
+
851
+ ### 9.2 `recordRun(agentName, durationMs, result, retries = 0)`
852
+
853
+ ```
854
+ entry = agents.get(agentName) ?? createDefaultMetrics(agentName) // all zeros
855
+ entry.totalRuns += 1
856
+ entry.totalDurationMs += durationMs
857
+ result === 'success' ? entry.successfulRuns += 1 : entry.failedRuns += 1
858
+ entry.totalRetries += retries
859
+ entry.avgDurationMs = Math.round(entry.totalDurationMs / entry.totalRuns)
860
+ ```
861
+
862
+ `Math.round` (half-up), recomputed from the running totals — never an incremental average. `result` is the literal union `'success' | 'failure'`; anything not `'success'` increments `failedRuns`. `recordRun` only mutates memory — `save()` must be called to persist.
863
+
864
+ ### 9.3 Store lifecycle
865
+
866
+ * `MetricsStore.getInstance(dataDir?)` — singleton **per `dataDir`** (`default = resolve(process.cwd(), '.openfeel')`), so several projects in one process do not share state. `resetInstance()` clears the cache (tests).
867
+ * `load()` — missing file ⇒ no-op; unreadable/unparsable ⇒ silently keep the empty state (`try/catch`); on success, each `agents[name]` is shallow-copied into the map. **`load()` overwrites in-memory entries with disk values**, hence callers must `load()` before `recordRun()`+`save()` or they will drop history:
868
+ ```ts
869
+ const metricsStore = MetricsStore.getInstance();
870
+ metricsStore.load(); // read-merge to protect history
871
+ … metricsStore.recordRun(agentName, Date.now() - baseMs, 'success', 0);
872
+ ```
873
+ * `save()` — `mkdirSync(dataDir, {recursive:true})` if absent; rebuild `{agents}`; atomic write.
874
+ * `summary(lang)` — human text (`Agent 性能指标摘要` + `═══…`; per agent a name line then `总执行次数` / `成功 / 失败` / `成功率` (`%.1f%`, `N/A` when `totalRuns===0`) / `总重试次数` / `总耗时 (ms)` / `平均耗时 (ms)`), empty ⇒ `暂无 Agent 性能数据。`; a closing caliber note `注:耗时口径 = phase 窗口(含跨会话闲置 / 等待),非 agent 纯执行时长。`
875
+ * `getSummaryData()` ⇒ `{agents:[{...AgentMetrics, successRate: number|null}]}` where `successRate = Number(((ok/runs)*100).toFixed(1))` or `null`.
876
+ * `flow metrics` ⇒ `store.load(); console.log(store.summary(lang))`.
877
+ * `flow metrics --json` ⇒ `JSON.stringify({schemaVersion:1, ...getSummaryData()}, null, 2)` — note `schemaVersion` is **only** in the CLI payload, not in the file.
878
+
879
+ ### 9.4 Which runs are recorded
880
+
881
+ `PHASE_AGENT_MAP` (phase → owning agent), the only mapping used:
882
+
883
+ | phase | agent |
884
+ |---|---|
885
+ | `plan_pending`, `plan_review` | `openfeel-planner` |
886
+ | `scheme_pending`, `scheme_review` | `openfeel-schemer` |
887
+ | `exec_running` | `openfeel-executor` |
888
+ | `review_pending`, `review_failed` | `openfeel-reviewer` |
889
+ | `test_pending`, `test_failed` | `openfeel-feel-tester` |
890
+ | `archiving` | `openfeel-archiver` |
891
+
892
+ `*_passed` / `done` / meta phases are not in the table ⇒ never recorded.
893
+
894
+ `flow advance` (non-dry-run only) records **one run per exited phase**: after each successful step + `save()`, `exitedPhase = (i>0 ? phasePath[i-1] : startPhase)`; if mapped, `durationMs = Date.now() - baseMs` where `baseMs` is the phase-entry timestamp recovered from `flow.json.log` (scanned back over entries older than `logLenBefore`, i.e. excluding this run's own writes) or, for later steps, the previous step's completion time. If no baseline can be established the run is **skipped**, not recorded with `0`, and a `debug` line `metrics skip (no baseline): stage=… phase=…` is emitted to the runtime log.
895
+
896
+ ---
897
+
898
+ ## 10. `.info.json` — workspace identity (`src/core/workspace/identity.ts`)
899
+
900
+ Path: `<project>/.openfeel/.info.json`. Schema:
901
+
902
+ ```jsonc
903
+ {
904
+ "user": "Liuary",
905
+ "lang": "zh-CN" // 'zh-CN' | 'en'
906
+ }
907
+ ```
908
+
909
+ * `ensureInfoJson(projectPath)`:
910
+ * file absent → `getUserName()` then write `{user, lang:'zh-CN'}` with `JSON.stringify(..., null, 2) + '\n'` (atomic). `init` immediately follows with `writeLang(projectPath, lang)` applying the chosen language.
911
+ * file present and `lang` missing/invalid → set `lang: 'zh-CN'` and rewrite **preserving all other keys** (the parsed object is re-serialized).
912
+ * file present with a valid `lang` → untouched. **`user` is never overwritten.**
913
+ * JSON parse failure → do nothing (file left as-is).
914
+ * `getUserName(projectPath)`: `.info.json.user` (non-empty string) → else `git config user.name` (via `execSync`, `cwd = projectPath`, trimmed, must be non-empty) → else `'unknown'`. Each step `try/catch`-guarded.
915
+ * `getLang(projectPath)`: `.info.json.lang` if it is one of `VALID_LANGS` → else `'zh-CN'`. Read-only; never writes.
916
+ * `.info.json` is also the username source for `PublicLogger` (which reads it directly, defaulting to `'unknown'`).
917
+
918
+ ### 10.1 Global preference store (`~/.openfeel/config.json`)
919
+
920
+ ```jsonc
921
+ { "lang": "zh-CN", "projects": { "C:\\path\\to\\project": "zh-CN" } }
922
+ ```
923
+
924
+ * `getGlobalConfig()` — missing ⇒ a deep copy of `{lang:'zh-CN', projects:{}}` **without creating the file**; parse failure ⇒ same default plus `console.warn('[WARN] Failed to parse global config JSON; using defaults')`; a non-object `projects` is coerced to `{}`.
925
+ * `setGlobalConfig()` — `withFileLock(~/.openfeel/locks/global-config.lock)` + atomic write.
926
+ * `recordProjectLang(projectPath, lang)` — normalises with `resolve()`, skips the write when the mapping already equals `lang`, otherwise upserts and writes.
927
+ * `isFirstUse()` ⇔ the global config file does not exist.
928
+
929
+ ### 10.2 `.openfeel/update_state.json` (`src/core/update-state.ts`)
930
+
931
+ Zod-validated; also used verbatim for `~/.openfeel/update_state.json`.
932
+
933
+ ```jsonc
934
+ {
935
+ "version": "1.0", // literal
936
+ "last_update": "2026-10-15T05:42:02.193Z",
937
+ "openfeel_version": "1.0.6",
938
+ "files": {
939
+ ".opencode/agents/feel.md": {
940
+ "hash": "28f678e815056706e76c53de4fca8ee52dcd24d249678817c7eb3a918cf22f28", // sha256 hex
941
+ "status": "clean" // 'clean' | 'conflict'
942
+ }
943
+ }
944
+ }
945
+ ```
946
+
947
+ * Keys of `files` are project-relative paths (forward slashes) — or absolute paths in the **global** state.
948
+ * `hashContent(s)` = SHA-256 hex of the string with **CRLF and lone CR normalised to LF** first.
949
+ * `loadUpdateState` / `loadGlobalUpdateState` — missing ⇒ `null`; schema mismatch ⇒ `console.warn` + `null`; parse error ⇒ `null` (`null` ⇒ the caller falls back to "full overwrite + rebuild state").
950
+ * `saveUpdateState` — `withFileLock(.openfeel/tmp/locks/update-state.lock)` + atomic write; `saveGlobalUpdateState` — `~/.openfeel/locks/global-update-state.lock`.
951
+ * `getOpenfeelVersion()` reads the CLI's own `package.json` via `createRequire(import.meta.url)('../../package.json').version`.
952
+ * Legacy keys are those starting with `.opencode/` or `.opencode\` (used by `openfeel migrate`).
953
+
954
+ ---
955
+
956
+ ## 11. Observed live state (reference sample, `OpenFeel` repo)
957
+
958
+ Useful as a conformance fixture; values are facts about the analyzed checkout, not normative defaults.
959
+
960
+ `.openfeel/flow.json` (136 700 B):
961
+
962
+ ```jsonc
963
+ "meta": { "version":"1.0", "project":"OpenFeel", "updated":"2026-10-15T07:34:06.404Z",
964
+ "revision":1187,
965
+ "archived_versions":["v1.0.0","v1.1.0","v1.1.1","v1.1.2","v1.1.3","v1.1.4","v1.1.5","v1.1.6","v1.1.7"] }
966
+ ```
967
+ Top-level keys in order: `meta, pipeline, stages, reviews, log` (no `requirements`). `log.length = 462`, `stages` = 17 entries, `reviews` = 0.
968
+
969
+ * `.openfeel/flow.json.bak` — 136 358 B (previous generation).
970
+ * `.openfeel/metrics.json` — 6 agents: `openfeel-executor` (9/9), `openfeel-reviewer` (11/11), `openfeel-feel-tester` (9/9), `openfeel-archiver` (9/9), `openfeel-planner` (22/22), `openfeel-schemer` (18/18); all `failedRuns:0`, `totalRetries:0`.
971
+ * `.openfeel/.info.json` — `{"user":"Liuary","lang":"zh-CN"}`.
972
+ * `.openfeel/update_state.json` — `version 1.0`, `openfeel_version 1.0.6`, 3 entries (`.opencode/agents/feel.md`, `.opencode/agents/reviewer.md`, `opencode.jsonc`), all `clean`.
973
+ * `.openfeel/flow-archive/` — 9 files: `v1.0.0.json` (89 990 B) … `v1.1.7.json` (29 219 B), exactly matching `archived_versions`.
974
+ * `.openfeel/log/` — nested `2026/{06,07,08,09,10}/{dd}/` with `YYYY-MM-DD-Liuary-NNN.md` + `day_index.md`; legacy flat `2026-08-07/`, `2026-08-09/`, `2026-08-11/`; root `index.md`, `log.md`, `archive-{version}-stage-NN.md`, plus two legacy root-level `2026-07-14-feel-001.md` / `2026-10-11-feel-001.md`.
975
+ * `.openfeel/tmp/locks/` — empty between commands (lock files are transient).
976
+
977
+ ---
978
+
979
+ ## 12. Reimplementation conformance checklist
980
+
981
+ **Storage layer**
982
+
983
+ 1. `atomicWriteFileSync` writes `.<name>.<pid>.<12hex>.tmp` in the target directory, `fsync`s, then renames; cleans up the temp on failure; optional `{backup:true}` copies the old file to `.bak` **before** the write.
984
+ 2. All JSON is `JSON.stringify(x, null, 2) + '\n'`, UTF-8, LF, no BOM, object key order preserved from load.
985
+ 3. `withFileLock` uses O_EXCL + JSON `{pid,time,token}`, 10→500 ms jittered exponential backoff, 5000 ms deadline, 3000 ms stale reclaim by atomic rename, token-verified release; it is non-reentrant and advisory only.
986
+ 4. Lock names: project `flow` / `log` / `flow-archive` / `kb` / `update-state` / `status-{stageDir}` / `scheme-{stageDir}`; global `global-config` / `global-update-state` / `runtime-log`.
987
+
988
+ **`flow.json`**
989
+
990
+ 5. Disk `ops` entries never contain `id`; `id` is injected from the map key on load.
991
+ 6. `save()` = (fill `meta`, stamp `meta.updated`) → deep clone → strip `op.id` → **under `flow.lock`**: compare on-disk integer `revision` to the baseline, throw `FlowConcurrentModificationError` on mismatch, else write `revision = baseline + 1` with `{backup:true}` and adopt the new revision.
992
+ 7. Missing/invalid `revision` ≡ `0`; revisions increase by exactly 1 per successful write; concurrent writers must retry from a fresh read.
993
+ 8. Conflict ⇒ command layer prints `common.concurrentConflict` and exits `2`.
994
+ 9. `.bak` = previous generation, written only on save/restore (and repair unless recovered), never rotated, never deleted.
995
+ 10. `load()` is silent and total: absent file / bad JSON ⇒ unloaded (`loadedRevision = 0`); it only repairs `stage.ops ??= {}`, `stage.deps ??= []` and `op.id`; everything else is `validate()`/`repair()` territory.
996
+ 11. `initFlow` never overwrites an existing `flow.json`; new files carry `revision: 0` and the exact default template.
997
+ 12. `log[]` is append-only with no cap; `flow log` shows `slice(-N)`.
998
+
999
+ **Version archive**
1000
+
1001
+ 13. `flow archive <version>` gate = (version present in active stages) ∧ (all its stages `phase==='done'`) ∧ (no `status==='open'` review with `blocking !== false` in that version's scope); **no `--force`**; every refusal exits 1 with zero writes.
1002
+ 14. Write order: archive file (lock `flow-archive`, `backup:false`) → remove from memory → append `meta.archived_versions` (idempotent) → pipeline disposition → append `archive_flow_version` log → `save()` (lock `flow`). Never nested locks.
1003
+ 15. Archive file key order `version, stages, reviews, log, counts, schemaVersion, archived_at`; `schemaVersion` always `1`; `counts` recomputed on read when absent.
1004
+ 16. `flow archive list` is filesystem-only and lexicographically sorted; `show --json` returns the file verbatim.
1005
+ 17. `meta.archived_versions` is append-only and never pruned.
1006
+
1007
+ **Everything else**
1008
+
1009
+ 18. `metrics.json` accumulates per-agent counters with `avgDurationMs = round(total/totalRuns)`, atomically written without a lock, no retention; `load()` before `recordRun()` + `save()`.
1010
+ 19. Workspace audit log path `log/{yyyy}/{MM}/{dd}/{yyyy-mm-dd}-{user}-{NNN}.md` with atomic `NNN`, three idempotent indexes, 30-row `log.md`, best-effort failure; legacy flat dirs are read-only.
1011
+ 20. CLI runtime log line `[ISO][LEVEL][pid] message`, `cli start:` / `cli done exit=… [duration_ms=…] [project=…] [session=…]`, `~/.openfeel/cli/logs/openfeel-YYYY-MM-DD.log`, best-effort, no rotation.
1012
+ 21. `.info.json` = `{user, lang}`; `user` is never overwritten; `lang` defaults to `zh-CN`.
1013
+ 22. `openfeel archive <stage>` writes `.openfeel/log/archive-{stage}.md`, appends one `archive_stage` log entry, saves `flow.json`, optionally seeds `kb/patterns.md` — and removes nothing.