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,1243 @@
1
+ # `openfeel req` — Requirement Distribution System — Behavioral Specification
2
+
3
+ Compatibility specification for a reimplementation of the requirement layer (7 statuses, lifecycle,
4
+ claim protocol, split, advance, aggregation, board) of the OpenFeel TypeScript CLI.
5
+
6
+ **Source of truth (read-only analysis of `C:\Users\Liuary\Dev\Mine\AI\OpenFeel`):**
7
+
8
+ | Concern | File |
9
+ |---|---|
10
+ | Status/priority/ID value domains, `RequirementData` | `src/core/req/types.ts` |
11
+ | All mutations + ID allocation + transition table + whitelist | `src/core/req/requirement-ops.ts` |
12
+ | Progress projection + symmetric write-back + health items | `src/core/req/aggregate.ts` |
13
+ | Body markdown file (path + skeleton) | `src/core/req/body-file.ts` |
14
+ | CLI surface (11 subcommands, output, `--json`) | `src/commands/req.ts` |
15
+ | `requirement_id` on stage, `FlowData`, mode resolution, aggregate triggers | `src/core/flow-manager.ts` |
16
+ | `WORKFLOW_MODES` | `src/core/config.ts` (line 27) |
17
+ | `PIPELINE_PHASES` (15 phases) | `src/core/pipeline-schema.ts` (line 14) |
18
+ | Stage-ID normalization | `src/core/plan/path.ts` (`normalizeStageId`) |
19
+ | Owner default chain | `src/core/workspace/identity.ts` (`getUserName`) |
20
+ | Message strings | `src/core/i18n-data/zh-CN.ts` lines 1081–1159, `src/core/i18n-data/en.ts` lines 1037–1114 |
21
+ | Authoritative usage doc | `src/core/templates-data/skills/openfeel-req/SKILL.md` |
22
+ | Behavioral evidence | `test/core/req.test.ts`, `test/commands/req.test.ts` (T-A1…T-E4, T90-REQ-*, T93.*) |
23
+
24
+ **Architectural invariants a reimplementation must preserve**
25
+
26
+ 1. **Zero business logic in the command layer.** `src/commands/req.ts` only parses args, calls one engine
27
+ primitive, maps an error code to a message, and sets an exit code. All validation, ID allocation,
28
+ transition legality and the `done` gate live in `src/core/req/`.
29
+ 2. **Engine primitives are pure in-memory.** No primitive writes a file. Persistence is *always* the
30
+ caller's `FlowManager.save()`. This makes "failure ⇒ zero disk change" structural.
31
+ 3. **Read-side tolerance.** Missing / non-array `requirements` is treated as `[]`; malformed `id` values are
32
+ skipped; out-of-domain `status` values never throw. No `load()` step back-fills `requirements`,
33
+ `parent_id`, or `workflow_mode`.
34
+ 4. **Single projection direction.** `stage.phase` → `requirement.status`. A requirement status must never
35
+ push stage phases forward.
36
+ 5. **`workflow_mode` is a *scheduling* attribute.** It gates *carrier legality* (`general` ⇔ zero linked
37
+ stages) and the `done` gate; it never participates in the aggregate projection or drift reconciliation.
38
+
39
+ ---
40
+
41
+ ## 1. Requirement record schema
42
+
43
+ Stored at `.openfeel/flow.json` → top-level optional array `requirements: RequirementData[]`
44
+ (`FlowData.requirements?`, `src/core/flow-manager.ts:132`). It is **not** back-filled at load time.
45
+
46
+ ```ts
47
+ interface RequirementData {
48
+ id: string; // required
49
+ title: string; // required, non-empty (trimmed)
50
+ status: RequirementStatus; // required
51
+ target_version?: string; // optional
52
+ priority?: RequirementPriority; // optional
53
+ owner?: string; // optional
54
+ claimed_at?: string; // optional
55
+ created_at: string; // required, ISO 8601
56
+ updated_at: string; // required, ISO 8601
57
+ body_path?: string; // optional
58
+ parent_id?: string; // optional
59
+ workflow_mode?: WorkflowMode; // optional
60
+ }
61
+ ```
62
+
63
+ | Field | Type | Optional | Semantics | Who writes it |
64
+ |---|---|---|---|---|
65
+ | `id` | `string` matching `/^REQ-(\d{3,})$/` | no | Immutable. Allocated by `nextRequirementId` (max+1, never recycled). | `add`, `split` |
66
+ | `title` | `string` | no | Human-readable summary; always stored trimmed. Empty-after-trim is rejected. | `add` (throws), `split` (error code), `set/update` |
67
+ | `status` | `RequirementStatus` (7 values) | no | Coarse explicit status. **New records are always `draft`** (except split children, always `scheduled`). | `add`, `split`, `update`, `advance`, aggregate write-back |
68
+ | `target_version` | `string` | yes | Estimated version, e.g. `v1.3.0`. **Never a gate** — does not affect create/claim/link/advance/done. Only used as a `list`/`board` filter and as the primary `next` sort key. Stored trimmed; empty string rejected. | `add`, `set/update` |
69
+ | `priority` | `'P0' \| 'P1' \| 'P2'` | yes | Informational only; no ordering/gating use anywhere. | `add`, `set/update` |
70
+ | `owner` | `string` | yes | Claimant. Absent = unclaimed. Trimmed on claim. | `claim` (set), `release` (deleted) |
71
+ | `claimed_at` | ISO 8601 | yes | **Same batch as `owner`** — set only by `claim`, deleted only by `release`. Never read for gating; displayed by `show` only. No TTL. | `claim`, `release` |
72
+ | `created_at` | ISO 8601 | no | Set once at construction. Used as the secondary `next` sort key and in the body skeleton change-log line. | `add`, `split` |
73
+ | `updated_at` | ISO 8601 | no | Refreshed **only on an actual change**; single-assignment discipline (see §11). | every mutating op except no-ops |
74
+ | `body_path` | `string` (POSIX separators) | yes | `.openfeel/requirements/REQ-NNN.md`. Set by `writeRequirementBodySkeleton`, not by `add`/`split` themselves. | `writeRequirementBodySkeleton` |
75
+ | `parent_id` | `string` (`REQ-NNN`) | yes | **Traceability only.** Does not participate in aggregation, the `done` gate, or any cascade. Dangling values are echoed verbatim, never validated on read. | `split` |
76
+ | `workflow_mode` | `'full' \| 'fast' \| 'general'` (`WORKFLOW_MODES`) | yes | Scheduling attribute. Resolved once by `FlowManager` onto linked stages. Participates in the *carrier gate* and the `done` gate (general dual path). Not in aggregation / drift. | `add`, `set/update` |
77
+
78
+ ### 1.1 Value domains (single source)
79
+
80
+ ```ts
81
+ // src/core/req/types.ts
82
+ export const REQ_STATUSES = ['draft','scheduled','in_progress','done','blocked','cancelled','rejected'] as const;
83
+ export const REQ_PRIORITIES = ['P0','P1','P2'] as const;
84
+ export const REQ_ID_PATTERN = /^REQ-(\d{3,})$/; // non-global (no lastIndex pollution); m[1] feeds nextRequirementId
85
+ // src/core/config.ts
86
+ export const WORKFLOW_MODES = ['full','fast','general'] as const;
87
+ ```
88
+
89
+ Array **declaration order is behavior**: `board` groups render in exactly this order (note it is *not*
90
+ lifecycle order — `done` precedes `blocked`). `list` error messages print `REQ_STATUSES.join(', ')`.
91
+
92
+ ### 1.2 Requirement bodies on disk
93
+
94
+ Location: `{projectPath}/.openfeel/requirements/{id}.md`. The directory is **not** part of `WORKSPACE_DIRS`;
95
+ it is created lazily with idempotent `mkdirSync(..., {recursive:true})`.
96
+
97
+ Three path helpers (`body-file.ts`) — each a single point, never re-derived elsewhere:
98
+
99
+ | Function | Returns |
100
+ |---|---|
101
+ | `getRequirementsDir(projectPath)` | `join(projectPath, '.openfeel', 'requirements')` |
102
+ | `getRequirementBodyRelPath(id)` | `` `.openfeel/requirements/${id}.md` `` — **always POSIX `/`** (never `path.join`) |
103
+ | `getRequirementBodyPath(projectPath, id)` | `join(getRequirementsDir(projectPath), `${id}.md`)` — native separators |
104
+
105
+ `writeRequirementBodySkeleton(projectPath, req)`:
106
+
107
+ 1. `mkdirSync(dir, { recursive: true })`.
108
+ 2. `req.body_path = getRequirementBodyRelPath(req.id)` — **assigned unconditionally**, even on the no-op path,
109
+ so `body_path` is guaranteed consistent. The caller must pass the *same object* returned by
110
+ `addRequirement` / `splitRequirement` (never a shallow copy).
111
+ 3. If the target file already exists → **return, do not overwrite** (preserves human additions).
112
+ 4. Otherwise `atomicWriteFileSync(filePath, skeleton)` (backup disabled).
113
+ 5. Returns the **absolute** file path (this is what the CLI prints).
114
+
115
+ Skeleton — byte-exact (`\n` line endings, `Array.join('\n')`, exactly one trailing newline):
116
+
117
+ ```
118
+ # {id}:{title}
119
+
120
+ > 本文件为需求正文(人读副本,由 openfeel req 机械铺骨架)。**机读权威恒为 `.openfeel/flow.json` 的 `requirements` 条目**;两者不一致时以 flow.json 为准。下方状态为创建时快照,不追写实时状态。
121
+
122
+ ## 状态
123
+
124
+ {status}(创建时快照;以 flow.json 为准)
125
+
126
+ ## 摘要
127
+
128
+ (待补充)
129
+
130
+ ## 目标与验收
131
+
132
+ (待补充)
133
+
134
+ ## 预估版本
135
+
136
+ {target_version | (未指定)}
137
+
138
+ ## 父需求
139
+
140
+ {parent_id | (无)}
141
+
142
+ ## 关联阶段
143
+
144
+ (以 flow.json 的 `stages[*].requirement_id` 为准;此处可人工补充说明)
145
+
146
+ ## 变更记录
147
+
148
+ - {created_at} 创建({id},初始状态 {status})
149
+ ```
150
+
151
+ Notes: the heading line uses a **full-width colon** `:`; `target_version` renders `(未指定)` when absent
152
+ **or** empty string; `status` is a create-time snapshot and is never re-written afterwards
153
+ (`req set`/`advance` do not touch the body). The body is a human-readable copy only; `flow.json` is
154
+ machine-authoritative.
155
+
156
+ ---
157
+
158
+ ## 2. The 7 statuses — state machine
159
+
160
+ ```ts
161
+ // ALLOWED_TRANSITIONS — src/core/req/requirement-ops.ts:231 (the ONLY transition table)
162
+ draft: ['scheduled','blocked','cancelled','rejected']
163
+ scheduled: ['in_progress','blocked','cancelled','rejected']
164
+ in_progress: ['done','blocked','cancelled','rejected']
165
+ blocked: ['scheduled','in_progress','cancelled','rejected']
166
+ done: []
167
+ cancelled: []
168
+ rejected: []
169
+ ```
170
+
171
+ | from \ to | draft | scheduled | in_progress | done | blocked | cancelled | rejected |
172
+ |---|---|---|---|---|---|---|---|
173
+ | **draft** | no-op¹ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
174
+ | **scheduled** | ❌ | no-op¹ | ✅ | ❌² | ✅ | ✅ | ✅ |
175
+ | **in_progress** | ❌ | ❌ | no-op¹ | ✅³ | ✅ | ✅ | ✅ |
176
+ | **done** | ❌ | ❌ | ❌ | no-op¹ | ❌ | ❌ | ❌ |
177
+ | **blocked** | ❌ | ✅ | ✅ | ❌² | no-op¹ | ✅ | ✅ |
178
+ | **cancelled** | ❌ | ❌ | ❌ | ❌ | ❌ | no-op¹ | ❌ |
179
+ | **rejected** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | no-op¹ |
180
+
181
+ ¹ `to === from` is **not an error**: `updateRequirement` skips `applyStatusChange` when
182
+ `p.status === req.status`, so the result is `{ ok: true, changed: false }` (no `updated_at` refresh, no
183
+ audit). CLI prints the no-op message and exits 0.
184
+
185
+ ² Forbidden by the table: `done` is only reachable from `in_progress`. A `scheduled` or `blocked`
186
+ requirement must first go to `in_progress`. This is the reason a static-table rejection (not the dedicated
187
+ `done` gate message) is produced in that case — see §5.4.
188
+
189
+ ³ Additionally gated by the completion predicate `canCompleteRequirement` (§6.4).
190
+
191
+ Terminal set (`isTerminalRequirementStatus`): `done`, `cancelled`, `rejected`. **No transition out of a
192
+ terminal status exists**, so a requirement could not be reopened via the state machine — only the aggregate
193
+ write-back can flip `done → in_progress` (§6.3), and only in response to a stage reset.
194
+
195
+ ### 2.1 Which command performs which transition
196
+
197
+ | Transition | `req add` | `req split` | `req set --status` | `req advance --to` | aggregate write-back |
198
+ |---|---|---|---|---|---|
199
+ | (new) → `draft` | ✅ only path | — | — | — | — |
200
+ | (new) → `scheduled` | — | ✅ only path (split children) | — | — | — |
201
+ | any → `cancelled` / `rejected` | — | — | ✅ | ✅ | — |
202
+ | `draft` → `scheduled` | — | — | ✅ | ✅ | — |
203
+ | `scheduled` → `in_progress` | — | — | ✅ | ✅ | — |
204
+ | `blocked` → `scheduled` / `in_progress` | — | — | ✅ | ✅ | — |
205
+ | (`scheduled`\|`in_progress`) → `done` | — | — | ✅ (gated) | ✅ (gated) | ✅ auto (`requirement_auto_done`) |
206
+ | `done` → `in_progress` | — | — | ✅⁴ | ✅⁴ | ✅ only (`requirement_auto_reopen`) |
207
+ | any non-terminal → `blocked` | — | — | ✅ | ✅ | — |
208
+ | same → same | — | — | no-op | no-op | — |
209
+
210
+ ⁴ **`done → in_progress` is forbidden for both `set` and `advance`.** `ALLOWED_TRANSITIONS.done === []`, so
211
+ `done` has no outgoing edge at all — `in_progress` appears only as a *target* status, never as a source that
212
+ can be re-entered from `done`. The only code path that reopens a completed requirement is the aggregate
213
+ write-back (§6.3), which mutates the record directly and bypasses the table by design. A reimplementation
214
+ must **not** add `in_progress` to `ALLOWED_TRANSITIONS.done`; doing so lets operators reopen finished work.
215
+
216
+ Forbidden transitions all fail identically at the engine level — `applyStatusChange` returns `false`, which
217
+ `updateRequirement` converts to `{ ok:false, code:'invalid_status' }` **before any field of the record is
218
+ mutated** (so a mixed patch such as `{status:'bogus', title:'x'}` changes nothing).
219
+
220
+ ### 2.2 Why these transitions are forbidden
221
+
222
+ * **Terminal is terminal.** `done`/`cancelled`/`rejected` have empty target lists: a completed or abandoned
223
+ requirement is not resurrected by an operator command. Only stage-driven aggregation may reopen `done`.
224
+ * **No `draft → in_progress`/`done`:** publishing (`scheduled`) is an explicit scheduling act; work must be
225
+ visible in the `next` candidate pool before it can be started.
226
+ * **No `done → anything`:** closure is a one-way door; the sole exception is the aggregation projection
227
+ observing that the linked stages are no longer all `done`.
228
+ * **`blocked → done` forbidden:** unblocking must pass through `scheduled` or `in_progress`, which keeps the
229
+ projection semantics honest (a blocked requirement that is force-completed would otherwise bypass the
230
+ stage aggregate).
231
+ * **Nothing returns to `draft`:** `draft` is a construction state only.
232
+
233
+ ---
234
+
235
+ ## 3. The claim protocol
236
+
237
+ ### 3.1 `claimRequirement(data, id, owner)` — `requirement-ops.ts:203`
238
+
239
+ Evaluation order (**order is behavior; each step is a distinct error code**):
240
+
241
+ | # | Condition | Result | Disk effect |
242
+ |---|---|---|---|
243
+ | 1 | `owner.trim() === ''` | `{ ok:false, code:'invalid_field' }` | none |
244
+ | 2 | no requirement with `id` | `{ ok:false, code:'not_found' }` | none |
245
+ | 3 | `isTerminalRequirementStatus(req.status)` | `{ ok:false, code:'invalid_status' }` | none |
246
+ | 4 | `req.owner` set **and** `req.owner !== name` | `{ ok:false, code:'already_claimed' }` | none (zero audit too) |
247
+ | 5 | `req.owner === name` | `{ ok:true, changed:false }` | none — **idempotent, no `updated_at` refresh, no audit** |
248
+ | 6 | otherwise | `req.owner = name; req.claimed_at = now; req.updated_at = req.claimed_at` (same ISO string); audit `requirement_claim {id, owner}` | caller saves |
249
+
250
+ Consequences to reproduce exactly:
251
+
252
+ * Step 3 precedes step 4: claiming someone else's **`done`** requirement yields `invalid_status`
253
+ (`req.claim.terminalTmpl`), not `already_claimed`.
254
+ * Step 5 emits *no* log entry and does *not* bump `updated_at`; the CLI returns early **without calling
255
+ `save()`**, so `meta.revision` is unchanged (test T-B3).
256
+ * `owner` is stored trimmed.
257
+ * `claimed_at` and `updated_at` are the **identical** ISO string (one `new Date()`).
258
+
259
+ ### 3.2 `releaseRequirement(data, id)` — `requirement-ops.ts:219`
260
+
261
+ | # | Condition | Result |
262
+ |---|---|---|
263
+ | 1 | no requirement with `id` | `{ ok:false, code:'not_found' }` |
264
+ | 2 | `!req.owner` | `{ ok:true, changed:false }` — no-op success: **no `updated_at` refresh, no audit, no save** |
265
+ | 3 | otherwise | `delete req.owner; delete req.claimed_at; req.updated_at = now`; audit `requirement_release {id, owner: prevOwner}` |
266
+
267
+ * **No status gate.** Releasing a claim on a terminal requirement is allowed.
268
+ * **No ownership check.** `release` takes no `--owner`; anybody may release anybody's claim. "Light atomic"
269
+ claim protocol: claim is defensive, release is permissive.
270
+ * Fields are **deleted**, not set to `null`/`''` (keys disappear from the JSON object).
271
+
272
+ ### 3.3 Concurrency rules — "无 TTL / 无心跳"
273
+
274
+ * There is **no TTL, no expiry, no heartbeat, no lease renewal**. A stale claim persists forever until an
275
+ explicit `req release`. `claimed_at` is display-only; no code reads it for a gate.
276
+ * Atomicity comes solely from `FlowManager.save()`'s optimistic concurrency: `save()` compares the
277
+ in-memory `meta.revision` against the on-disk value; on mismatch it throws
278
+ `FlowConcurrentModificationError(expected, actual)`, the command layer prints
279
+ `common.concurrentConflict` and exits **2**, and **nothing is written**. So two simultaneous
280
+ `req claim` on the same id resolve as "one wins with revision bump, the other exits 2 and retries".
281
+ * Because `requirements` is a top-level array in a single `flow.json`, there is no per-requirement lock; the
282
+ whole document is the concurrency unit. That is acceptable for the intended single-machine use.
283
+ * The claim gate itself is a *logical* check inside one process's in-memory copy; it is not a mutex.
284
+
285
+ ### 3.4 Who may claim / default owner resolution (command layer only)
286
+
287
+ `req claim <id> [--owner <name>]`:
288
+
289
+ 1. `isValidReqId(id)` → otherwise `req.invalidIdTmpl`, exit 1, zero write.
290
+ 2. If `--owner` is absent: `owner = getUserName(process.cwd())`, whose chain is
291
+ `.openfeel/.info.json`'s `user` → `git config user.name` → sentinel `'unknown'`
292
+ (`src/core/workspace/identity.ts:46`).
293
+ If the resolved value is `''` or `'unknown'` → print `req.claim.ownerUnresolvedTmpl`, exit 1, **zero
294
+ write**, and never reach the engine. (A literal `known user named "unknown"` is therefore unusable as an
295
+ implicit owner; pass `--owner unknown` explicitly — the explicit path skips the chain entirely.)
296
+ 3. Call `claimRequirement`; map by code (see §12).
297
+
298
+ `req release <id>`: id-format check only; no owner resolution.
299
+
300
+ ---
301
+
302
+ ## 4. `split` — the full algorithm
303
+
304
+ `splitRequirement(data, parentId, children: Array<{title: string}>)` — `requirement-ops.ts:165`.
305
+ Pure in-memory; one audit entry; persistence by the caller.
306
+
307
+ ### 4.1 Gates (fixed order; any failure ⇒ zero change **and zero audit**)
308
+
309
+ | # | Condition | Result |
310
+ |---|---|---|
311
+ | 1 | `!Array.isArray(children) \|\| children.length === 0` | `{ ok:false, code:'invalid_field' }` (argument-shape) |
312
+ | 2 | no requirement with `parentId` | `{ ok:false, code:'not_found' }` |
313
+ | 3 | `isTerminalRequirementStatus(parent.status)` | `{ ok:false, code:'invalid_status' }` (same terminal gate as `claim`/`link`) |
314
+ | 4 | any child title, trimmed, is `''` | `{ ok:false, code:'invalid_field' }` |
315
+
316
+ The child-title validation is **content-aligned with `add` but error-paradigm-aligned with `update`**:
317
+ `addRequirement` *throws* on an empty title (programming-error path), while `splitRequirement` returns a
318
+ runtime error code (self-contained validation loop). Do not unify these.
319
+
320
+ CLI `req split <id> --into <t> [--into <t>…] [--json]` adds two command-layer guards **before** the engine:
321
+ missing/invalid `id` → `invalid-argument` (json) / `req.invalidIdTmpl`; `--into` count 0 →
322
+ `invalid-argument field:"into"` (json) / `req.split.noIntoTmpl`.
323
+
324
+ ### 4.2 Construction (single pass, no fallible statement inside the loop)
325
+
326
+ ```
327
+ list = Array.isArray(data.requirements) ? [...data.requirements] : []
328
+ staged = { ...data, requirements: list } // staged container carries "current max"
329
+ now = new Date().toISOString()
330
+ created = []
331
+ for each title in titles (already trimmed):
332
+ child = {
333
+ id: nextRequirementId(staged), // max+1 over list, which already holds earlier children
334
+ title: title,
335
+ status: 'scheduled', // SPLIT_CHILD_STATUS
336
+ parent_id: parentId,
337
+ created_at: now,
338
+ updated_at: now,
339
+ }
340
+ list.push(child); created.push(child)
341
+ data.requirements = list
342
+ appendRequirementLog('requirement_split', { parentId, children: created.map(c => c.id) }) // exactly ONE entry
343
+ return { ok: true, children: created } // the LIVE objects (not shallow copies)
344
+ ```
345
+
346
+ * **ID allocation is sequential**: child *N* = (max at entry) + *N*. The `staged` container trick lets
347
+ `nextRequirementId` stay the single derivation point — there is no second parser.
348
+ * **All-or-nothing**: children are merged into `data.requirements` only after the whole loop. A partially
349
+ built child is unreachable.
350
+ * **`parent_id` records the parent ID and nothing else.** It is traceability: no aggregation, no `done`
351
+ gate, no cascade, no parent status change, no inheritance of fields. A dangling `parent_id` is echoed
352
+ verbatim and never validated on read (`req show` prints it as-is; reconciliation is
353
+ `requirementHealthItems`'s job).
354
+ * **Cycle detection is unnecessary and absent**: children always get *new* max+1 IDs and the direction is
355
+ always `child → parent`, so the graph cannot contain a cycle by construction.
356
+ * **"拆分即发布" (split = publish)**: `SPLIT_CHILD_STATUS = 'scheduled'`, so children immediately join the
357
+ `req next` candidate pool and can be claimed in parallel — each child has its own ID, own body file, own
358
+ owner, own stage links. This is the whole point of `split` vs. just `add`: no `req advance` step is needed
359
+ to make children workable.
360
+ * **What is NOT inherited** (a common reimplementation bug): `target_version`, `priority`, `workflow_mode`,
361
+ `owner`, `claimed_at`, `body_path`, `status`. Children are born with exactly the six keys listed above.
362
+ * **Parent has zero side effects**: status unchanged, `updated_at` not refreshed, no field written on the
363
+ parent. Advancing/archiving the parent is an explicit `feel`/operator action.
364
+
365
+ ### 4.3 CLI post-processing for `split`
366
+
367
+ After `ok:true`, the command writes one body skeleton per child **strictly using the returned live objects**,
368
+ collecting the absolute paths. If any body write throws → `failGeneric`, **`save()` is never called** ⇒
369
+ `flow.json` unchanged (the already-written `.md` files remain — bodies are not transactional). Then
370
+ `saveOrFail` (concurrency → exit 2). Then output.
371
+
372
+ ---
373
+
374
+ ## 5. `advance --to <status>` and `set --status <status>`
375
+
376
+ `advanceRequirement(data, id, to)` is **literally** `updateRequirement(data, id, { status: to })` — "same
377
+ table, same gate, same audit, zero second implementation" (`requirement-ops.ts:345`). Everything below
378
+ therefore applies identically to `req set --status`.
379
+
380
+ ### 5.1 `updateRequirement` execution ladder (`requirement-ops.ts:270`)
381
+
382
+ ```
383
+ ① raw find in data.requirements (NOT via getRequirement — shallow-copy trap) → miss: not_found
384
+ ② keys = entries of patch with value !== undefined
385
+ ③ every key must be in PATCH_WHITELIST → else: invalid_field
386
+ ④ keys.length === 0 (empty patch / all-undefined) → { ok:true, changed:false }
387
+ ⑤ value validation — ALL dimensions first, no mutation until all pass:
388
+ title : String(v).trim() !== '' → else invalid_field
389
+ target_version: typeof v === 'string' && v.trim() !== '' → else invalid_field
390
+ priority : REQ_PRIORITIES.includes(v) → else invalid_field
391
+ status : REQ_STATUSES.includes(v) → else invalid_status ← note the code
392
+ workflow_mode : WORKFLOW_MODES.includes(v) → else invalid_field
393
+ ⑥ carrier gate: if patch.workflow_mode === 'general' AND any linked stage exists
394
+ → general_mode_conflict
395
+ ⑦ status dimension: if patch.status !== undefined && patch.status !== req.status:
396
+ applyStatusChange(...) === false → invalid_status
397
+ ⑧ field dimensions: title / target_version / priority / workflow_mode; assign only when actually different
398
+ ⑨ updated_at: exactly once — if fields changed and status did not, set now(); if status changed,
399
+ applyStatusChange already did it (no double assignment)
400
+ ⑩ audit: requirement_status (from applyStatusChange) and/or requirement_update {id, fields:[...]}
401
+ return { ok: true, changed: statusChanged || changedFields.length > 0 }
402
+ ```
403
+
404
+ ### 5.2 `applyStatusChange` — the single status-write implementation
405
+
406
+ ```
407
+ if (!REQ_STATUSES.includes(to)) return false // invalid_status
408
+ if (to === req.status) return false // caller handles as no-op
409
+ allowed = ALLOWED_TRANSITIONS[req.status]
410
+ if (!allowed || !allowed.includes(to)) return false // out-of-domain current status
411
+ // → undefined → same as "not allowed",
412
+ // never a TypeError
413
+ if (to === 'done' && !canCompleteRequirement(data, req)) return false // the done gate
414
+ req.status = to
415
+ req.updated_at = new Date().toISOString() // single assignment
416
+ audit 'requirement_status' { id, from, to }
417
+ return true
418
+ ```
419
+
420
+ ### 5.3 The `done` gate — `canCompleteRequirement(data, req)` (`requirement-ops.ts:97`)
421
+
422
+ This is **the** rule. Two paths:
423
+
424
+ ```
425
+ canCompleteRequirement(data, req):
426
+ if req.workflow_mode === 'general':
427
+ hasLinked = ∃ stage s in data.stages with (s.requirement_id ?? null) === req.id
428
+ if (!hasLinked) return true // PATH 2 — general dual completion path
429
+ // else: general + linked stages = unreachable corrupt state → fall through, NO exemption
430
+ return allLinkedStagesDone(data, req.id) // PATH 1
431
+
432
+ allLinkedStagesDone(data, requirementId):
433
+ linked = values of data.stages where (s.requirement_id ?? null) === requirementId
434
+ return linked.length > 0 && linked.every(s => s.phase === 'done') // empty set guard ⇒ false
435
+ ```
436
+
437
+ Exact consequences:
438
+
439
+ | Situation | Gate result | Reason |
440
+ |---|---|---|
441
+ | `workflow_mode === 'general'`, **zero** linked stages | **allow `done`** | path 2 (dual carrier completion) |
442
+ | `workflow_mode === 'general'`, ≥1 linked stage (corrupt/impossible state) | delegate to path 1 | defence: mode must not buy an exemption |
443
+ | any other mode (`full`/`fast`/**absent**), zero linked stages | **deny** | `allLinkedStagesDone` empty-set guard returns `false` |
444
+ | any mode, ≥1 linked stage, all `phase === 'done'` | **allow** | path 1 |
445
+ | any mode, ≥1 linked stage, any `phase !== 'done'` | **deny** | path 1 |
446
+
447
+ The predicate source is the requirement's **own `workflow_mode` field, judged directly** — no cascade /
448
+ resolution is consulted here. (`FlowManager.resolveStageWorkflowMode` is a *stage-side scheduling* concern and
449
+ must not be reused for the `done` gate.)
450
+
451
+ Note the empty-set guard asymmetry that a reimplementation typically gets wrong: **absent `workflow_mode`
452
+ with zero linked stages can never be completed**; to complete such a requirement you must either link a stage
453
+ and drive it to `done`, or set `workflow_mode: general` first (`req set --workflow-mode general`, legal
454
+ exactly because it has no linked stages) and then `advance --to done`.
455
+
456
+ ### 5.4 Exact refusal messages for `done`
457
+
458
+ The command layer disambiguates a `done` rejection with a **derived** test, not by inspecting the gate:
459
+
460
+ ```ts
461
+ // advance: from = status read before the call (undefined if the requirement is missing)
462
+ doneGate = (to === 'done') && from !== undefined && getAllowedNextStatuses(from).includes('done')
463
+ // set: current = getRequirement(data, id)?.status ?? ''
464
+ doneGate = (options.status === 'done') && getAllowedNextStatuses(current).includes('done')
465
+ ```
466
+
467
+ i.e. "the static table says `done` is a legal target from the current status, yet the engine still refused ⇒
468
+ the only remaining cause is the completion gate."
469
+
470
+ | Current status | Table allows `done`? | Message key | Rendered (zh-CN) |
471
+ |---|---|---|---|
472
+ | `in_progress` | yes | `req.err.doneGateTmpl` | `{id} 目标状态 done 被拒(当前 {current})——done 须全部关联阶段 done;零关联阶段的需求可设 general 模式显式完成` |
473
+ | `scheduled`, `blocked`, `draft`, terminal | no | `req.err.invalidStatusTmpl` | `{id} 目标状态 {to} 被拒(当前 {current};合法目标:{targets})` |
474
+
475
+ So: `scheduled → done` prints the **generic** message listing the legal targets and **must not** mention the
476
+ `done` gate (test T93.8 asserts `err()` does **not** contain `done 须全部关联阶段` and **does** contain
477
+ `合法目标:`). Conversely `in_progress → done` with an unfinished stage prints the dedicated gate message and
478
+ **must not** say `合法目标:done` (test T-B9).
479
+
480
+ `targets` = `getAllowedNextStatuses(current).join(', ')` — derived from the engine table (a returned copy;
481
+ the command layer must never hold a second transition table). With an out-of-domain stored status,
482
+ `targets` is `''`.
483
+
484
+ ### 5.5 Refusal messages for the `general` carrier gate
485
+
486
+ | Path | Code | Key | Rendered (zh-CN) |
487
+ |---|---|---|---|
488
+ | `set --workflow-mode general` on a requirement **with** linked stages | `general_mode_conflict` | `req.err.generalModeConflictTmpl` | `{id} 已关联阶段——general(无流水线)仅限无关联阶段的需求(可先解绑阶段,或改走 fast / full)` |
489
+ | `link --requirement` of a `general` requirement onto a stage | `general_mode_conflict` | `req.err.generalTargetTmpl` | `{id} 工作流模式为 general(无流水线)——不接受阶段关联` |
490
+
491
+ The gate is **bidirectional** and both directions are required for the `done` dual path to be sound
492
+ (`D-90-4`): `general` ⇔ zero linked stages.
493
+
494
+ ### 5.6 Other `advance` semantics
495
+
496
+ * `advance` does **not** write the requirement body markdown; `body_path`/`# 状态` stay at the create-time
497
+ snapshot.
498
+ * Same-value `advance` → `changed:false` → CLI prints `req.advance.noopTmpl` (`{id} 已是 {to}(no-op)`),
499
+ exit 0, **no save** (revision unchanged).
500
+ * `advance` on a terminal requirement with a different target → `invalid_status` (generic message), exit 1,
501
+ zero disk change.
502
+ * `advance` never touches stages, never triggers aggregation, never syncs `workflow_mode`.
503
+ * Success message: `req.advance.doneTmpl` = `已推进 {id}:{from} → {to}` where `from` is the status read
504
+ **before** the call (a pure read for messaging only).
505
+
506
+ ### 5.7 `set` specifics
507
+
508
+ * Command-layer "no fields" guard: when **all** of `--status/--title/--target-version/--priority/
509
+ --workflow-mode` are `undefined` → `req.set.noFieldsTmpl`, exit 1 (never reaches the engine, so it is an
510
+ error rather than a no-op). This guard's message lists all five options including `--workflow-mode`.
511
+ * Post-save mode propagation (`stage-90 T2.3`), only when `changed === true` **and**
512
+ `--workflow-mode` was supplied:
513
+ ```
514
+ linked = [normalizeStageId(k) ?? k for k in data.stages keys where stage.requirement_id === id]
515
+ if linked.length > 0:
516
+ synced = mgr.syncConfigFieldToStages('workflow_mode', mode, { onlyStages: linked })
517
+ wrote = count of results in {'updated','inserted'}
518
+ print req.set.modeSyncedTmpl {id, mode, count: wrote}
519
+ ```
520
+ Ordering matters: **save the primary change first, then dispatch**. Zero linked stages → requirement field
521
+ only, no propagation message. `changed === false` (already identical value) → early no-op, no propagation.
522
+ * `set` success prints `req.set.doneTmpl` = `已更新 {id}` (after any mode-synced line).
523
+ * `set` is the only command that can change `title`/`target_version`/`priority`/`workflow_mode`.
524
+
525
+ ---
526
+
527
+ ## 6. Aggregation — requirements progress from linked stages
528
+
529
+ Module `src/core/req/aggregate.ts`. Pure functions; all persistence by the caller. `workflow_mode` is
530
+ explicitly **excluded** from this module's decisions (it appears nowhere in the projection).
531
+
532
+ ### 6.1 `computeRequirementAggregate(data, requirementId)`
533
+
534
+ ```
535
+ linked = entries of data.stages (in flow.json key order) where (s.requirement_id ?? null) === requirementId
536
+ stageIds = linked keys, in that order
537
+ stageCount = stageIds.length
538
+ doneCount = |{ s : s.phase === 'done' }|
539
+ anyStarted = ∃ s : s.phase !== 'plan_pending' // 'plan_pending' is the only "not started" phase
540
+ aggregate = null if stageCount === 0 // empty-set guard
541
+ = allLinkedStagesDone ? 'done' // DELEGATED, never re-expressed
542
+ : anyStarted ? 'in_progress' : 'scheduled'
543
+ return { stageIds, stageCount, doneCount, aggregate }
544
+ ```
545
+
546
+ `RequirementAggregateValue = 'scheduled' | 'in_progress' | 'done' | null`.
547
+
548
+ Key points:
549
+
550
+ * **`'done'` is decided by delegating to `allLinkedStagesDone`** — a single done predicate; no second
551
+ expression of "all stages done" may exist anywhere.
552
+ * `anyStarted` is true for *any* non-`plan_pending` phase, **including `done`** (so a partially-finished
553
+ requirement projects `in_progress`). Of the 15 phases
554
+ (`plan_pending, plan_review, plan_passed, scheme_pending, scheme_review, scheme_passed, exec_running,
555
+ review_pending, review_failed, review_passed, test_pending, test_failed, test_passed, archiving, done`),
556
+ only `plan_pending` counts as not-started.
557
+ * **Zero linked stages ⇒ `aggregate = null`**, never `'done'`. A requirement with no stages has *no*
558
+ projection at all — it must not be reported or written as complete by aggregation.
559
+ * Stage ordering follows `flow.json` object key order (insertion order); no secondary sort is applied.
560
+
561
+ ### 6.2 `resolveRequirementStatusForStage(data, stageId)`
562
+
563
+ ```
564
+ stage = data.stages?.[stageId]; reqId = stage?.requirement_id ?? null
565
+ if (!reqId) return null // absent stage / no link / '' → zero action
566
+ agg = computeRequirementAggregate(data, reqId)
567
+ if (agg.aggregate === null) return null // unreachable defensive guard
568
+ return { requirementId: reqId, target: agg.aggregate }
569
+ ```
570
+
571
+ ### 6.3 `syncRequirementStatus(data, stageId)` — the symmetric write-back (`aggregate.ts:88`)
572
+
573
+ ```
574
+ resolved = resolveRequirementStatusForStage(data, stageId)
575
+ if (!resolved) return []
576
+ req = raw find in data.requirements // NOT a shallow copy
577
+ if (!req) return [] // dangling requirement_id → zero action
578
+ if (resolved.target === 'done'):
579
+ if (req.status ∈ AUTO_DONE_PRECONDITION): // AUTO_DONE_PRECONDITION = ['scheduled','in_progress']
580
+ req.status = 'done'; req.updated_at = now
581
+ audit 'requirement_auto_done' { requirementId, from, to:'done', triggerStage }
582
+ affected.push(req.id)
583
+ // status outside the precondition set → zero action, zero audit
584
+ else if (req.status === 'done'):
585
+ req.status = 'in_progress'; req.updated_at = now
586
+ audit 'requirement_auto_reopen' { requirementId, from:'done', to:'in_progress', triggerStage }
587
+ affected.push(req.id)
588
+ return affected
589
+ ```
590
+
591
+ * **Forward direction**: aggregate `done` ∧ current status ∈ `{scheduled, in_progress}` → `done`.
592
+ `draft`, `blocked`, `cancelled`, `rejected` are **design exemptions** and are never auto-completed (a
593
+ cancelled requirement whose stages all finished stays cancelled; a blocked one stays blocked — this is
594
+ deliberate, so blocking is an effective hold).
595
+ * **Reverse direction**: aggregate ≠ `done` ∧ current status === `done` → `in_progress`. It does **not**
596
+ attempt a finer rollback to `scheduled`; `done → in_progress` is the only symmetric undo.
597
+ * Anything else (outside precondition set, dangling link, no change) → zero action, zero audit.
598
+ * `updated_at` is refreshed only when the status actually flips.
599
+ * Status writes go through **raw object mutation**, not the `requirement-ops` path: the table
600
+ (`ALLOWED_TRANSITIONS`) is **bypassed** here, which is exactly how `done → in_progress` becomes possible.
601
+ A reimplementation must not route this through `applyStatusChange`.
602
+
603
+ **Trigger points** (`FlowManager`), and only these two:
604
+
605
+ | Trigger | Condition | Location |
606
+ |---|---|---|
607
+ | `advanceStagePhase` | only when `targetPhase === 'done' && prevStatus !== 'done'`, **after** `endStage(key)` | `flow-manager.ts:1597` |
608
+ | `resetStagePhase` | after the phase write commits (not on `dryRun`, not on the changed===false early return) | `flow-manager.ts:1680` |
609
+
610
+ Both are called inside the same command transaction that already ends with a single `save()` — the
611
+ write-back adds **no new persistence point** (same transaction). Neither `req show`, `req board`, `req next`,
612
+ `req list`, `run health` nor any read path calls aggregation, so **reading never mutates requirement state**.
613
+
614
+ ### 6.4 `workflow_mode = general` interplay (zero linked stages)
615
+
616
+ * A `general` requirement has, by the carrier gate, **zero linked stages**, so
617
+ `computeRequirementAggregate` returns `aggregate: null` for it forever.
618
+ * `syncRequirementStatus` therefore can never write it: `resolveRequirementStatusForStage` needs a stage with
619
+ `requirement_id` pointing at it, which cannot exist.
620
+ * Completion for such a requirement is **explicit only** — `canCompleteRequirement` path 2 returns `true`
621
+ and `advance --to done` / `set --status done` succeed directly (v1.2.1+; before that this was a deadlock).
622
+ * Reachability: a `general` requirement cannot be linked (`link → general_mode_conflict`), and a linked
623
+ requirement cannot become `general` (`set --workflow-mode general → general_mode_conflict`). This pair is
624
+ what keeps the dual path sound.
625
+
626
+ ### 6.5 Drift reconciliation (warn-only; `req`-adjacent)
627
+
628
+ `requirementHealthItems({data, lang})` — used by `flow health`, never by `req`:
629
+
630
+ 1. Section `需求对账` (constant `SECTION_REQ_LINKS`): exactly one item, always emitted. `pass` when no stage's
631
+ `requirement_id` points at a missing requirement (message `flow.health.reqLinksPassTmpl` with `{n}` =
632
+ requirement count); otherwise `warn` with `flow.health.reqDanglingTmpl` (`{items}` = up to 5
633
+ `"{stageId} → {rid}"` pairs joined by `, `).
634
+ 2. Section `需求漂移` (constant `SECTION_REQ_DRIFT`): emitted **only when non-empty**, `warn`, message
635
+ `flow.health.reqDriftTmpl` (`{items}` = up to 5 requirement IDs). Drift = status ∈
636
+ `AUTO_DONE_PRECONDITION` ∧ `aggregate === 'done'` ∧ status ≠ `done` — i.e. exactly the write-back's own
637
+ precondition set, so the two can never disagree. `draft`/`blocked`/terminal requirements are exempt.
638
+ 3. **Never produces `fail`** → does not change the exit code and is not part of `--fix`.
639
+
640
+ ---
641
+
642
+ ## 7. ID allocation — `nextRequirementId`
643
+
644
+ `requirement-ops.ts:67`:
645
+
646
+ ```
647
+ max = 0
648
+ for r in getRequirements(data): // shallow copies; non-array ⇒ [] ⇒ max stays 0
649
+ m = REQ_ID_PATTERN.exec(typeof r?.id === 'string' ? r.id : '')
650
+ if (!m) continue // malformed id: skipped, never throws
651
+ n = parseInt(m[1], 10)
652
+ if (Number.isFinite(n) && n > max) max = n
653
+ return 'REQ-' + String(max + 1).padStart(3, '0')
654
+ ```
655
+
656
+ * **max + 1**, over the numeric capture group of every well-formed `REQ-(\d{3,})` id.
657
+ * **Zero-padded to at least 3 digits** — `padStart(3,'0')`, so 1000+ produces `REQ-1000` (no truncation).
658
+ * **No recycling**: a deleted/cancelled/rejected requirement's number is never reused while it is present; the
659
+ allocation is purely monotone over the current set. (Consequence: if the highest-numbered requirement is
660
+ *removed* from `flow.json`, its number can be re-issued — the rule is "max+1 of what exists", not a
661
+ persisted counter. There is **no counter field** anywhere.)
662
+ * **Allocation happens before insertion**: `addRequirement` computes the id then pushes; `splitRequirement`
663
+ computes each id against a staged container that already includes earlier children.
664
+ * Malformed / non-string ids are silently skipped, so a corrupted document cannot crash allocation.
665
+ * Non-array `requirements` is treated as `[]` → next id is `REQ-001`.
666
+ * Uniqueness is not re-checked after derivation; the parser's digit-count equivalence (`REQ-0001` and
667
+ `REQ-001` both → 1) means mixed-padding legacy data still cannot collide with `max+1`.
668
+
669
+ ---
670
+
671
+ ## 8. `PATCH_WHITELIST`
672
+
673
+ `requirement-ops.ts:264` — the exact, complete set of patchable fields:
674
+
675
+ ```ts
676
+ const PATCH_WHITELIST = ['status', 'title', 'target_version', 'priority', 'workflow_mode'] as const;
677
+ ```
678
+
679
+ | Field | In whitelist | CLI surface |
680
+ |---|---|---|
681
+ | `status` | ✅ | `req set --status`, `req advance --to` |
682
+ | `title` | ✅ | `req set --title` |
683
+ | `target_version` | ✅ | `req set --target-version` |
684
+ | `priority` | ✅ | `req set --priority` |
685
+ | `workflow_mode` | ✅ | `req set --workflow-mode`, `req add --workflow-mode` (at creation) |
686
+ | `id` | ❌ | never (immutable) |
687
+ | `owner`, `claimed_at` | ❌ | only `claim`/`release` |
688
+ | `created_at`, `updated_at` | ❌ | engine-managed |
689
+ | `body_path` | ❌ | only `writeRequirementBodySkeleton` |
690
+ | `parent_id` | ❌ | only `split` (immutable afterwards) |
691
+
692
+ Rules:
693
+
694
+ * A key counts only when its value is `!== undefined`; explicit `undefined` keys are filtered out before the
695
+ whitelist test.
696
+ * Whitelist check precedes value validation, so `{owner:'x'}` → `invalid_field`, regardless of value.
697
+ * **Unknown key ⇒ `invalid_field`, whole patch zero-change.**
698
+ * An empty patch / all-`undefined` patch ⇒ `{ ok:true, changed:false }` (success, no-op) — except at the CLI
699
+ where `set` rejects it earlier with `req.set.noFieldsTmpl`.
700
+ * `delete`/clear semantics: **none exist**. `target_version`/`priority`/`workflow_mode`/`title` cannot be
701
+ un-set via `set` (empty string is rejected with `invalid_field`). Only `release` deletes fields (`owner`,
702
+ `claimed_at`).
703
+
704
+ ---
705
+
706
+ ## 9. `board` and `next`
707
+
708
+ Both are strictly read-only: no `save()`, no `appendLog`, no disk write (tests assert unchanged
709
+ `meta.revision` and `log.length`).
710
+
711
+ ### 9.1 `board`
712
+
713
+ ```
714
+ rows = listRequirements(data, { target_version: options.targetVersion }) // status filter NOT applied
715
+ groups = {} for s in REQ_STATUSES: groups[s] = rows.filter(r => r.status === s)
716
+ ```
717
+
718
+ * **Exactly 7 groups, always present, always in `REQ_STATUSES` declaration order**:
719
+ `draft, scheduled, in_progress, done, blocked, cancelled, rejected`. Empty groups are still rendered.
720
+ (Order is declaration order, not lifecycle order — `done` sits between `in_progress` and `blocked`.)
721
+ * Requirements whose stored `status` is outside the domain are **silently not rendered** in any group (they
722
+ still exist in `flow.json`).
723
+ * `--target-version` filter is applied before grouping; `--owner` is not supported.
724
+ * Human layout, per status:
725
+ ```
726
+ {status}({n})
727
+ - {id} {title}
728
+ - {id} {title}(已认领:{owner})
729
+ ```
730
+ i.e. `req.board.groupTmpl` then one `req.board.itemTmpl` per row, with `req.board.ownedSuffixTmpl`
731
+ appended only when `owner` is set. No blank lines, no separator, no header, no trailer.
732
+ * Within a group, order = storage order (no sorting).
733
+
734
+ ### 9.2 `next`
735
+
736
+ ```
737
+ limit: undefined → 1
738
+ else /^\d+$/ test on the RAW string → Number(...) else NaN
739
+ reject unless Number.isInteger(parsed) && parsed >= 1 → invalid-argument / req.next.limitInvalidTmpl, exit 1
740
+
741
+ rows = listRequirements(data, { status: 'scheduled' }) // ONLY scheduled
742
+ candidates = rows.filter(r => !r.owner || (options.owner !== undefined && r.owner === options.owner))
743
+ candidates.sort(frozen comparator):
744
+ ① target_version ascending, undefined ALWAYS last
745
+ (if av !== bv: av===undefined → 1; bv===undefined → -1; else plain string < / >)
746
+ ② created_at ascending (plain string compare — ISO 8601 sorts lexicographically)
747
+ ③ id ascending (plain string compare) as final tiebreak
748
+ ④ else 0
749
+ picked = candidates.slice(0, limit)
750
+ top = picked[0] ?? null
751
+ ```
752
+
753
+ Selection-rule details a reimplementation is likely to get wrong:
754
+
755
+ * **Only `scheduled`** is eligible. `draft`, `in_progress`, `blocked`, `done` never appear — even if
756
+ unclaimed. Availability = *published* (scheduled) ∧ unclaimed.
757
+ * **`--owner` widens** the pool: without it, only *unclaimed* rows; with it, unclaimed rows **plus** rows
758
+ claimed by that exact owner (so an agent can re-discover its own work). It never narrows to only that
759
+ owner's rows.
760
+ * **Version ordering is lexicographic, not semantic**: `'v1.10.0' < 'v1.9.0'`. Absent `target_version`
761
+ sorts last regardless. Do not introduce semver comparison — the golden output depends on plain `<`/`>`.
762
+ * **`limit` is judged on the parsed number, not on "was the flag passed"**: explicit `--limit 1` is
763
+ byte-identical to omitting it (same human line, same two JSON keys). `--limit 001` parses to 1 and equals
764
+ the default; `0`, `-1`, `1.5`, `abc`, `' 1'`, `+1` are all invalid (regex `/^\d+$/` then integer ≥ 1).
765
+ * **Empty-result precedence over the multi-label**: when `top === null`, the empty message
766
+ (`req.next.empty` = `(无可接取需求)`) is printed *even when* `limit > 1`; the
767
+ `可接取需求({n}):` label is not printed. In JSON, `requirement: null` and (limit>1) `candidates: []`.
768
+ * The multi-label count is `picked.length` (actual), not `limit`: 3 candidates with `--limit 5` prints
769
+ `可接取需求(3):` and 3 item lines — no padding.
770
+ * `listRequirements` returns a fresh shallow-copied array, so the in-place `sort` cannot disturb
771
+ `data.requirements`.
772
+ * `--json` human/JSON split and key-presence rules: see §10.
773
+
774
+ ---
775
+
776
+ ## 10. `--json` output shapes
777
+
778
+ Global JSON conventions: a **single pure JSON document on stdout** (`JSON.stringify(payload, null, 2)`),
779
+ `stderr` stays empty, and `schemaVersion: 1` is always the **first** key. Failure payloads also go to
780
+ `stdout` with `process.exitCode = 1` (never to stderr).
781
+
782
+ `asJson = options.json === true || command.parent?.opts().json === true` — the parent `req` group does **not**
783
+ declare `--json`, so in practice only the subcommand flag matters (the parent check is defensive).
784
+
785
+ ### 10.1 Write commands — only `split` supports `--json`
786
+
787
+ `add`, `claim`, `release`, `set`, `link`, `advance` have **no `--json` option at all**: passing it is a
788
+ commander "unknown option" error (test T-B11). Their `failNoInit` path is the human one.
789
+
790
+ `req split <id> --into … --json`:
791
+
792
+ **Success (exit 0):**
793
+ ```json
794
+ {
795
+ "schemaVersion": 1,
796
+ "parent": "REQ-001",
797
+ "children": [
798
+ {
799
+ "id": "REQ-002",
800
+ "title": "A",
801
+ "status": "scheduled",
802
+ "parent_id": "REQ-001",
803
+ "created_at": "2026-01-01T00:00:00.000Z",
804
+ "updated_at": "2026-01-01T00:00:00.000Z",
805
+ "body_path": ".openfeel/requirements/REQ-002.md"
806
+ }
807
+ ]
808
+ }
809
+ ```
810
+ Key order is exactly `schemaVersion`, `parent`, `children`. `parent` is the **id string** (not an object).
811
+ `children` are the full `RequirementData` records **as persisted** — which is why `body_path` appears:
812
+ the skeletons are written before `save()`, so the live objects already carry it.
813
+
814
+ **Failures (exit 1):**
815
+
816
+ | Situation | Payload |
817
+ |---|---|
818
+ | `--json` + flow.json not loaded | `{ "schemaVersion": 1, "error": "no-init" }` |
819
+ | malformed `id` (before engine) | `{ "schemaVersion": 1, "error": "invalid-argument", "field": "id", "value": "<input>" }` |
820
+ | `--into` count 0 | `{ "schemaVersion": 1, "error": "invalid-argument", "field": "into" }` |
821
+ | engine `not_found` | `{ "schemaVersion": 1, "error": "not_found", "id": "<id>" }` |
822
+ | engine `invalid_status` | `{ "schemaVersion": 1, "error": "invalid_status", "id": "<id>" }` |
823
+ | engine `invalid_field` | `{ "schemaVersion": 1, "error": "invalid_field", "id": "<id>" }` |
824
+
825
+ Concurrency failure is *not* JSON: it goes through `handleConcurrentConflict` → stderr + `exit(2)`.
826
+
827
+ ### 10.2 Read commands with `--json` (for completeness)
828
+
829
+ | Command | Success payload | Failure payloads |
830
+ |---|---|---|
831
+ | `req list` | `{ schemaVersion, requirements: RequirementData[] }` | invalid `--status`: `{ schemaVersion, error:'invalid-argument', field:'status', value, allowed:[...REQ_STATUSES] }`; no-init: `{ schemaVersion, error:'no-init' }` |
832
+ | `req show` | `{ schemaVersion, requirement, stages: string[], parent: string \| null, children: string[] }` — key order fixed, `stages` = raw `flow.json` stage keys, `parent` = `r.parent_id ?? null` echoed verbatim (dangling not an error), `children` = storage-order ids | invalid id: `{ schemaVersion, error:'invalid-argument', field:'id', value }`; missing: `{ schemaVersion, error:'not_found', id }`; no-init |
833
+ | `req board` | `{ schemaVersion, groups }` where `groups` has exactly the 7 `REQ_STATUSES` keys in declaration order, each an array of full records | no-init |
834
+ | `req next` | `limit === 1`: `{ schemaVersion, requirement }` (**exactly two keys**, no `candidates`); `limit > 1`: `{ schemaVersion, requirement, candidates }` (the two existing keys are byte-identical, `candidates` appended) | invalid limit: `{ schemaVersion, error:'invalid-argument', field:'limit', value }`; no-init |
835
+
836
+ `req show --json` has no aggregation fields — `stages` is a raw read-only enumeration of IDs.
837
+
838
+ ---
839
+
840
+ ## 11. Audit log entries
841
+
842
+ All engine-level requirement writes append to `data.log` via `appendRequirementLog`, with
843
+ `agent: 'cli'`, `time: new Date().toISOString()`, and a `detail` object:
844
+
845
+ | action | detail | Emitted by |
846
+ |---|---|---|
847
+ | `requirement_add` | `{ id, title }` | `addRequirement` |
848
+ | `requirement_split` | `{ parentId, children: string[] }` — exactly **one** entry per split | `splitRequirement` |
849
+ | `requirement_claim` | `{ id, owner }` | `claimRequirement` (not on idempotent no-op) |
850
+ | `requirement_release` | `{ id, owner: prevOwner }` | `releaseRequirement` (not on unclaimed no-op) |
851
+ | `requirement_status` | `{ id, from, to }` | `applyStatusChange` |
852
+ | `requirement_update` | `{ id, fields: string[] }` — only actually-changed field names | `updateRequirement` |
853
+ | `requirement_link` | `{ stage, requirementId }` (`requirementId: null` for unlink) | `linkRequirement` |
854
+ | `requirement_auto_done` | `{ requirementId, from, to: 'done', triggerStage }` | `syncRequirementStatus` |
855
+ | `requirement_auto_reopen` | `{ requirementId, from: 'done', to: 'in_progress', triggerStage }` | `syncRequirementStatus` |
856
+
857
+ No audit entry is written for a rejected operation or a no-op. A successful write always coincides with
858
+ exactly one `save()` (⇒ `meta.revision += 1`).
859
+
860
+ `updated_at` discipline (`REV-81-013`, single assignment): `applyStatusChange` refreshes it when the status
861
+ changes; `updateRequirement` refreshes it once more **only** when fields changed *and* the status did not;
862
+ `claim`/`release` refresh it themselves; no-op paths never refresh it.
863
+
864
+ ---
865
+
866
+ ## 12. Engine error codes → exit codes and message mapping
867
+
868
+ `RequirementErrorCode` (frozen): `not_found` | `stage_not_found` | `already_claimed` | `already_linked` |
869
+ `invalid_status` | `invalid_field` | `general_mode_conflict`.
870
+
871
+ `RequirementOpResult = { ok:true; changed:boolean } | { ok:false; code }`;
872
+ `RequirementSplitResult = { ok:true; children: RequirementData[] } | { ok:false; code }` (business branching,
873
+ no exceptions).
874
+
875
+ Exit codes: **0** success (including every no-op); **1** validation / occupancy / existence failure, or an
876
+ `add` construction throw; **2** flow.json optimistic-concurrency conflict (`process.exit(2)` inside
877
+ `handleConcurrentConflict`, after printing to stderr).
878
+
879
+ ### 12.1 Per-command mapping table (zh-CN strings are the reference rendering)
880
+
881
+ | Code | `add` | `claim` | `release` | `set` | `advance` | `link` | `split` |
882
+ |---|---|---|---|---|---|---|---|
883
+ | `not_found` | — | `req.err.notFoundTmpl` | `req.err.notFoundTmpl` | `req.err.notFoundTmpl` | `req.err.notFoundTmpl` | `req.err.notFoundTmpl` | `req.err.notFoundTmpl` |
884
+ | `stage_not_found` | — | — | — | — | — | `req.err.stageNotFoundTmpl` | — |
885
+ | `already_claimed` | — | `req.err.alreadyClaimedTmpl` | — | — | — | — | — |
886
+ | `already_linked` | — | — | — | — | — | `req.err.alreadyLinkedTmpl` | — |
887
+ | `invalid_status` | — | `req.claim.terminalTmpl` | — | `req.err.invalidStatusTmpl` or `req.err.doneGateTmpl` | same as `set` | `req.link.terminalTmpl` | `req.split.terminalTmpl` |
888
+ | `invalid_field` | thrown (see below) | `req.err.invalidFieldTmpl` | — | `req.err.invalidFieldTmpl` | — | — | `req.split.invalidFieldTmpl` |
889
+ | `general_mode_conflict` | — | — | — | `req.err.generalModeConflictTmpl` | — | `req.err.generalTargetTmpl` | — |
890
+
891
+ Unregistered codes fall back to `common.errorTmpl` with the raw code as `{msg}`.
892
+
893
+ ### 12.2 `add` is the only throwing primitive
894
+
895
+ `addRequirement` throws (never returns a code) on construction-contract violations; the CLI catches and prints
896
+ `common.errorTmpl` (`错误:{msg}`), exit 1, zero write:
897
+
898
+ | Message | Trigger |
899
+ |---|---|
900
+ | `需求标题不能为空` | `title`, after trim, is `''` or not a string |
901
+ | `需求优先级非法:{value}` | `priority` supplied and not in `REQ_PRIORITIES` |
902
+ | `需求工作流模式非法:{value}` | `workflow_mode` supplied and not in `WORKFLOW_MODES` |
903
+ | `预估版本不能为空串` | `target_version` supplied and trims to `''` |
904
+
905
+ (Registered as an accepted asymmetry: `add` throws at construction time; `update`/`split` return runtime error
906
+ codes. Do not normalize.)
907
+
908
+ ### 12.3 stderr-only command-layer messages (no engine involvement)
909
+
910
+ | Key | zh-CN |
911
+ |---|---|
912
+ | `req.invalidIdTmpl` | `{input} → 非法需求 ID(应为 REQ-NNN,如 REQ-001)` |
913
+ | `req.link.exclusiveTmpl` | `--requirement 与 --unlink 必须且只能提供一个` |
914
+ | `req.set.noFieldsTmpl` | `未提供任何字段;至少提供 --status / --title / --target-version / --priority / --workflow-mode 之一` |
915
+ | `req.split.noIntoTmpl` | `未提供 --into;请至少给出一个子需求标题(--into 可重复)` |
916
+ | `req.claim.ownerUnresolvedTmpl` | `无法解析当前用户名(.openfeel/.info.json 与 git config user.name 均不可用);请显式传入 --owner <name>` |
917
+ | `req.list.invalidStatusTmpl` | `{value} → 非法状态过滤值(合法值:{allowed})` |
918
+ | `req.next.limitInvalidTmpl` | `{value} → 非法 --limit 值(应为 ≥1 的整数)` |
919
+ | `common.errorNoInit` | `错误:flow.json 未初始化,请先运行 openfeel init` |
920
+ | `common.errorTmpl` | `错误:{msg}` |
921
+ | `common.concurrentConflict` | `[并发冲突] flow.json 已被其它进程修改(期望 revision={expected},磁盘={actual});本次修改未写入,请重新执行该命令重试。` |
922
+ | `common.none` | `(无)` — used by `show` for absent `target_version` / `priority` / `body_path` |
923
+
924
+ ### 12.4 Full message string table (zh-CN / en)
925
+
926
+ | Key | zh-CN | en |
927
+ |---|---|---|
928
+ | `req.add.doneTmpl` | `已创建 {id}:{title}(状态 draft)` | `Created {id}: {title} (status: draft)` |
929
+ | `req.add.bodyTmpl` | `正文骨架:{path}` | `Body skeleton: {path}` |
930
+ | `req.list.empty` | `(无匹配需求)` | `(no matching requirements)` |
931
+ | `req.list.itemTmpl` | ` - {id} [{status}] {title}` | ` - {id} [{status}] {title}` |
932
+ | `req.show.titleTmpl` | `{id}:{title}` | `{id}: {title}` |
933
+ | `req.show.metaTmpl` | `状态: {status} | 预估版本: {targetVersion} | 优先级: {priority}` | `Status: {status} \| Target version: {targetVersion} \| Priority: {priority}` |
934
+ | `req.show.ownerTmpl` | `认领人: {owner}(认领于 {claimedAt})` | `Owner: {owner} (claimed at {claimedAt})` |
935
+ | `req.show.ownerNone` | `认领人: (无)` | `Owner: (none)` |
936
+ | `req.show.timesTmpl` | `创建: {created} | 更新: {updated}` | `Created: {created} \| Updated: {updated}` |
937
+ | `req.show.bodyTmpl` | `正文: {path}` | `Body: {path}` |
938
+ | `req.show.parentTmpl` | `父需求: {parent}` | `Parent: {parent}` |
939
+ | `req.show.parentNone` | `父需求: (无)` | `Parent: (none)` |
940
+ | `req.show.childrenLabel` | `子需求:` | `Children:` |
941
+ | `req.show.childItemTmpl` | ` - {id} [{status}] {title}` | ` - {id} [{status}] {title}` |
942
+ | `req.show.childrenNone` | `子需求: (无)` | `Children: (none)` |
943
+ | `req.show.stagesLabel` | `关联阶段:` | `Linked stages:` |
944
+ | `req.show.stageItemTmpl` | ` - {stage}` | ` - {stage}` |
945
+ | `req.show.stagesNone` | `关联阶段: (无)` | `Linked stages: (none)` |
946
+ | `req.err.notFoundTmpl` | `需求 {id} 不存在` | `Requirement {id} not found` |
947
+ | `req.err.stageNotFoundTmpl` | `阶段 {stage} 不存在(未注册于 flow.json 的 stages)` | `Stage {stage} not found (not registered in flow.json stages)` |
948
+ | `req.err.alreadyClaimedTmpl` | `{id} 已被他人认领;须先释放:openfeel req release {id}` | `{id} is claimed by someone else; release first: openfeel req release {id}` |
949
+ | `req.err.alreadyLinkedTmpl` | `{stage} 已关联其它需求;须先解绑:openfeel req link --stage {stage} --unlink` | `{stage} is already linked to another requirement; unlink first: openfeel req link --stage {stage} --unlink` |
950
+ | `req.err.invalidFieldTmpl` | `{id} 字段取值非法(检查 --status / --title / --target-version / --priority / --workflow-mode)` | `{id} invalid field value (check --status / --title / --target-version / --priority / --workflow-mode)` |
951
+ | `req.err.invalidStatusTmpl` | `{id} 目标状态 {to} 被拒(当前 {current};合法目标:{targets})` | `{id} target status {to} rejected (current {current}; allowed: {targets})` |
952
+ | `req.err.doneGateTmpl` | `{id} 目标状态 done 被拒(当前 {current})——done 须全部关联阶段 done;零关联阶段的需求可设 general 模式显式完成` | `{id} target status done rejected (current {current}) — done requires all linked stages done; a requirement with no linked stages can use general mode for explicit completion` |
953
+ | `req.err.generalModeConflictTmpl` | `{id} 已关联阶段——general(无流水线)仅限无关联阶段的需求(可先解绑阶段,或改走 fast / full)` | `{id} has linked stages — general (no pipeline) requires a requirement without linked stages (unlink first, or use fast / full)` |
954
+ | `req.err.generalTargetTmpl` | `{id} 工作流模式为 general(无流水线)——不接受阶段关联` | `{id} uses workflow mode general (no pipeline) — stage linking is not allowed` |
955
+ | `req.claim.doneTmpl` | `已认领 {id}({owner})` | `Claimed {id} ({owner})` |
956
+ | `req.claim.idempotentTmpl` | `{id} 已由 {owner} 认领(no-op)` | `{id} already claimed by {owner} (no-op)` |
957
+ | `req.claim.terminalTmpl` | `{id} 已是终态({status}),不可认领` | `{id} is in a terminal status ({status}); cannot claim` |
958
+ | `req.release.doneTmpl` | `已释放 {id}` | `Released {id}` |
959
+ | `req.release.noopTmpl` | `{id} 未被认领(no-op)` | `{id} is not claimed (no-op)` |
960
+ | `req.set.doneTmpl` | `已更新 {id}` | `Updated {id}` |
961
+ | `req.set.noopTmpl` | `{id} 无变更(no-op)` | `{id} unchanged (no-op)` |
962
+ | `req.set.modeSyncedTmpl` | `已将工作流模式 {mode} 下发到 {count} 个关联阶段` | `Workflow mode {mode} dispatched to {count} linked stage(s)` |
963
+ | `req.link.doneTmpl` | `已将 {stage} 关联到 {id}` | `Linked {stage} to {id}` |
964
+ | `req.link.unlinkDoneTmpl` | `已解除 {stage} 的需求关联` | `Unlinked requirement from {stage}` |
965
+ | `req.link.noopTmpl` | `{stage} 关联未变化(no-op)` | `{stage} link unchanged (no-op)` |
966
+ | `req.link.terminalTmpl` | `{id} 已是终态({status}),不可关联` | `{id} is in a terminal status ({status}); cannot link` |
967
+ | `req.link.modeSyncedTmpl` | `已将需求工作流模式 {mode} 下发到阶段 {stage}(status.md 字段 + flow.json 冗余同批)` | `Requirement workflow mode {mode} dispatched to stage {stage} (status.md field + flow.json redundancy batched)` |
968
+ | `req.advance.doneTmpl` | `已推进 {id}:{from} → {to}` | `Advanced {id}: {from} -> {to}` |
969
+ | `req.advance.noopTmpl` | `{id} 已是 {to}(no-op)` | `{id} is already {to} (no-op)` |
970
+ | `req.board.groupTmpl` | `{status}({n})` | `{status} ({n})` |
971
+ | `req.board.itemTmpl` | ` - {id} {title}` | ` - {id} {title}` |
972
+ | `req.board.ownedSuffixTmpl` | `(已认领:{owner})` | ` (claimed by {owner})` |
973
+ | `req.next.foundTmpl` | `下一项:{id} [{status}] {title}` | `Next: {id} [{status}] {title}` |
974
+ | `req.next.ownedSuffixTmpl` | `(已认领:{owner})` | ` (claimed by {owner})` |
975
+ | `req.next.empty` | `(无可接取需求)` | `(no claimable requirement)` |
976
+ | `req.next.multiLabel` | `可接取需求({n}):` | `Claimable requirements ({n}):` |
977
+ | `req.next.itemTmpl` | ` - {id} [{status}] {title}` | ` - {id} [{status}] {title}` |
978
+ | `req.split.doneTmpl` | `已拆分 {id} → {children}(共 {n} 条子需求)` | `Split {id} -> {children} ({n} child requirements)` |
979
+ | `req.split.childTmpl` | ` - {id}:{title}(正文:{path})` | ` - {id}: {title} (body: {path})` |
980
+ | `req.split.terminalTmpl` | `{id} 已是终态({status}),不可拆分` | `{id} is in a terminal status ({status}); cannot split` |
981
+ | `req.split.invalidFieldTmpl` | `子需求取值非法(标题不能为空)` | `Invalid child value (title must not be empty)` |
982
+
983
+ Interpolation is `{name}` → `vars[name]`, missing var renders literally as `{name}`. In `req.split.doneTmpl`,
984
+ `{children}` is the child ids joined by `, `. In `req.split.childTmpl`, `{path}` is the **absolute** body path.
985
+
986
+ ---
987
+
988
+ ## 13. CLI surface — the 11 subcommands
989
+
990
+ `registerReqCommand(program)` registers the group `req` (`description: '需求管理(需求流转命令组)'`). The
991
+ group itself declares no options. All subcommands construct `new FlowManager(process.cwd())` and refuse with
992
+ `failNoInit` when `!mgr.isLoaded()`.
993
+
994
+ | # | Subcommand | Arguments / options | `--json` | Writes |
995
+ |---|---|---|---|---|
996
+ | 1 | `add <title>` | `--target-version <v>`, `--priority <p>`, `--workflow-mode <m>` | ❌ | flow.json + body file |
997
+ | 2 | `list` | `--status <s>`, `--target-version <v>`, `--owner <o>` | ✅ | — |
998
+ | 3 | `show <id>` | — | ✅ | — |
999
+ | 4 | `claim <id>` | `--owner <o>` (default chain) | ❌ | flow.json |
1000
+ | 5 | `release <id>` | — | ❌ | flow.json |
1001
+ | 6 | `set <id>` | `--status`, `--title`, `--target-version`, `--priority`, `--workflow-mode` | ❌ | flow.json (+ status.md/flow.json mode dispatch) |
1002
+ | 7 | `link` | `--stage <id>` (**required**), `--requirement <id>` **xor** `--unlink` | ❌ | flow.json (+ mode dispatch) |
1003
+ | 8 | `advance <id>` | `--to <status>` (**required**) | ❌ | flow.json |
1004
+ | 9 | `split <id>` | `--into <title>` (repeatable, ≥1) | ✅ | flow.json + N body files |
1005
+ | 10 | `board` | `--target-version <v>` | ✅ | — |
1006
+ | 11 | `next` | `--owner <o>`, `--limit <n>` | ✅ | — |
1007
+
1008
+ Human output line order per command:
1009
+
1010
+ * **add**: `req.add.doneTmpl` then `req.add.bodyTmpl` (absolute path).
1011
+ * **list**: one `req.list.itemTmpl` per row in storage order; `req.list.empty` when zero rows. No header.
1012
+ * **show**: title → meta → owner → times → body → parent → children block → stages block. Each is one
1013
+ `console.log`. Exact order (source lines 239–268):
1014
+ 1. `req.show.titleTmpl`
1015
+ 2. `req.show.metaTmpl` (`targetVersion`/`priority` fall back to `common.none` = `(无)`)
1016
+ 3. `req.show.ownerTmpl` if `owner`, else `req.show.ownerNone`. Note `{claimedAt}` is `r.claimed_at ?? ''`.
1017
+ 4. `req.show.timesTmpl`
1018
+ 5. `req.show.bodyTmpl` (`body_path ?? (无)`)
1019
+ 6. `req.show.parentTmpl` (raw `parent_id`) or `req.show.parentNone`
1020
+ 7. `req.show.childrenNone` if none, else `req.show.childrenLabel` + one `req.show.childItemTmpl` per child
1021
+ (storage order)
1022
+ 8. `req.show.stagesNone` if none, else `req.show.stagesLabel` + one `req.show.stageItemTmpl` per stage key
1023
+ * **claim / release / set / link / advance**: single line, or `modeSyncedTmpl` line before `set.doneTmpl`
1024
+ (set) / `link.doneTmpl`|`link.unlinkDoneTmpl` (link).
1025
+ * **split**: `req.split.doneTmpl` then one `req.split.childTmpl` per child (aligned with `bodies[i]`).
1026
+ * **board**: 7 × (`groupTmpl` + items), continuous lines.
1027
+ * **next**: `req.next.empty` | single `req.next.foundTmpl`(+owner suffix) | `req.next.multiLabel` + items.
1028
+ * **Errors**: stderr only + `process.exitCode = 1` (or `exit(2)` for concurrency).
1029
+
1030
+ ### 13.1 `link` — bidirectional carrier gate + ID normalization
1031
+
1032
+ `linkRequirement(data, stageId, requirementId | null)` (`requirement-ops.ts:354`):
1033
+
1034
+ ```
1035
+ stage = data.stages?.[stageId]; if (!stage) → stage_not_found // ① stage existence, FIRST
1036
+ if (requirementId !== null):
1037
+ req = find(requirements, id); if (!req) → not_found
1038
+ if (isTerminal(req.status)) → invalid_status
1039
+ if (req.workflow_mode === 'general') → general_mode_conflict
1040
+ if ((stage.requirement_id ?? null) === requirementId) → { ok:true, changed:false } // idempotent
1041
+ if (stage.requirement_id != null) → already_linked // cross-requirement rebind
1042
+ stage.requirement_id = requirementId; audit 'requirement_link' {stage, requirementId}
1043
+ → { ok:true, changed:true }
1044
+ else: // explicit null = unlink
1045
+ if (stage.requirement_id == null) → { ok:true, changed:false }
1046
+ stage.requirement_id = null; audit 'requirement_link' {stage, requirementId: null}
1047
+ → { ok:true, changed:true }
1048
+ ```
1049
+
1050
+ * `link` writes **only the stage record**; `requirement.updated_at` is deliberately **not** refreshed (the
1051
+ requirement object is unchanged).
1052
+ * Rebind requires an explicit unlink first (`already_linked`); there is no auto-replace.
1053
+ * The unlink path performs **no** requirement existence / terminal checks.
1054
+ * The general carrier gate sits **before** the idempotence check.
1055
+ * Command layer: `--stage` is a `requiredOption`; `--requirement`/`--unlink` are mutually exclusive and one
1056
+ is required (`req.link.exclusiveTmpl`). The stage key is normalized **exactly once**:
1057
+ `stageKey = normalizeStageId(options.stage) ?? options.stage` — and the engine does **no double-key
1058
+ fallback**. So `stage-01` → `v1.0.0-stage-01` works only if that full key exists in `flow.json`; a legacy
1059
+ key such as `v4-stage-04` is *not* reachable by passing `v4.0.0-stage-04`.
1060
+ * Post-save: if the linked requirement carries `workflow_mode`, it is dispatched onto that stage via
1061
+ `syncConfigFieldToStages('workflow_mode', mode, { onlyStages: [stageKey] })`, and
1062
+ `req.link.modeSyncedTmpl` is printed only when some result is `updated`/`inserted`.
1063
+ * `requirement_id` is `string | null | undefined` on `StageData`; legacy absence ≡ `null` (read tolerance,
1064
+ no migration).
1065
+
1066
+ ### 13.2 Stage-side mode resolution (context for `workflow_mode`)
1067
+
1068
+ `FlowManager.resolveStageWorkflowMode(stageId?)` resolves, in order:
1069
+ `status.md` field → **requirement-level** (`stages[normalized].requirement_id` → that requirement's
1070
+ `workflow_mode`, if in `WORKFLOW_MODES`; source label `'requirement'`) → `config.yaml defaults` →
1071
+ `profile.yaml` (never actually produces a value) → `builtin` (`DEFAULT_CONFIG.workflow_mode = 'fast'`).
1072
+ Read-tolerant: missing/dangling/illegal requirement mode silently falls through. This is a **stage-side
1073
+ scheduling** concern and is deliberately *not* used by the `done` gate.
1074
+
1075
+ ---
1076
+
1077
+ ## 14. Reference conformance checklist (subtle rules reimplementations get wrong)
1078
+
1079
+ 1. **`scheduled` cannot jump to `done`.** Only `in_progress → done` exists in the table. So the dedicated
1080
+ `done`-gate message only ever appears when the current status is `in_progress`; from `scheduled`/`blocked`
1081
+ the generic "allowed targets" message must appear instead (and must not mention the gate).
1082
+ 2. **Absent `workflow_mode` + zero linked stages = permanently un-completable** (empty-set guard). The fix is
1083
+ `set --workflow-mode general` first, then `done`. Do not "helpfully" treat zero stages as complete.
1084
+ 3. **`general` + linked stages must fall back to path 1**, not be exempted. Two separate gates
1085
+ (`update` carrier gate, `link` carrier gate) are what make that state unreachable.
1086
+ 4. **`done → in_progress` is not in the transition table.** It exists *only* via `syncRequirementStatus`.
1087
+ Adding it to `ALLOWED_TRANSITIONS` would let operators reopen completed requirements.
1088
+ 5. **Aggregate auto-done is restricted to `scheduled`/`in_progress`.** `draft`, `blocked`, and terminal
1089
+ statuses are never auto-completed; a `blocked` requirement whose stages all finish stays `blocked`.
1090
+ 6. **Aggregate reverse direction always lands on `in_progress`**, never `scheduled`, and only from `done`.
1091
+ 7. **Aggregation triggers only on** `advanceStagePhase(to === 'done', prevStatus !== 'done')` and
1092
+ successful `resetStagePhase`. Not on `plan_pending` transitions, not on `dryRun`, not on no-ops, never on
1093
+ read commands.
1094
+ 8. **`split` children inherit nothing but the title.** Not `target_version`, not `priority`, not
1095
+ `workflow_mode`. They are born `scheduled` (not `draft`).
1096
+ 9. **`split` allocates ids sequentially against a staged container** and returns *live* objects so the caller
1097
+ can set `body_path`; a shallow copy would silently break body-path assignment.
1098
+ 10. **`split` has zero parent side effects** — parent status/`updated_at` untouched.
1099
+ 11. **Exact one audit entry per split**, regardless of child count.
1100
+ 12. **Terminal check precedes occupancy check** in `claim` (a done requirement claimed by someone else →
1101
+ `invalid_status`, not `already_claimed`).
1102
+ 13. **Same-owner claim is a no-op that does not `save()`** → `meta.revision` unchanged, no audit,
1103
+ `updated_at` not refreshed.
1104
+ 14. **`release` has no status gate and no ownership check**, and *deletes* `owner`/`claimed_at` keys.
1105
+ 15. **No TTL / heartbeat anywhere**; `claimed_at` is display-only.
1106
+ 16. **`PATCH_WHITELIST` is exactly 5 fields**; `owner`/`claimed_at`/`parent_id`/`body_path`/timestamps/`id`
1107
+ are not patchable. An invalid `status` yields `invalid_status`, while every other bad value yields
1108
+ `invalid_field`.
1109
+ 17. **Empty patch is a successful no-op at the engine level**; the CLI's `set` rejects it earlier with its own
1110
+ message. `add` **throws** where `split`/`update` return codes.
1111
+ 18. **`updated_at` is written at most once per operation** (status dimension wins; field dimension skips the
1112
+ refresh when the status already changed).
1113
+ 19. **`next` sorts `target_version` lexicographically** with `undefined` last; the `--limit` verdict is based
1114
+ on the parsed number, so `--limit 1` ≡ no flag (including JSON key set); the empty result wins over the
1115
+ multi-label.
1116
+ 20. **`next` eligibility is `scheduled` only, and `--owner` *widens*** (unclaimed ∪ mine), it does not
1117
+ restrict to mine.
1118
+ 21. **`board` group order is `REQ_STATUSES` declaration order** — `draft, scheduled, in_progress, done,
1119
+ blocked, cancelled, rejected` — which is *not* lifecycle order (`done` sits before `blocked`). Empty
1120
+ groups are always printed; out-of-domain stored statuses are dropped from the view.
1121
+ 22. **`show --json` key order and key set are frozen**: `schemaVersion, requirement, stages, parent,
1122
+ children`; `parent` is the raw `parent_id ?? null` (dangling echoed, no error).
1123
+ 23. **`next --json` keys are conditional on `limit === 1`** (`candidates` absent), while `show`/`board`/
1124
+ `list` always emit their full key set.
1125
+ 24. **JSON failures go to stdout with exit 1 and empty stderr**; concurrency failures are the exception
1126
+ (stderr + `exit 2`) and are never JSON.
1127
+ 25. **Only `split` among write commands supports `--json`.**
1128
+ 26. **The requirement body is never updated after creation** (create-time status snapshot). `body_path` is
1129
+ (re)assigned unconditionally by `writeRequirementBodySkeleton`, and an existing file is never overwritten.
1130
+ 27. **`requirements` is never back-filled at load**; `getRequirements` derives `[]` on the fly. A non-array
1131
+ `requirements` container is rebuilt as an array on the next `add`/`split` (and only there).
1132
+ 28. **`link` does not refresh `requirement.updated_at`** and does not use double-key stage fallback.
1133
+
1134
+ ---
1135
+
1136
+ ## Appendix A — consolidated pseudocode
1137
+
1138
+ ```
1139
+ # ── read / allocate ───────────────────────────────────────────────────────────
1140
+ getRequirements(data) = isArray(data.requirements) ? data.requirements.map(r => {...r}) : []
1141
+ getRequirement(data, id) = { ...(find(r => r?.id === id)) } ?? null
1142
+ listRequirements(data, f) = getRequirements(data).filter(AND over {status, target_version, owner})
1143
+ nextRequirementId(data) = 'REQ-' + pad3(max(parse(ids)) + 1)
1144
+ isTerminal(status) = status ∈ {done, cancelled, rejected}
1145
+
1146
+ # ── state machine ─────────────────────────────────────────────────────────────
1147
+ ALLOWED_NEXT = { draft:[scheduled,blocked,cancelled,rejected],
1148
+ scheduled:[in_progress,blocked,cancelled,rejected],
1149
+ in_progress:[done,blocked,cancelled,rejected],
1150
+ blocked:[scheduled,in_progress,cancelled,rejected],
1151
+ done:[], cancelled:[], rejected:[] }
1152
+ getAllowedNextStatuses(s) = copy of (ALLOWED_NEXT[s] ?? [])
1153
+
1154
+ applyStatusChange(data, req, to):
1155
+ if to ∉ REQ_STATUSES → false
1156
+ if to === req.status → false
1157
+ if to ∉ (ALLOWED_NEXT[req.status] ?? []) → false
1158
+ if to === 'done' ∧ ¬canComplete(data, req) → false
1159
+ req.status = to; req.updated_at = now(); audit('requirement_status', {id, from, to}); return true
1160
+
1161
+ # ── completion gate ───────────────────────────────────────────────────────────
1162
+ allLinkedStagesDone(data, rid):
1163
+ L = [s for s in values(data.stages) where (s.requirement_id ?? null) === rid]
1164
+ return len(L) > 0 ∧ ∀s∈L: s.phase === 'done'
1165
+
1166
+ canCompleteRequirement(data, req):
1167
+ if req.workflow_mode === 'general'
1168
+ ∧ ¬∃s: (s.requirement_id ?? null) === req.id → true
1169
+ return allLinkedStagesDone(data, req.id)
1170
+
1171
+ # ── aggregate projection ──────────────────────────────────────────────────────
1172
+ computeRequirementAggregate(data, rid):
1173
+ L = [(k,s) for (k,s) in entries(data.stages) where (s.requirement_id ?? null) === rid]
1174
+ if len(L) === 0 → {stageIds: [], stageCount: 0, doneCount: 0, aggregate: null}
1175
+ doneCount = |{s ∈ L : s.phase === 'done'}|
1176
+ anyStarted = ∃s ∈ L : s.phase !== 'plan_pending'
1177
+ aggregate = allLinkedStagesDone(data,rid) ? 'done' : anyStarted ? 'in_progress' : 'scheduled'
1178
+
1179
+ syncRequirementStatus(data, stageId):
1180
+ rid = data.stages?.[stageId]?.requirement_id ?? null; if ¬rid → []
1181
+ agg = computeRequirementAggregate(data, rid).aggregate; if agg === null → []
1182
+ req = raw find(data.requirements, rid); if ¬req → []
1183
+ if agg === 'done' ∧ req.status ∈ ['scheduled','in_progress']:
1184
+ req.status='done'; req.updated_at=now(); audit('requirement_auto_done', …); return [rid]
1185
+ if agg !== 'done' ∧ req.status === 'done':
1186
+ req.status='in_progress'; req.updated_at=now(); audit('requirement_auto_reopen', …); return [rid]
1187
+ return []
1188
+
1189
+ # ── claim protocol ────────────────────────────────────────────────────────────
1190
+ claim(data, id, owner):
1191
+ name = trim(owner); if name === '' → invalid_field
1192
+ req = raw find(data.requirements, id); if ¬req → not_found
1193
+ if isTerminal(req.status) → invalid_status
1194
+ if req.owner ∧ req.owner !== name → already_claimed
1195
+ if req.owner === name → ok(changed=false)
1196
+ req.owner = name; req.claimed_at = now(); req.updated_at = req.claimed_at
1197
+ audit('requirement_claim', {id, owner:name}); ok(changed=true)
1198
+
1199
+ release(data, id):
1200
+ req = raw find(data.requirements, id); if ¬req → not_found
1201
+ if ¬req.owner → ok(changed=false)
1202
+ prev = req.owner; delete req.owner; delete req.claimed_at; req.updated_at = now()
1203
+ audit('requirement_release', {id, owner:prev}); ok(changed=true)
1204
+
1205
+ # ── split ─────────────────────────────────────────────────────────────────────
1206
+ split(data, parentId, children):
1207
+ if ¬isArray(children) ∨ len === 0 → invalid_field
1208
+ parent = find(data.requirements, parentId); if ¬parent → not_found
1209
+ if isTerminal(parent.status) → invalid_status
1210
+ titles = [trim(c.title) for c in children]; if any === '' → invalid_field
1211
+ list = isArray(data.requirements) ? [...data.requirements] : []
1212
+ staged = {...data, requirements: list}; now = now(); created = []
1213
+ for t in titles:
1214
+ child = {id: nextRequirementId(staged), title: t, status: 'scheduled',
1215
+ parent_id: parentId, created_at: now, updated_at: now}
1216
+ list.push(child); created.push(child)
1217
+ data.requirements = list
1218
+ audit('requirement_split', {parentId, children: created.map(c => c.id)})
1219
+ ok(children = created) # live objects, single audit, parent untouched
1220
+ ```
1221
+
1222
+ ## Appendix B — conformance fixtures to assert
1223
+
1224
+ | Fixture | Expected |
1225
+ |---|---|
1226
+ | empty `requirements` → `req add 'x'` | `REQ-001`, status `draft`, body written, `req.add.doneTmpl` + absolute path |
1227
+ | only `REQ-005` exists → `add` | `REQ-006` |
1228
+ | only `REQ-999` exists → `add` | `REQ-1000` |
1229
+ | `requirements: {}` (corrupt) → `add` | no crash, exit 0, container rebuilt as array |
1230
+ | `in_progress` + stage `plan_pending` linked → `advance --to done` | exit 1, `done 须全部关联阶段`, zero write |
1231
+ | `scheduled` (no stages) → `advance --to done` | exit 1, `合法目标:in_progress, blocked, cancelled, rejected` |
1232
+ | `workflow_mode: general`, 0 stages, `in_progress` → `advance --to done` | exit 0, `已推进 … in_progress → done`, audit `requirement_status` |
1233
+ | `in_progress` + 2 stages both `done` → `advance --to done` | exit 0, audit `requirement_status` |
1234
+ | stage → `done` on a `scheduled` requirement (both linked stages done) | requirement auto-flips to `done` with `requirement_auto_done` |
1235
+ | stage reset out of `done` on a `done` requirement | requirement back to `in_progress` with `requirement_auto_reopen` |
1236
+ | `draft` requirement whose stage somehow reached `done` | no auto-done (outside precondition set) |
1237
+ | `req split REQ-001 --into A --into B` on `REQ-001`+`REQ-002` existing | children `REQ-003`, `REQ-004`, both `scheduled`, `parent_id: REQ-001`, bodies written, one `requirement_split` |
1238
+ | `req split REQ-001 --into ' '` | exit 1, `子需求取值非法(标题不能为空)`, zero write |
1239
+ | `req board` with one `scheduled` | 7 group lines with `done` before `blocked`, item under `scheduled` |
1240
+ | `req next --limit 2` with 3 scheduled candidates | `可接取需求(2):` + 2 items; `--json` adds `candidates` (len 2) |
1241
+ | `req next --limit 2` with 0 candidates | `(无可接取需求)`; `--json` → `requirement: null, candidates: []` |
1242
+ | `req next --limit 0` | exit 1, `非法 --limit 值(应为 ≥1 的整数)` |
1243
+ | `req claim REQ-001 --json` | commander unknown-option error (write commands have no `--json`) |