openfeel 1.2.4 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +40 -74
- package/README.md +39 -62
- package/README.zh-CN.md +40 -74
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +90 -5
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +7 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/core/config.js +2 -2
- package/dist/core/dsh-profile.d.ts +93 -0
- package/dist/core/dsh-profile.d.ts.map +1 -0
- package/dist/core/dsh-profile.js +400 -0
- package/dist/core/dsh-profile.js.map +1 -0
- package/dist/core/i18n-data/en.d.ts.map +1 -1
- package/dist/core/i18n-data/en.js +21 -2
- package/dist/core/i18n-data/en.js.map +1 -1
- package/dist/core/i18n-data/zh-CN.d.ts.map +1 -1
- package/dist/core/i18n-data/zh-CN.js +21 -2
- package/dist/core/i18n-data/zh-CN.js.map +1 -1
- package/dist/core/setup.d.ts +12 -1
- package/dist/core/setup.d.ts.map +1 -1
- package/dist/core/setup.js +25 -10
- package/dist/core/setup.js.map +1 -1
- package/dist/core/template-loader.d.ts.map +1 -1
- package/dist/core/template-loader.js +18 -9
- package/dist/core/template-loader.js.map +1 -1
- package/dist/core/update.d.ts +2 -0
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +25 -7
- package/dist/core/update.js.map +1 -1
- package/dsh/ACTION-GUIDE.md +366 -0
- package/dsh/ADAPTER.md +65 -0
- package/dsh/README.md +264 -0
- package/dsh/cordis.patch.yml +26 -0
- package/dsh/docs/compat/commands.md +873 -0
- package/dsh/docs/compat/kb-skills.md +883 -0
- package/dsh/docs/compat/persistence.md +1013 -0
- package/dsh/docs/compat/pipeline.md +1565 -0
- package/dsh/docs/compat/req.md +1243 -0
- package/dsh/docs/compat/stage.md +1437 -0
- package/dsh/lib/adapters/cli.js +624 -0
- package/dsh/lib/adapters/dashboard.js +393 -0
- package/dsh/lib/adapters/workspace.js +150 -0
- package/dsh/lib/index.js +189 -0
- package/dsh/lib/surface/command.js +186 -0
- package/dsh/lib/surface/prompt.js +131 -0
- package/dsh/lib/tools/index.js +1245 -0
- package/dsh/lib/tools/shared.js +320 -0
- package/dsh/package.json +45 -0
- package/package.json +9 -2
|
@@ -0,0 +1,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`) |
|