@garygentry/feature-forge 0.1.5 → 0.2.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 (182) hide show
  1. package/README.md +19 -1
  2. package/adapters/GENERATION-REPORT.md +12 -12
  3. package/adapters/claude/.feature-forge-bundle.json +6 -0
  4. package/adapters/claude/references/forge-config-schema.json +2 -2
  5. package/adapters/claude/references/portable-root.md +8 -5
  6. package/adapters/claude/references/process-overview.md +1 -1
  7. package/adapters/claude/references/shared-conventions.md +24 -5
  8. package/adapters/claude/references/stack-resolution.md +4 -1
  9. package/adapters/claude/references/stacks/go.md +1 -1
  10. package/adapters/claude/references/stacks/python.md +1 -1
  11. package/adapters/claude/references/stacks/rust.md +1 -1
  12. package/adapters/claude/references/stacks/typescript.md +1 -1
  13. package/adapters/claude/scripts/epic-manifest.py +1379 -0
  14. package/adapters/claude/scripts/forge-bootstrap.py +991 -0
  15. package/adapters/claude/scripts/forge-init.sh +44 -0
  16. package/adapters/claude/scripts/forge-root.sh +30 -8
  17. package/adapters/claude/scripts/validate-traceability.py +150 -0
  18. package/adapters/claude/skills/forge/SKILL.md +5 -5
  19. package/adapters/claude/skills/forge-0-epic/SKILL.md +6 -10
  20. package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +2 -2
  21. package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  22. package/adapters/claude/skills/forge-1-prd/SKILL.md +2 -2
  23. package/adapters/claude/skills/forge-2-tech/SKILL.md +8 -7
  24. package/adapters/claude/skills/forge-2-tech/references/stack-discovery-checklist.md +4 -4
  25. package/adapters/claude/skills/forge-3-specs/SKILL.md +1 -1
  26. package/adapters/claude/skills/forge-4-backlog/SKILL.md +2 -2
  27. package/adapters/claude/skills/forge-5-loop/SKILL.md +2 -2
  28. package/adapters/claude/skills/forge-6-docs/SKILL.md +2 -2
  29. package/adapters/claude/skills/forge-bootstrap/SKILL.md +4 -4
  30. package/adapters/claude/skills/forge-fix/SKILL.md +1 -1
  31. package/adapters/claude/skills/forge-init/SKILL.md +1 -1
  32. package/adapters/claude/skills/forge-verify/SKILL.md +7 -2
  33. package/adapters/claude/skills/forge-verify/references/verification-checklists.md +1 -1
  34. package/adapters/codex/.feature-forge-bundle.json +6 -0
  35. package/adapters/codex/agents/{forge-researcher.md → forge-researcher.toml} +4 -4
  36. package/adapters/codex/agents/{forge-spec-writer.md → forge-spec-writer.toml} +4 -4
  37. package/adapters/codex/agents/{forge-verifier.md → forge-verifier.toml} +4 -4
  38. package/adapters/codex/references/forge-config-schema.json +2 -2
  39. package/adapters/codex/references/portable-root.md +8 -5
  40. package/adapters/codex/references/process-overview.md +1 -1
  41. package/adapters/codex/references/shared-conventions.md +24 -5
  42. package/adapters/codex/references/stack-resolution.md +4 -1
  43. package/adapters/codex/references/stacks/go.md +1 -1
  44. package/adapters/codex/references/stacks/python.md +1 -1
  45. package/adapters/codex/references/stacks/rust.md +1 -1
  46. package/adapters/codex/references/stacks/typescript.md +1 -1
  47. package/adapters/codex/scripts/epic-manifest.py +1379 -0
  48. package/adapters/codex/scripts/forge-bootstrap.py +991 -0
  49. package/adapters/codex/scripts/forge-init.sh +44 -0
  50. package/adapters/codex/scripts/forge-root.sh +30 -8
  51. package/adapters/codex/scripts/validate-traceability.py +150 -0
  52. package/adapters/codex/skills/forge/{forge.md → SKILL.md} +16 -6
  53. package/adapters/codex/skills/forge-0-epic/{forge-0-epic.md → SKILL.md} +26 -20
  54. package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +2 -2
  55. package/adapters/codex/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  56. package/adapters/codex/skills/forge-1-prd/{forge-1-prd.md → SKILL.md} +18 -8
  57. package/adapters/codex/skills/forge-2-tech/{forge-2-tech.md → SKILL.md} +26 -15
  58. package/adapters/codex/skills/forge-2-tech/references/stack-discovery-checklist.md +4 -4
  59. package/adapters/codex/skills/forge-3-specs/{forge-3-specs.md → SKILL.md} +16 -6
  60. package/adapters/codex/skills/forge-4-backlog/{forge-4-backlog.md → SKILL.md} +15 -5
  61. package/adapters/codex/skills/forge-5-loop/{forge-5-loop.md → SKILL.md} +27 -17
  62. package/adapters/codex/skills/forge-6-docs/{forge-6-docs.md → SKILL.md} +17 -7
  63. package/adapters/codex/skills/forge-bootstrap/{forge-bootstrap.md → SKILL.md} +17 -7
  64. package/adapters/codex/skills/forge-fix/{forge-fix.md → SKILL.md} +12 -2
  65. package/adapters/codex/skills/forge-init/{forge-init.md → SKILL.md} +11 -1
  66. package/adapters/codex/skills/forge-verify/{forge-verify.md → SKILL.md} +24 -9
  67. package/adapters/codex/skills/forge-verify/references/verification-checklists.md +1 -1
  68. package/adapters/copilot/.feature-forge-bundle.json +6 -0
  69. package/adapters/copilot/references/forge-config-schema.json +2 -2
  70. package/adapters/copilot/references/portable-root.md +8 -5
  71. package/adapters/copilot/references/process-overview.md +1 -1
  72. package/adapters/copilot/references/shared-conventions.md +24 -5
  73. package/adapters/copilot/references/stack-resolution.md +4 -1
  74. package/adapters/copilot/references/stacks/go.md +1 -1
  75. package/adapters/copilot/references/stacks/python.md +1 -1
  76. package/adapters/copilot/references/stacks/rust.md +1 -1
  77. package/adapters/copilot/references/stacks/typescript.md +1 -1
  78. package/adapters/copilot/scripts/epic-manifest.py +1379 -0
  79. package/adapters/copilot/scripts/forge-bootstrap.py +991 -0
  80. package/adapters/copilot/scripts/forge-init.sh +44 -0
  81. package/adapters/copilot/scripts/forge-root.sh +30 -8
  82. package/adapters/copilot/scripts/validate-traceability.py +150 -0
  83. package/adapters/copilot/skills/forge/forge.md +16 -6
  84. package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +26 -20
  85. package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +2 -2
  86. package/adapters/copilot/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  87. package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +18 -8
  88. package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +26 -15
  89. package/adapters/copilot/skills/forge-2-tech/references/stack-discovery-checklist.md +4 -4
  90. package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +16 -6
  91. package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +15 -5
  92. package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +27 -17
  93. package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +17 -7
  94. package/adapters/copilot/skills/forge-bootstrap/forge-bootstrap.md +17 -7
  95. package/adapters/copilot/skills/forge-fix/forge-fix.md +12 -2
  96. package/adapters/copilot/skills/forge-init/forge-init.md +11 -1
  97. package/adapters/copilot/skills/forge-verify/forge-verify.md +24 -9
  98. package/adapters/copilot/skills/forge-verify/references/verification-checklists.md +1 -1
  99. package/adapters/cursor/.feature-forge-bundle.json +6 -0
  100. package/adapters/cursor/references/forge-config-schema.json +2 -2
  101. package/adapters/cursor/references/portable-root.md +8 -5
  102. package/adapters/cursor/references/process-overview.md +1 -1
  103. package/adapters/cursor/references/shared-conventions.md +24 -5
  104. package/adapters/cursor/references/stack-resolution.md +4 -1
  105. package/adapters/cursor/references/stacks/go.md +1 -1
  106. package/adapters/cursor/references/stacks/python.md +1 -1
  107. package/adapters/cursor/references/stacks/rust.md +1 -1
  108. package/adapters/cursor/references/stacks/typescript.md +1 -1
  109. package/adapters/cursor/scripts/epic-manifest.py +1379 -0
  110. package/adapters/cursor/scripts/forge-bootstrap.py +991 -0
  111. package/adapters/cursor/scripts/forge-init.sh +44 -0
  112. package/adapters/cursor/scripts/forge-root.sh +30 -8
  113. package/adapters/cursor/scripts/validate-traceability.py +150 -0
  114. package/adapters/cursor/skills/forge/forge.mdc +16 -6
  115. package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +26 -20
  116. package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +2 -2
  117. package/adapters/cursor/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  118. package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +18 -8
  119. package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +26 -15
  120. package/adapters/cursor/skills/forge-2-tech/references/stack-discovery-checklist.md +4 -4
  121. package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +16 -6
  122. package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +15 -5
  123. package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +27 -17
  124. package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +17 -7
  125. package/adapters/cursor/skills/forge-bootstrap/forge-bootstrap.mdc +17 -7
  126. package/adapters/cursor/skills/forge-fix/forge-fix.mdc +12 -2
  127. package/adapters/cursor/skills/forge-init/forge-init.mdc +11 -1
  128. package/adapters/cursor/skills/forge-verify/forge-verify.mdc +24 -9
  129. package/adapters/cursor/skills/forge-verify/references/verification-checklists.md +1 -1
  130. package/adapters/gemini/.feature-forge-bundle.json +6 -0
  131. package/adapters/gemini/gemini-extension.json +1 -1
  132. package/adapters/gemini/references/forge-config-schema.json +2 -2
  133. package/adapters/gemini/references/portable-root.md +8 -5
  134. package/adapters/gemini/references/process-overview.md +1 -1
  135. package/adapters/gemini/references/shared-conventions.md +24 -5
  136. package/adapters/gemini/references/stack-resolution.md +4 -1
  137. package/adapters/gemini/references/stacks/go.md +1 -1
  138. package/adapters/gemini/references/stacks/python.md +1 -1
  139. package/adapters/gemini/references/stacks/rust.md +1 -1
  140. package/adapters/gemini/references/stacks/typescript.md +1 -1
  141. package/adapters/gemini/scripts/epic-manifest.py +1379 -0
  142. package/adapters/gemini/scripts/forge-bootstrap.py +991 -0
  143. package/adapters/gemini/scripts/forge-init.sh +44 -0
  144. package/adapters/gemini/scripts/forge-root.sh +30 -8
  145. package/adapters/gemini/scripts/validate-traceability.py +150 -0
  146. package/adapters/gemini/skills/forge/forge.md +16 -6
  147. package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +26 -20
  148. package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +2 -2
  149. package/adapters/gemini/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  150. package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +18 -8
  151. package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +26 -15
  152. package/adapters/gemini/skills/forge-2-tech/references/stack-discovery-checklist.md +4 -4
  153. package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +16 -6
  154. package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +15 -5
  155. package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +27 -17
  156. package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +17 -7
  157. package/adapters/gemini/skills/forge-bootstrap/forge-bootstrap.md +17 -7
  158. package/adapters/gemini/skills/forge-fix/forge-fix.md +12 -2
  159. package/adapters/gemini/skills/forge-init/forge-init.md +11 -1
  160. package/adapters/gemini/skills/forge-verify/forge-verify.md +24 -9
  161. package/adapters/gemini/skills/forge-verify/references/verification-checklists.md +1 -1
  162. package/dist/agent-targets.d.ts +20 -4
  163. package/dist/agent-targets.js +29 -4
  164. package/dist/apply.js +245 -18
  165. package/dist/cli.js +12 -6
  166. package/dist/hash.d.ts +5 -0
  167. package/dist/hash.js +7 -0
  168. package/dist/manifest.d.ts +4 -2
  169. package/dist/manifest.js +58 -2
  170. package/dist/placements.d.ts +69 -0
  171. package/dist/placements.js +116 -0
  172. package/dist/plan.d.ts +7 -0
  173. package/dist/plan.js +87 -1
  174. package/dist/rauf.d.ts +4 -4
  175. package/dist/rauf.js +3 -3
  176. package/dist/report.js +21 -0
  177. package/dist/source.d.ts +4 -3
  178. package/dist/source.js +4 -3
  179. package/dist/types.d.ts +163 -19
  180. package/dist/types.js +42 -11
  181. package/package.json +1 -1
  182. package/adapters/codex/agents/openai.yaml +0 -10
@@ -0,0 +1,1379 @@
1
+ #!/usr/bin/env python3
2
+ """Read, validate, and atomically mutate an epic manifest.
3
+
4
+ The deterministic core for Epic Orchestration: name->directory resolution,
5
+ acyclicity and schema validation, global name-uniqueness, path containment,
6
+ live per-feature status derivation, and atomic manifest mutation.
7
+
8
+ Usage:
9
+ python3 epic-manifest.py resolve <name> [--specs-dir DIR]
10
+ python3 epic-manifest.py validate <epic> [--specs-dir DIR] [--json]
11
+ python3 epic-manifest.py check-name <name> [--specs-dir DIR]
12
+ python3 epic-manifest.py render-status <epic> [--specs-dir DIR] [--json]
13
+ python3 epic-manifest.py add-feature <epic> <name> --charter TEXT \
14
+ [--depends-on A,B] [--specs-dir DIR] [--json]
15
+ python3 epic-manifest.py remove-feature <epic> <name> [--specs-dir DIR] [--json]
16
+ python3 epic-manifest.py reorder <epic> --order A,B,C [--specs-dir DIR] [--json]
17
+ python3 epic-manifest.py set-dep <epic> <name> --depends-on A,B [--specs-dir DIR] [--json]
18
+ python3 epic-manifest.py set-status <epic> --status STATE [--specs-dir DIR] [--json]
19
+
20
+ Exit codes:
21
+ 0 = ok / valid / unique / resolved
22
+ 1 = findings / validation failure / duplicate / ambiguous / not-found
23
+ 2 = usage error or I/O error (missing file, unreadable, unsafe path)
24
+ """
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ import tempfile
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Final, Literal, TypedDict
35
+
36
+
37
+ # --------------------------------------------------------------------------- #
38
+ # Constants (00-core-definitions.md §6)
39
+ # --------------------------------------------------------------------------- #
40
+
41
+ #: A safe feature/epic name: one kebab-case token (00 §6).
42
+ SAFE_NAME_RE: Final = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
43
+ #: A directory is "feature-shaped" iff it directly contains this file.
44
+ PIPELINE_STATE_FILENAME: Final = ".pipeline-state.json"
45
+ #: Canonical filenames sited at the epic subtree root.
46
+ MANIFEST_FILENAME: Final = "epic-manifest.json"
47
+ NARRATIVE_FILENAME: Final = "EPIC.md"
48
+
49
+
50
+ # --------------------------------------------------------------------------- #
51
+ # Type Definitions (00-core-definitions.md §4, §5; 02 §8.4)
52
+ # --------------------------------------------------------------------------- #
53
+
54
+ FindingCode = Literal[
55
+ "corrupt-json", # manifest is not parseable JSON (REQ-ROBUST-02)
56
+ "schema", # manifest violates epic-manifest-schema.json
57
+ "duplicate-name", # a feature/epic name occurs more than once in the tree (REQ-DIR-04)
58
+ "dangling-ref", # dependsOn / consumes.from references an unknown feature (REQ-ROBUST-02)
59
+ "cycle", # the dependsOn graph contains a cycle (REQ-EPIC-05)
60
+ "unsafe-name", # a name contains a path separator, "..", or is absolute (REQ-SEC-02)
61
+ "not-found", # a name resolves to zero feature-shaped directories
62
+ "ambiguous", # a name resolves to more than one feature-shaped directory (REQ-DIR-04)
63
+ "cached-status", # a Feature object illegally carries a status field (REQ-STATE-02)
64
+ ]
65
+
66
+
67
+ class Finding(TypedDict):
68
+ """A single, actionable validation or resolution failure.
69
+
70
+ Attributes:
71
+ code: Machine-readable category (see FindingCode).
72
+ message: Human-readable, actionable description. Includes offending
73
+ identifiers and, where relevant, the conflicting paths.
74
+ feature: The feature name the finding pertains to, or None for
75
+ manifest- or epic-level findings.
76
+ """
77
+
78
+ code: FindingCode
79
+ message: str
80
+ feature: str | None
81
+
82
+
83
+ DerivedStatus = Literal[
84
+ "not-started", # no .pipeline-state.json, or all stages pending
85
+ "in-progress", # at least one stage started, loop not complete-for-orchestration
86
+ "complete", # complete-for-orchestration per 00 §7
87
+ ]
88
+
89
+
90
+ class FeatureStatus(TypedDict):
91
+ """Live per-feature status derived from its own pipeline state (00 §5).
92
+
93
+ Attributes:
94
+ name: Feature name.
95
+ stage: The feature's current pipeline stage (its currentStage), or
96
+ "forge-0-epic" if the member directory exists but no stage has run.
97
+ status: Coarse derived status (see DerivedStatus). Reuses existing
98
+ navigator status semantics for display.
99
+ blocked: True if any entry in unmetDeps is non-empty.
100
+ unmetDeps: Names of this feature's direct dependencies that are not yet
101
+ complete-for-orchestration (00 §7). Empty when actionable or complete.
102
+ """
103
+
104
+ name: str
105
+ stage: str
106
+ status: DerivedStatus
107
+ blocked: bool
108
+ unmetDeps: list[str]
109
+
110
+
111
+ class Rollup(TypedDict):
112
+ """Aggregate completion counts for the epic dashboard (00 §8)."""
113
+
114
+ complete: int #: Number of member features complete-for-orchestration (00 §7).
115
+ total: int #: Total member features in the manifest (0 for an empty epic).
116
+
117
+
118
+ class RenderStatus(TypedDict):
119
+ """The full live dashboard payload returned by render_status (00 §5, §8).
120
+
121
+ Attributes:
122
+ epic: The epic name (manifest `epic`).
123
+ status: The epic lifecycle status (00 §2.1).
124
+ features: Per-member status rows, one per manifest feature (may be empty).
125
+ actionable: Names of features whose dependsOn are all complete and that
126
+ are not themselves complete (00 §8).
127
+ parallelEligible: Subset of `actionable` with no mutual (transitive)
128
+ dependency — surfaced for future parallel execution (00 §8).
129
+ rollup: Aggregate {complete, total} counts.
130
+ nextCommand: Recommended next command for the first actionable feature, or
131
+ None when nothing is actionable (all complete, empty epic, or paused).
132
+ """
133
+
134
+ epic: str
135
+ status: Literal["active", "paused", "abandoned", "complete"]
136
+ features: list[FeatureStatus]
137
+ actionable: list[str]
138
+ parallelEligible: list[str]
139
+ rollup: Rollup
140
+ nextCommand: str | None
141
+
142
+
143
+ # --------------------------------------------------------------------------- #
144
+ # Internal Exceptions (02 §2)
145
+ # --------------------------------------------------------------------------- #
146
+
147
+
148
+ class UsageError(Exception):
149
+ """A usage or I/O failure that must exit 2.
150
+
151
+ Raised for missing files, unreadable paths, malformed CLI arguments, and
152
+ unsafe-name / path-escape conditions detected before filesystem access
153
+ (REQ-SEC-02). Maps to exit code 2.
154
+
155
+ Attributes:
156
+ message: Human-readable description printed to stderr.
157
+ """
158
+
159
+ def __init__(self, message: str) -> None:
160
+ self.message = message
161
+ super().__init__(message)
162
+
163
+
164
+ class FindingsError(Exception):
165
+ """A non-fatal validation outcome that must exit 1.
166
+
167
+ Raised when an operation produces one or more Findings (00 §4) that block a
168
+ gating operation: a cycle, a dangling ref, an ambiguous/not-found name, etc.
169
+ Maps to exit code 1. Carries the structured findings so the dispatch layer
170
+ can emit them as JSON or human lines.
171
+
172
+ Attributes:
173
+ findings: The list of Findings to surface.
174
+ """
175
+
176
+ def __init__(self, findings: list["Finding"]) -> None:
177
+ self.findings = findings
178
+ super().__init__(f"{len(findings)} finding(s)")
179
+
180
+
181
+ # --------------------------------------------------------------------------- #
182
+ # Safety & I/O Layer (02 §3)
183
+ # --------------------------------------------------------------------------- #
184
+
185
+
186
+ def assert_safe_name(name: str) -> None:
187
+ """Validate a bare feature/epic name before any filesystem access.
188
+
189
+ A name is safe iff it is a single kebab-case token with no path separator,
190
+ no ``..`` segment, and is not absolute (REQ-SEC-02). This runs first in
191
+ every subcommand so that an unsafe name never reaches a glob or open().
192
+
193
+ Args:
194
+ name: The bare name supplied on the command line.
195
+
196
+ Raises:
197
+ UsageError: If the name is empty, absolute, contains '/' or '\\',
198
+ equals '..', or fails SAFE_NAME_RE. The message embeds the
199
+ offending name (e.g. ``unsafe name '../escape'``) so the caller can
200
+ surface it verbatim. Corresponds to the 'unsafe-name' Finding code
201
+ (00 §4) but is raised as a usage error because it is detected before
202
+ any manifest is read.
203
+ """
204
+ if (
205
+ not name
206
+ or name == ".."
207
+ or "/" in name
208
+ or "\\" in name
209
+ or os.path.isabs(name)
210
+ or not SAFE_NAME_RE.match(name)
211
+ ):
212
+ raise UsageError(f"unsafe name {name!r}")
213
+
214
+
215
+ def contained_path(base: Path, *parts: str) -> Path:
216
+ """Join parts onto base and assert the result stays within base.
217
+
218
+ Canonicalizes (symlink-resolves) both base and the joined path and verifies
219
+ the result is contained within the real base (REQ-SEC-02). Used before
220
+ reading or writing any manifest, narrative, or pipeline-state file so no
221
+ epic operation can read or write outside the specs subtree.
222
+
223
+ Args:
224
+ base: The containing directory (typically {specsDir} or an epic dir),
225
+ already known to exist.
226
+ *parts: Path segments to append (each already passed through
227
+ assert_safe_name when it originates from user input).
228
+
229
+ Returns:
230
+ The resolved, contained absolute path.
231
+
232
+ Raises:
233
+ UsageError: If the resolved path escapes ``base`` (message:
234
+ ``resolved path escapes specs dir: …``). Containment violations
235
+ surface only as exit-2 usage errors per the error model in
236
+ tech-spec §6 (there is no dedicated Finding code for them).
237
+ """
238
+ base_real = base.resolve()
239
+ target = (base_real / Path(*parts)).resolve()
240
+ try:
241
+ target.relative_to(base_real)
242
+ except ValueError:
243
+ raise UsageError(f"resolved path escapes specs dir: {base_real / Path(*parts)}")
244
+ return target
245
+
246
+
247
+ def load_manifest(epic_dir: Path) -> dict:
248
+ """Load and JSON-parse an epic's manifest.
249
+
250
+ Args:
251
+ epic_dir: The epic subtree directory (must already be contained within
252
+ {specsDir} via contained_path).
253
+
254
+ Returns:
255
+ The parsed manifest as a plain dict. Structural validation (schema,
256
+ cycles, dangling refs) is performed separately by ``validate`` — this
257
+ function only guarantees the file exists and parses.
258
+
259
+ Raises:
260
+ UsageError: If the manifest file is missing or unreadable (exit 2).
261
+ FindingsError: If the file exists but is not parseable JSON — emits a
262
+ single 'corrupt-json' Finding (00 §4) with the JSON error position,
263
+ so a hand-corrupted manifest fails with an actionable message rather
264
+ than a traceback (REQ-ROBUST-02). Exit 1.
265
+ """
266
+ path = epic_dir / MANIFEST_FILENAME
267
+ if not path.is_file():
268
+ raise UsageError(f"manifest not found: {path}")
269
+ try:
270
+ text = path.read_text(encoding="utf-8")
271
+ except OSError as exc:
272
+ raise UsageError(f"cannot read manifest {path}: {exc}")
273
+ try:
274
+ return json.loads(text)
275
+ except json.JSONDecodeError as exc:
276
+ raise FindingsError([
277
+ {
278
+ "code": "corrupt-json",
279
+ "message": f"manifest {path} is not valid JSON: {exc}",
280
+ "feature": None,
281
+ }
282
+ ])
283
+
284
+
285
+ def atomic_write(path: Path, data: dict) -> None:
286
+ """Write a manifest dict to disk atomically.
287
+
288
+ Writes to a temporary file **in the same directory** as the target, flushes
289
+ and fsyncs it, then ``os.replace`` swaps it into place. ``os.replace`` is
290
+ atomic on POSIX within a single filesystem, so an interrupted write never
291
+ leaves a partial or corrupt manifest (REQ-ROBUST-03). Concurrent multi-
292
+ session mutation is out of scope (single-writer assumed, PRD REQ-ROBUST-03).
293
+
294
+ Args:
295
+ path: The destination manifest path (e.g. {epic}/epic-manifest.json).
296
+ data: The fully-formed, already-validated manifest dict to serialize.
297
+
298
+ Raises:
299
+ UsageError: If the temp file cannot be created/written or the replace
300
+ fails (exit 2). On failure the temp file is removed so no debris is
301
+ left behind.
302
+ """
303
+ parent = path.parent
304
+ fd, tmp_name = tempfile.mkstemp(
305
+ prefix=f".{path.name}.", suffix=".tmp", dir=parent
306
+ )
307
+ tmp_path = Path(tmp_name)
308
+ try:
309
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
310
+ json.dump(data, handle, indent=2, ensure_ascii=False)
311
+ handle.write("\n")
312
+ handle.flush()
313
+ os.fsync(handle.fileno())
314
+ os.replace(tmp_path, path)
315
+ except OSError as exc:
316
+ tmp_path.unlink(missing_ok=True)
317
+ raise UsageError(f"atomic write to {path} failed: {exc}")
318
+
319
+
320
+ # --------------------------------------------------------------------------- #
321
+ # Graph Algorithms (02 §4) — implemented in item 004
322
+ # --------------------------------------------------------------------------- #
323
+
324
+
325
+ def find_cycle(features: list[dict]) -> list[str] | None:
326
+ """Return a cycle in the dependsOn graph, or None if acyclic (02 §4).
327
+
328
+ Iterative DFS over the directed graph whose edges are ``feature -> dep``.
329
+ On the first back-edge into a GRAY node, reconstructs and returns the cycle
330
+ path including the repeated start node (e.g. ``["a", "b", "a"]``). A
331
+ self-dependency is a degenerate self-loop returning ``["x", "x"]``. Only
332
+ edges to names present in ``features`` are traversed (dangling refs are
333
+ reported separately by validate). O(V+E).
334
+ """
335
+ adjacency: dict[str, list[str]] = {f["name"]: list(f.get("dependsOn", [])) for f in features}
336
+ WHITE, GRAY, BLACK = 0, 1, 2
337
+ color: dict[str, int] = {name: WHITE for name in adjacency}
338
+ parent: dict[str, str | None] = {name: None for name in adjacency}
339
+
340
+ for root in adjacency:
341
+ if color[root] != WHITE:
342
+ continue
343
+ # Iterative DFS; stack holds (node, index-of-next-neighbor-to-visit).
344
+ stack: list[tuple[str, int]] = [(root, 0)]
345
+ color[root] = GRAY
346
+ while stack:
347
+ node, idx = stack[-1]
348
+ neighbors = [n for n in adjacency[node] if n in adjacency]
349
+ if idx < len(neighbors):
350
+ stack[-1] = (node, idx + 1)
351
+ nxt = neighbors[idx]
352
+ if color[nxt] == WHITE:
353
+ color[nxt] = GRAY
354
+ parent[nxt] = node
355
+ stack.append((nxt, 0))
356
+ elif color[nxt] == GRAY:
357
+ # Back-edge: reconstruct nxt -> … -> node -> nxt.
358
+ # Degenerate self-loop (node == nxt): the while loop body never
359
+ # runs, yielding ["x", "x"] — the documented self-dependency case.
360
+ path = [nxt]
361
+ cursor: str | None = node
362
+ while cursor is not None and cursor != nxt:
363
+ path.append(cursor)
364
+ cursor = parent[cursor]
365
+ path.append(nxt)
366
+ path.reverse()
367
+ return path
368
+ else:
369
+ color[node] = BLACK
370
+ stack.pop()
371
+ return None
372
+
373
+
374
+ def unmet_deps(
375
+ name: str, features: list[dict], complete: dict[str, bool]
376
+ ) -> list[str]:
377
+ """Return a feature's direct dependencies that are not complete (02 §4).
378
+
379
+ Names of this feature's direct ``dependsOn`` entries whose value in
380
+ ``complete`` is False, preserving manifest order. Empty when the feature is
381
+ actionable or itself complete.
382
+ """
383
+ by_name = {f["name"]: f for f in features}
384
+ feature = by_name.get(name, {})
385
+ return [dep for dep in feature.get("dependsOn", []) if not complete.get(dep, False)]
386
+
387
+
388
+ # --------------------------------------------------------------------------- #
389
+ # Resolution & Uniqueness (02 §5) — implemented in item 005
390
+ # --------------------------------------------------------------------------- #
391
+
392
+
393
+ def feature_dirs(specs_dir: Path) -> dict[str, list[Path]]:
394
+ """Map every feature name in the specs tree to the dirs that bear it (02 §5).
395
+
396
+ Scans both layouts to a fixed depth, treating a directory as a feature iff
397
+ it directly contains a ``.pipeline-state.json`` (00 §6, REQ-DIR-03):
398
+ * flat: {specsDir}/{name}/.pipeline-state.json
399
+ * nested: {specsDir}/{epic}/{name}/.pipeline-state.json
400
+
401
+ The returned map is keyed by bare feature name; a name with more than one
402
+ entry is a uniqueness violation (REQ-DIR-04) surfaced as 'ambiguous' or
403
+ 'duplicate-name' by the caller. Epic directories themselves (which hold
404
+ ``epic-manifest.json`` but no ``.pipeline-state.json``) are skipped, so an
405
+ epic name never collides with a feature name (01 §4.3).
406
+
407
+ Args:
408
+ specs_dir: The configured specs directory (already verified to exist).
409
+
410
+ Returns:
411
+ Dict of feature name -> sorted list of absolute feature-dir paths. A
412
+ single-entry list means the name is unique. Descends exactly one level
413
+ below each top dir — never deeper.
414
+ """
415
+ result: dict[str, list[Path]] = {}
416
+ specs_real = specs_dir.resolve()
417
+ for top in sorted(p for p in specs_real.iterdir() if p.is_dir()):
418
+ if (top / PIPELINE_STATE_FILENAME).is_file():
419
+ result.setdefault(top.name, []).append(top) # flat feature
420
+ # Descend one level for nested features (skip epic root, which has no state file).
421
+ for child in sorted(p for p in top.iterdir() if p.is_dir()):
422
+ if (child / PIPELINE_STATE_FILENAME).is_file():
423
+ result.setdefault(child.name, []).append(child)
424
+ return result
425
+
426
+
427
+ def resolve(name: str, specs_dir: Path) -> Path:
428
+ """Resolve a bare feature/epic name to its absolute directory (02 §5).
429
+
430
+ Implements the 5-step algorithm (tech-spec §3.4):
431
+ 1. reject unsafe names (assert_safe_name) — exit 2 before any FS access;
432
+ 2. flat match: {specsDir}/{name}/.pipeline-state.json wins outright;
433
+ 3. exactly one nested match resolves cleanly;
434
+ 4. more than one match anywhere -> 'ambiguous' (REQ-DIR-04);
435
+ 5. zero matches -> 'not-found'.
436
+
437
+ Standalone features resolve to their flat path exactly as today, with no
438
+ epic logic engaged (REQ-COMPAT-01/02).
439
+
440
+ Args:
441
+ name: Bare feature/epic name from the command line.
442
+ specs_dir: The configured specs directory.
443
+
444
+ Returns:
445
+ The resolved, path-contained absolute feature directory.
446
+
447
+ Raises:
448
+ UsageError: Unsafe name or missing specs dir (exit 2).
449
+ FindingsError: 'ambiguous' (lists every matching path) or 'not-found'
450
+ (exit 1). 00 §4.2 gives the canonical message shapes.
451
+ """
452
+ assert_safe_name(name)
453
+ if not specs_dir.is_dir():
454
+ raise UsageError(f"specs dir not found: {specs_dir}")
455
+
456
+ flat = specs_dir / name
457
+ if (flat / PIPELINE_STATE_FILENAME).is_file():
458
+ return contained_path(specs_dir, name) # step 2: flat match wins
459
+
460
+ matches = feature_dirs(specs_dir).get(name, [])
461
+ if len(matches) == 1: # step 3
462
+ return contained_path(matches[0].parent, matches[0].name)
463
+ if len(matches) > 1: # step 4
464
+ joined = " and ".join(str(p) for p in matches)
465
+ raise FindingsError([
466
+ {"code": "ambiguous",
467
+ "message": f"ambiguous name {name!r}: matches {joined}",
468
+ "feature": name}
469
+ ])
470
+ raise FindingsError([ # step 5
471
+ {"code": "not-found",
472
+ "message": f"no feature named {name!r} found under {specs_dir}",
473
+ "feature": name}
474
+ ])
475
+
476
+
477
+ def check_name(name: str, specs_dir: Path) -> list[Finding]:
478
+ """Return a duplicate-name finding if the name is already taken (02 §6.3).
479
+
480
+ Used by forge-0-epic before creating a new member feature so no NEW global
481
+ name collision can be introduced (REQ-DIR-04, tech-spec §3.4). Any single
482
+ existing occurrence is enough to reject — unlike ``resolve``, which tolerates
483
+ a uniquely-matching name and only errors on genuine multi-match.
484
+
485
+ Args:
486
+ name: The candidate new feature/epic name.
487
+ specs_dir: The configured specs directory.
488
+
489
+ Returns:
490
+ A single-element list with a 'duplicate-name' Finding when the name
491
+ already maps to one or more feature-shaped dirs (or to an existing epic
492
+ dir); an empty list when the name is free.
493
+
494
+ Raises:
495
+ UsageError: Unsafe name or missing specs dir (exit 2).
496
+ """
497
+ assert_safe_name(name)
498
+ if not specs_dir.is_dir():
499
+ raise UsageError(f"specs dir not found: {specs_dir}")
500
+ existing = feature_dirs(specs_dir).get(name, [])
501
+ # An epic dir (manifest, no state file) with this name also collides.
502
+ epic_dir = specs_dir / name
503
+ if (epic_dir / MANIFEST_FILENAME).is_file():
504
+ existing = [*existing, epic_dir]
505
+ if existing:
506
+ joined = ", ".join(str(p) for p in existing)
507
+ return [{"code": "duplicate-name",
508
+ "message": f"duplicate feature name {name!r} (also at {joined})",
509
+ "feature": name}]
510
+ return []
511
+
512
+
513
+ # --------------------------------------------------------------------------- #
514
+ # Validation (02 §6.2, §10) — implemented in item 006
515
+ # --------------------------------------------------------------------------- #
516
+
517
+
518
+ #: Top-level required keys (00 §2.1, mirrors epic-manifest-schema.json).
519
+ _TOP_REQUIRED: Final = (
520
+ "schemaVersion", "epic", "description", "status",
521
+ "narrativeDoc", "createdAt", "updatedAt", "features",
522
+ )
523
+ #: Required keys on each Feature object (00 §2.2).
524
+ _FEATURE_REQUIRED: Final = ("name", "charter", "dependsOn", "exposes", "consumes")
525
+ #: Required keys on each Contract (exposes[]) object (00 §2.3).
526
+ _CONTRACT_REQUIRED: Final = ("name", "kind", "summary")
527
+ #: Required keys on each ConsumedContract (consumes[]) object (00 §2.4).
528
+ _CONSUMED_REQUIRED: Final = ("from", "name", "summary")
529
+ #: Allowed epic lifecycle states (00 §2.1).
530
+ _EPIC_STATUSES: Final = ("active", "paused", "abandoned", "complete")
531
+ #: Allowed Contract kinds (00 §2.3).
532
+ _CONTRACT_KINDS: Final = ("function", "type", "endpoint", "module", "event")
533
+
534
+
535
+ def _schema(message: str, feature: str | None = None) -> Finding:
536
+ """Construct a 'schema' Finding (00 §4)."""
537
+ return {"code": "schema", "message": message, "feature": feature}
538
+
539
+
540
+ def _schema_findings(manifest: dict) -> list[Finding]:
541
+ """Hand-rolled stdlib schema checker over the manifest (02 §6.2, 00 §2.6).
542
+
543
+ Asserts required keys/types/enums/consts from 00 §2 and explicitly rejects
544
+ any ``features[].status`` key (REQ-STATE-02 -> 'cached-status'). No
545
+ third-party ``jsonschema`` (01 §2.1). Returns 'schema' findings plus, for a
546
+ per-feature status key, a 'cached-status' finding.
547
+ """
548
+ findings: list[Finding] = []
549
+ if not isinstance(manifest, dict):
550
+ return [_schema(f"manifest must be a JSON object, got {type(manifest).__name__}")]
551
+
552
+ for key in _TOP_REQUIRED:
553
+ if key not in manifest:
554
+ findings.append(_schema(f"missing required key {key!r}"))
555
+
556
+ if "schemaVersion" in manifest and manifest["schemaVersion"] != 1:
557
+ findings.append(_schema(f"schemaVersion must be 1, got {manifest['schemaVersion']!r}"))
558
+ if "narrativeDoc" in manifest and manifest["narrativeDoc"] != NARRATIVE_FILENAME:
559
+ findings.append(_schema(f"narrativeDoc must be {NARRATIVE_FILENAME!r}, got {manifest['narrativeDoc']!r}")) # noqa: E501
560
+ for key in ("epic", "description", "createdAt", "updatedAt"):
561
+ if key in manifest and not isinstance(manifest[key], str):
562
+ findings.append(_schema(f"{key} must be a string"))
563
+ for key in ("createdAt", "updatedAt"):
564
+ if isinstance(manifest.get(key), str):
565
+ try:
566
+ # Py3.10's fromisoformat rejects a trailing 'Z'; normalize it first.
567
+ datetime.fromisoformat(manifest[key].replace("Z", "+00:00"))
568
+ except ValueError:
569
+ findings.append(_schema(f"{key} must be an ISO-8601 date-time, got {manifest[key]!r}")) # noqa: E501
570
+ if "status" in manifest and manifest["status"] not in _EPIC_STATUSES:
571
+ findings.append(_schema(f"status must be one of {list(_EPIC_STATUSES)}, got {manifest['status']!r}")) # noqa: E501
572
+ for key in manifest:
573
+ if key not in _TOP_REQUIRED:
574
+ findings.append(_schema(f"unknown key {key!r}"))
575
+
576
+ features = manifest.get("features")
577
+ if "features" in manifest and not isinstance(features, list):
578
+ findings.append(_schema("features must be an array"))
579
+ return findings
580
+ if not isinstance(features, list):
581
+ return findings
582
+
583
+ for idx, feat in enumerate(features):
584
+ if not isinstance(feat, dict):
585
+ findings.append(_schema(f"features[{idx}] must be an object"))
586
+ continue
587
+ fname = feat.get("name") if isinstance(feat.get("name"), str) else None
588
+ label = fname or f"features[{idx}]"
589
+ if "status" in feat:
590
+ findings.append({
591
+ "code": "cached-status",
592
+ "message": f"feature {label!r} carries a forbidden 'status' key (REQ-STATE-02)",
593
+ "feature": fname,
594
+ })
595
+ for key in _FEATURE_REQUIRED:
596
+ if key not in feat:
597
+ findings.append(_schema(f"feature {label!r} missing required key {key!r}", fname))
598
+ for key in feat:
599
+ # 'status' is rejected separately above via the dedicated 'cached-status' code.
600
+ if key not in _FEATURE_REQUIRED and key != "status":
601
+ findings.append(_schema(f"feature {label!r} has unknown key {key!r}", fname))
602
+ for key in ("name", "charter"):
603
+ if key in feat and not isinstance(feat[key], str):
604
+ findings.append(_schema(f"feature {label!r} {key} must be a string", fname))
605
+ if "dependsOn" in feat:
606
+ if not isinstance(feat["dependsOn"], list) or not all(isinstance(d, str) for d in feat["dependsOn"]): # noqa: E501
607
+ findings.append(_schema(f"feature {label!r} dependsOn must be an array of strings", fname)) # noqa: E501
608
+ for key, required, kind_check in (
609
+ ("exposes", _CONTRACT_REQUIRED, True),
610
+ ("consumes", _CONSUMED_REQUIRED, False),
611
+ ):
612
+ if key not in feat:
613
+ continue
614
+ entries = feat[key]
615
+ if not isinstance(entries, list):
616
+ findings.append(_schema(f"feature {label!r} {key} must be an array", fname))
617
+ continue
618
+ for j, entry in enumerate(entries):
619
+ if not isinstance(entry, dict):
620
+ findings.append(_schema(f"feature {label!r} {key}[{j}] must be an object", fname)) # noqa: E501
621
+ continue
622
+ for rk in required:
623
+ if rk not in entry:
624
+ findings.append(_schema(f"feature {label!r} {key}[{j}] missing required key {rk!r}", fname)) # noqa: E501
625
+ for ek in entry:
626
+ if ek not in required:
627
+ findings.append(_schema(f"feature {label!r} {key}[{j}] has unknown key {ek!r}", fname)) # noqa: E501
628
+ if kind_check and "kind" in entry and entry["kind"] not in _CONTRACT_KINDS:
629
+ findings.append(_schema(f"feature {label!r} {key}[{j}] kind must be one of {list(_CONTRACT_KINDS)}", fname)) # noqa: E501
630
+ return findings
631
+
632
+
633
+ def _validate_dict(
634
+ manifest: dict, epic_dir: Path, specs_dir: Path
635
+ ) -> list[Finding]:
636
+ """Validate an already-parsed manifest dict, returning findings (02 §6.2).
637
+
638
+ Runs the invariant checks of 00 §2.6 in order, short-circuiting only where a
639
+ later check cannot run. Reused by the item-008 mutators on the EDITED dict
640
+ before writing. Does not parse JSON (that is ``validate``'s job) — operates
641
+ purely in memory.
642
+ """
643
+ findings: list[Finding] = []
644
+
645
+ # (2) schema conformance (incl. cached-status guard).
646
+ findings.extend(_schema_findings(manifest))
647
+
648
+ features = manifest.get("features")
649
+ if not isinstance(features, list) or not all(
650
+ isinstance(f, dict) and isinstance(f.get("name"), str) for f in features
651
+ ):
652
+ # Cannot run name/graph checks without well-formed feature names.
653
+ return findings
654
+
655
+ names = [f["name"] for f in features]
656
+
657
+ # (3) epic + every feature name safe.
658
+ epic_name = manifest.get("epic")
659
+ candidates = ([epic_name] if isinstance(epic_name, str) else []) + names
660
+ for candidate in candidates:
661
+ if not SAFE_NAME_RE.match(candidate):
662
+ findings.append({
663
+ "code": "unsafe-name",
664
+ "message": f"unsafe name {candidate!r}",
665
+ "feature": candidate if candidate in names else None,
666
+ })
667
+
668
+ # (3b) names unique within the manifest.
669
+ seen: set[str] = set()
670
+ for n in names:
671
+ if n in seen:
672
+ findings.append({
673
+ "code": "duplicate-name",
674
+ "message": f"duplicate feature name {n!r} within the manifest",
675
+ "feature": n,
676
+ })
677
+ seen.add(n)
678
+
679
+ # (4) global name uniqueness across the specs tree.
680
+ if specs_dir.is_dir():
681
+ tree = feature_dirs(specs_dir)
682
+ for n in names:
683
+ dirs = tree.get(n, [])
684
+ if len(dirs) > 1:
685
+ joined = ", ".join(str(p) for p in dirs)
686
+ findings.append({
687
+ "code": "duplicate-name",
688
+ "message": f"duplicate feature name {n!r} (maps to {joined})",
689
+ "feature": n,
690
+ })
691
+
692
+ # (5) every dependsOn / consumes.from references a known feature.
693
+ known = set(names)
694
+ for feat in features:
695
+ fname = feat["name"]
696
+ for dep in feat.get("dependsOn", []) or []:
697
+ if isinstance(dep, str) and dep not in known:
698
+ findings.append({
699
+ "code": "dangling-ref",
700
+ "message": f"feature {fname!r} dependsOn unknown feature {dep!r}",
701
+ "feature": fname,
702
+ })
703
+ for entry in feat.get("consumes", []) or []:
704
+ if isinstance(entry, dict):
705
+ src = entry.get("from")
706
+ if isinstance(src, str) and src not in known:
707
+ findings.append({
708
+ "code": "dangling-ref",
709
+ "message": f"feature {fname!r} consumes from unknown feature {src!r}",
710
+ "feature": fname,
711
+ })
712
+
713
+ # (6) dependsOn graph acyclic (self-dependency surfaces as a cycle).
714
+ cycle = find_cycle(features)
715
+ if cycle is not None:
716
+ findings.append({
717
+ "code": "cycle",
718
+ "message": " → ".join(cycle),
719
+ "feature": cycle[0],
720
+ })
721
+
722
+ return findings
723
+
724
+
725
+ def validate(epic_dir: Path, specs_dir: Path) -> list[Finding]:
726
+ """Validate a single epic manifest, returning all findings (02 §6.2).
727
+
728
+ Parses the manifest (folding any corrupt-json finding from load_manifest
729
+ into the returned list) then delegates to ``_validate_dict``. Raises
730
+ UsageError (exit 2) for a missing/unreadable manifest.
731
+ """
732
+ try:
733
+ manifest = load_manifest(epic_dir)
734
+ except FindingsError as exc:
735
+ return list(exc.findings)
736
+ return _validate_dict(manifest, epic_dir, specs_dir)
737
+
738
+
739
+ # --------------------------------------------------------------------------- #
740
+ # Live Status Derivation (02 §8) — implemented in item 007
741
+ # --------------------------------------------------------------------------- #
742
+
743
+
744
+ def is_complete_for_orchestration(state: dict) -> bool:
745
+ """Apply the completion-for-orchestration predicate (00 §7, 02 §8.1).
746
+
747
+ A feature is complete-for-orchestration iff::
748
+
749
+ stages['forge-5-loop'].status == 'complete'
750
+ AND ('forge-verify-impl' absent
751
+ OR stages['forge-verify-impl'].status in {'passed', 'findings-applied'})
752
+
753
+ A feature whose forge-verify-impl is 'findings-reported' (unfixed) is NOT
754
+ complete and does NOT unblock dependents (REQ-ORCH-01). This is the single
755
+ implementation of the predicate, reused by the dependency gate and handoff
756
+ (04-pipeline-integration.md).
757
+
758
+ Args:
759
+ state: A parsed .pipeline-state.json dict (or {} if the member has none).
760
+
761
+ Returns:
762
+ True iff the feature is complete for orchestration purposes.
763
+ """
764
+ stages = state.get("stages", {})
765
+ if not isinstance(stages, dict):
766
+ return False
767
+ loop = stages.get("forge-5-loop", {})
768
+ if not isinstance(loop, dict) or loop.get("status") != "complete":
769
+ return False
770
+ impl = stages.get("forge-verify-impl")
771
+ if impl is None:
772
+ return True
773
+ if not isinstance(impl, dict):
774
+ return False
775
+ return impl.get("status") in {"passed", "findings-applied"}
776
+
777
+
778
+ def _read_state_safely(state_path: Path) -> dict:
779
+ """Read and parse a member's .pipeline-state.json, tolerating corruption.
780
+
781
+ A missing, unreadable, unparseable, or torn (partially-written) member state
782
+ downgrades to ``{}`` rather than crashing the dashboard (02 §8.2). Member
783
+ state writes are made by forge-1..5 skills outside the helper's atomicity
784
+ scope, so a torn read is expected and simply renders that one feature as
785
+ ``not-started``.
786
+ """
787
+ if not state_path.is_file():
788
+ return {}
789
+ try:
790
+ parsed = json.loads(state_path.read_text(encoding="utf-8"))
791
+ except (OSError, json.JSONDecodeError):
792
+ return {}
793
+ return parsed if isinstance(parsed, dict) else {}
794
+
795
+
796
+ def derive_status(feature_dir: Path) -> FeatureStatus:
797
+ """Derive a feature's live status from its own pipeline state (00 §5, 02 §8).
798
+
799
+ Reads ``{feature_dir}/.pipeline-state.json`` and maps it to a FeatureStatus:
800
+ missing/unparseable/all-pending -> ``not-started``; complete-for-
801
+ orchestration -> ``complete``; otherwise ``in-progress``. The ``stage`` field
802
+ is the state's ``currentStage``, defaulting to ``forge-0-epic`` when the
803
+ member dir exists but no stage ran. ``blocked``/``unmetDeps`` are placeholders
804
+ (``False``/``[]``); ``render_status`` overwrites them once it knows the graph.
805
+
806
+ Args:
807
+ feature_dir: The member feature's directory.
808
+
809
+ Returns:
810
+ A FeatureStatus (00 §5) with name, stage, coarse status, and placeholder
811
+ blocked/unmetDeps.
812
+ """
813
+ name = feature_dir.name
814
+ state = _read_state_safely(feature_dir / PIPELINE_STATE_FILENAME)
815
+ stage = state.get("currentStage") or "forge-0-epic"
816
+
817
+ if not state:
818
+ derived: DerivedStatus = "not-started"
819
+ elif is_complete_for_orchestration(state):
820
+ derived = "complete"
821
+ else:
822
+ stages = state.get("stages", {})
823
+ started = isinstance(stages, dict) and any(
824
+ isinstance(entry, dict) and entry.get("status") not in (None, "pending")
825
+ for entry in stages.values()
826
+ )
827
+ derived = "in-progress" if started else "not-started"
828
+
829
+ return {
830
+ "name": name,
831
+ "stage": stage,
832
+ "status": derived,
833
+ "blocked": False,
834
+ "unmetDeps": [],
835
+ }
836
+
837
+
838
+ def _transitive_deps(name: str, adjacency: dict[str, list[str]]) -> set[str]:
839
+ """Return all features reachable from ``name`` via dependsOn edges (00 §8)."""
840
+ seen: set[str] = set()
841
+ stack = list(adjacency.get(name, []))
842
+ while stack:
843
+ cur = stack.pop()
844
+ if cur in seen:
845
+ continue
846
+ seen.add(cur)
847
+ stack.extend(adjacency.get(cur, []))
848
+ return seen
849
+
850
+
851
+ def _next_command(feature_dir: Path, status_row: FeatureStatus) -> str:
852
+ """Recommend the next forge command for an actionable feature (02 §8.3).
853
+
854
+ ``/feature-forge:forge-1-prd <name>`` when the feature's PRD is absent (or it
855
+ has not progressed past epic creation), else the command for its next un-run
856
+ stage (its ``currentStage``).
857
+ """
858
+ name = status_row["name"]
859
+ stage = status_row["stage"]
860
+ prd_present = (feature_dir / "PRD.md").is_file()
861
+ if not prd_present or stage in ("forge-0-epic", "forge-1-prd"):
862
+ return f"/feature-forge:forge-1-prd {name}"
863
+ return f"/feature-forge:{stage} {name}"
864
+
865
+
866
+ def render_status(epic_dir: Path, specs_dir: Path) -> RenderStatus:
867
+ """Build the full live dashboard payload for an epic (00 §5, §8; 02 §8.3).
868
+
869
+ Validates first (refusing to render over an invalid graph), then derives each
870
+ member's live status from its own state file, computes blocked/unmetDeps,
871
+ actionable, parallelEligible, the rollup, and the recommended next command.
872
+
873
+ Args:
874
+ epic_dir: The epic subtree directory.
875
+ specs_dir: The configured specs directory.
876
+
877
+ Returns:
878
+ The RenderStatus dict (02 §8.4).
879
+
880
+ Raises:
881
+ UsageError: Missing/unreadable manifest (exit 2).
882
+ FindingsError: The manifest fails validation (exit 1).
883
+ """
884
+ # (1) validate first — no dashboard over an invalid graph.
885
+ findings = validate(epic_dir, specs_dir)
886
+ if findings:
887
+ raise FindingsError(findings)
888
+
889
+ manifest = load_manifest(epic_dir)
890
+ features = manifest.get("features", [])
891
+
892
+ # (2) derive each feature's status and (3) build the completion map.
893
+ rows: list[FeatureStatus] = []
894
+ feature_dir_by_name: dict[str, Path] = {}
895
+ complete: dict[str, bool] = {}
896
+ for feat in features:
897
+ name = feat["name"]
898
+ member_dir = contained_path(epic_dir, name)
899
+ feature_dir_by_name[name] = member_dir
900
+ rows.append(derive_status(member_dir))
901
+ complete[name] = is_complete_for_orchestration(
902
+ _read_state_safely(member_dir / PIPELINE_STATE_FILENAME)
903
+ )
904
+
905
+ # (4) per-feature unmetDeps + blocked. A feature that is itself complete is
906
+ # never "blocked" — unmet deps only matter for work not yet finished.
907
+ for row in rows:
908
+ if complete[row["name"]]:
909
+ row["unmetDeps"] = []
910
+ row["blocked"] = False
911
+ continue
912
+ deps = unmet_deps(row["name"], features, complete)
913
+ row["unmetDeps"] = deps
914
+ row["blocked"] = bool(deps)
915
+
916
+ # (5) actionable = unmetDeps empty AND not complete.
917
+ actionable = [
918
+ row["name"]
919
+ for row in rows
920
+ if not row["unmetDeps"] and not complete[row["name"]]
921
+ ]
922
+
923
+ # (6) parallelEligible = actionable features with no transitive dependsOn
924
+ # relationship to any other actionable feature.
925
+ adjacency = {f["name"]: list(f.get("dependsOn", [])) for f in features}
926
+ actionable_set = set(actionable)
927
+ parallel_eligible: list[str] = []
928
+ for name in actionable:
929
+ related = _transitive_deps(name, adjacency)
930
+ others = actionable_set - {name}
931
+ # Eligible iff it neither depends on nor is depended on by another actionable.
932
+ depends_on_other = bool(related & others)
933
+ depended_on = any(name in _transitive_deps(o, adjacency) for o in others)
934
+ if not depends_on_other and not depended_on:
935
+ parallel_eligible.append(name)
936
+
937
+ # (7) rollup.
938
+ rollup: Rollup = {
939
+ "complete": sum(1 for v in complete.values() if v),
940
+ "total": len(features),
941
+ }
942
+
943
+ # (8) nextCommand for the first actionable feature, else None.
944
+ next_command: str | None = None
945
+ if actionable:
946
+ first = actionable[0]
947
+ first_row = next(r for r in rows if r["name"] == first)
948
+ next_command = _next_command(feature_dir_by_name[first], first_row)
949
+
950
+ return {
951
+ "epic": manifest.get("epic", epic_dir.name),
952
+ "status": manifest.get("status", "active"),
953
+ "features": rows,
954
+ "actionable": actionable,
955
+ "parallelEligible": parallel_eligible,
956
+ "rollup": rollup,
957
+ "nextCommand": next_command,
958
+ }
959
+
960
+
961
+ # --------------------------------------------------------------------------- #
962
+ # Mutators (02 §7) — implemented in item 008
963
+ # --------------------------------------------------------------------------- #
964
+
965
+
966
+ def _bump_and_write(
967
+ epic_dir: Path, specs_dir: Path, manifest: dict
968
+ ) -> list[Finding]:
969
+ """Re-validate, bump updatedAt, and atomically persist a manifest (02 §7).
970
+
971
+ The shared tail of every mutator (REQ-ROBUST-03, REQ-OBS-01, REQ-EPIC-05).
972
+ Re-runs ``_validate_dict`` on the EDITED manifest; if any blocking finding is
973
+ present (cycle, dangling-ref, duplicate-name, schema, ...), the on-disk file
974
+ is left byte-identical and the findings are returned so the caller exits 1.
975
+ Otherwise ``updatedAt`` is set to now (UTC, ISO-8601) and the manifest is
976
+ written via ``atomic_write``.
977
+
978
+ Args:
979
+ epic_dir: The epic subtree directory.
980
+ specs_dir: The configured specs directory (for the uniqueness re-check).
981
+ manifest: The already-edited in-memory manifest dict.
982
+
983
+ Returns:
984
+ An empty list on success (write performed); the blocking findings on
985
+ refusal (no write performed).
986
+
987
+ Raises:
988
+ UsageError: If the atomic write itself fails (exit 2).
989
+ """
990
+ findings = _validate_dict(manifest, epic_dir, specs_dir)
991
+ if findings:
992
+ return findings
993
+ manifest["updatedAt"] = datetime.now(timezone.utc).isoformat()
994
+ atomic_write(epic_dir / MANIFEST_FILENAME, manifest)
995
+ return []
996
+
997
+
998
+ def add_feature(
999
+ epic_dir: Path,
1000
+ specs_dir: Path,
1001
+ name: str,
1002
+ charter: str,
1003
+ deps: list[str],
1004
+ ) -> list[Finding]:
1005
+ """Append a new member feature to the manifest (02 §7.1).
1006
+
1007
+ Appends a ``Feature`` with the given name/charter/dependsOn and EMPTY
1008
+ exposes/consumes. Re-validation surfaces a duplicate name (within the
1009
+ manifest or across the tree), an unknown dependency (``dangling-ref``), or a
1010
+ cycle, refusing the write in every such case.
1011
+
1012
+ Args:
1013
+ epic_dir: The epic subtree directory.
1014
+ specs_dir: The configured specs directory.
1015
+ name: The new member feature name (already safe-checked by the dispatch).
1016
+ charter: The feature charter text.
1017
+ deps: The new feature's dependsOn list (already comma-split).
1018
+
1019
+ Returns:
1020
+ Empty list on success; blocking findings (duplicate-name / dangling-ref /
1021
+ cycle / schema) on refusal — manifest left unchanged.
1022
+
1023
+ Raises:
1024
+ UsageError: Unsafe name, corrupt/missing manifest, or write failure.
1025
+ """
1026
+ for dep in deps:
1027
+ assert_safe_name(dep)
1028
+ manifest = load_manifest(epic_dir)
1029
+ features = manifest.setdefault("features", [])
1030
+ features.append({
1031
+ "name": name,
1032
+ "charter": charter,
1033
+ "dependsOn": deps,
1034
+ "exposes": [],
1035
+ "consumes": [],
1036
+ })
1037
+ return _bump_and_write(epic_dir, specs_dir, manifest)
1038
+
1039
+
1040
+ def remove_feature(epic_dir: Path, specs_dir: Path, name: str) -> list[Finding]:
1041
+ """Remove a member feature from the manifest (02 §7.2).
1042
+
1043
+ Drops the named feature from ``features[]``. After removal, re-validation
1044
+ surfaces any now-dangling ``dependsOn`` / ``consumes.from`` that pointed at
1045
+ the removed feature; the write is refused in that case so the references can
1046
+ be fixed first. A name that is not a member yields a ``not-found`` finding.
1047
+
1048
+ Args:
1049
+ epic_dir: The epic subtree directory.
1050
+ specs_dir: The configured specs directory.
1051
+ name: The member feature to remove.
1052
+
1053
+ Returns:
1054
+ Empty list on success; ``not-found`` if the name is not a member, or the
1055
+ now-dangling ``dangling-ref`` findings on refusal.
1056
+
1057
+ Raises:
1058
+ UsageError: Unsafe name, corrupt/missing manifest, or write failure.
1059
+ """
1060
+ manifest = load_manifest(epic_dir)
1061
+ features = manifest.get("features", [])
1062
+ if not any(isinstance(f, dict) and f.get("name") == name for f in features):
1063
+ return [{"code": "not-found",
1064
+ "message": f"feature {name!r} is not a member of epic {epic_dir.name!r}",
1065
+ "feature": name}]
1066
+ manifest["features"] = [
1067
+ f for f in features if not (isinstance(f, dict) and f.get("name") == name)
1068
+ ]
1069
+ return _bump_and_write(epic_dir, specs_dir, manifest)
1070
+
1071
+
1072
+ def reorder(epic_dir: Path, specs_dir: Path, order: list[str]) -> list[Finding]:
1073
+ """Reorder the manifest features[] to a given permutation (02 §7.3).
1074
+
1075
+ ``order`` must be an exact permutation of the current member names (purely a
1076
+ display sequence, not a dependency ordering — 00 §2.1). If it is not, a
1077
+ ``schema`` finding is returned and the manifest is left unchanged.
1078
+
1079
+ Args:
1080
+ epic_dir: The epic subtree directory.
1081
+ specs_dir: The configured specs directory.
1082
+ order: The desired member-name ordering (already comma-split).
1083
+
1084
+ Returns:
1085
+ Empty list on success; a ``schema`` finding when ``order`` is not an exact
1086
+ permutation of the current members.
1087
+
1088
+ Raises:
1089
+ UsageError: Corrupt/missing manifest or write failure.
1090
+ """
1091
+ manifest = load_manifest(epic_dir)
1092
+ features = manifest.get("features", [])
1093
+ by_name = {f["name"]: f for f in features if isinstance(f, dict) and isinstance(f.get("name"), str)} # noqa: E501
1094
+ current = sorted(by_name)
1095
+ if sorted(order) != current:
1096
+ return [{"code": "schema",
1097
+ "message": f"--order {order} is not an exact permutation of members {sorted(by_name)}", # noqa: E501
1098
+ "feature": None}]
1099
+ manifest["features"] = [by_name[n] for n in order]
1100
+ return _bump_and_write(epic_dir, specs_dir, manifest)
1101
+
1102
+
1103
+ def set_dep(
1104
+ epic_dir: Path, specs_dir: Path, name: str, deps: list[str]
1105
+ ) -> list[Finding]:
1106
+ """Replace a member feature's dependsOn list (02 §7.4, §7.6).
1107
+
1108
+ Re-validation enforces every new dependency exists (``dangling-ref``) and the
1109
+ resulting graph is acyclic (``cycle``). An empty ``deps`` clears the
1110
+ dependencies.
1111
+
1112
+ Args:
1113
+ epic_dir: The epic subtree directory.
1114
+ specs_dir: The configured specs directory.
1115
+ name: The member feature to edit.
1116
+ deps: The new dependsOn list (already comma-split; empty clears deps).
1117
+
1118
+ Returns:
1119
+ Empty list on success; blocking findings (dangling-ref / cycle /
1120
+ not-found) on refusal — manifest left unchanged.
1121
+
1122
+ Raises:
1123
+ UsageError: Unsafe name, corrupt/missing manifest, or write failure.
1124
+ """
1125
+ for dep in deps:
1126
+ assert_safe_name(dep)
1127
+ manifest = load_manifest(epic_dir)
1128
+ by_name = {
1129
+ f["name"]: f
1130
+ for f in manifest.get("features", [])
1131
+ if isinstance(f, dict) and isinstance(f.get("name"), str)
1132
+ }
1133
+ if name not in by_name:
1134
+ return [{"code": "not-found",
1135
+ "message": f"feature {name!r} is not a member of epic {epic_dir.name!r}",
1136
+ "feature": name}]
1137
+ by_name[name]["dependsOn"] = deps
1138
+ return _bump_and_write(epic_dir, specs_dir, manifest)
1139
+
1140
+
1141
+ def set_status(epic_dir: Path, specs_dir: Path, status: str) -> list[Finding]:
1142
+ """Set the epic-level lifecycle status (02 §7.5).
1143
+
1144
+ Sets the epic-level ``status`` (the value is constrained to the allowed
1145
+ lifecycle states by ``argparse`` ``choices`` before reaching here). Never
1146
+ touches per-feature status (there is none — REQ-STATE-02).
1147
+
1148
+ Args:
1149
+ epic_dir: The epic subtree directory.
1150
+ specs_dir: The configured specs directory.
1151
+ status: The new epic lifecycle status (already choice-validated).
1152
+
1153
+ Returns:
1154
+ Empty list on success; blocking findings if the manifest somehow fails
1155
+ re-validation.
1156
+
1157
+ Raises:
1158
+ UsageError: Corrupt/missing manifest or write failure.
1159
+ """
1160
+ manifest = load_manifest(epic_dir)
1161
+ manifest["status"] = status
1162
+ return _bump_and_write(epic_dir, specs_dir, manifest)
1163
+
1164
+
1165
+ # --------------------------------------------------------------------------- #
1166
+ # CLI Dispatch (02 §9)
1167
+ # --------------------------------------------------------------------------- #
1168
+
1169
+
1170
+ def _split_list(value: str | None) -> list[str]:
1171
+ """Split a comma-separated CLI argument into a stripped list.
1172
+
1173
+ An empty or absent value yields an empty list (e.g. ``--depends-on ""``
1174
+ clears dependencies). Each token is stripped; empty tokens are dropped.
1175
+ """
1176
+ if not value:
1177
+ return []
1178
+ return [item.strip() for item in value.split(",") if item.strip()]
1179
+
1180
+
1181
+ def _emit_findings(findings: list[Finding], as_json: bool) -> None:
1182
+ """Print findings as JSON or as one actionable line per finding.
1183
+
1184
+ JSON ({"valid": false, "findings": [...]}) goes to stdout; human-readable
1185
+ lines go to stderr (mirroring validate-traceability.py's two output modes).
1186
+ """
1187
+ if as_json:
1188
+ print(json.dumps({"valid": not findings, "findings": findings}, indent=2, ensure_ascii=False)) # noqa: E501
1189
+ else:
1190
+ for finding in findings:
1191
+ print(f"{finding['code']}: {finding['message']}", file=sys.stderr)
1192
+
1193
+
1194
+ def _print_status_table(status: RenderStatus) -> None:
1195
+ """Print a readable epic dashboard plus the recommended next command (02 §8)."""
1196
+ rollup = status["rollup"]
1197
+ print(f"Epic: {status['epic']} [{status['status']}]")
1198
+ print(f"Progress: {rollup['complete']}/{rollup['total']} complete")
1199
+ if not status["features"]:
1200
+ print(" (no features — add features to begin)")
1201
+ for row in status["features"]:
1202
+ line = f" - {row['name']}: {row['status']} (stage {row['stage']})"
1203
+ if row["blocked"]:
1204
+ line += f" — blocked on {', '.join(row['unmetDeps'])}"
1205
+ print(line)
1206
+ if status["actionable"]:
1207
+ print(f"Actionable: {', '.join(status['actionable'])}")
1208
+ if status["parallelEligible"]:
1209
+ print(f"Parallel-eligible: {', '.join(status['parallelEligible'])}")
1210
+ if status["nextCommand"]:
1211
+ print(f"Next: {status['nextCommand']}")
1212
+
1213
+
1214
+ def _dispatch(args: argparse.Namespace, specs_dir: Path) -> int:
1215
+ """Route a parsed command to its handler, translating return/raise into exit codes.
1216
+
1217
+ Read-only commands (resolve / check-name / validate / render-status) print to
1218
+ stdout and return 0; mutators return findings the caller raises as a
1219
+ ``FindingsError`` (exit 1). Unknown commands raise ``UsageError`` (exit 2).
1220
+ """
1221
+ cmd: str = args.cmd
1222
+
1223
+ if cmd == "resolve":
1224
+ path = resolve(args.name, specs_dir)
1225
+ print(str(path))
1226
+ return 0
1227
+
1228
+ if cmd == "check-name":
1229
+ findings = check_name(args.name, specs_dir)
1230
+ if findings:
1231
+ raise FindingsError(findings)
1232
+ return 0
1233
+
1234
+ if cmd == "validate":
1235
+ epic_dir = contained_path(specs_dir, args.epic)
1236
+ findings = validate(epic_dir, specs_dir)
1237
+ if findings:
1238
+ raise FindingsError(findings)
1239
+ if args.json_output:
1240
+ print(json.dumps({"valid": True, "findings": []}, indent=2))
1241
+ return 0
1242
+
1243
+ if cmd == "render-status":
1244
+ epic_dir = contained_path(specs_dir, args.epic)
1245
+ status = render_status(epic_dir, specs_dir)
1246
+ if args.json_output:
1247
+ print(json.dumps(status, indent=2))
1248
+ else:
1249
+ _print_status_table(status)
1250
+ return 0
1251
+
1252
+ # Mutators ---------------------------------------------------------------
1253
+ if cmd in {"add-feature", "remove-feature", "reorder", "set-dep", "set-status"}:
1254
+ epic_dir = contained_path(specs_dir, args.epic)
1255
+ if cmd == "add-feature":
1256
+ findings = add_feature(
1257
+ epic_dir, specs_dir, args.name, args.charter,
1258
+ _split_list(args.depends_on),
1259
+ )
1260
+ elif cmd == "remove-feature":
1261
+ findings = remove_feature(epic_dir, specs_dir, args.name)
1262
+ elif cmd == "reorder":
1263
+ findings = reorder(epic_dir, specs_dir, _split_list(args.order))
1264
+ elif cmd == "set-dep":
1265
+ findings = set_dep(
1266
+ epic_dir, specs_dir, args.name, _split_list(args.depends_on)
1267
+ )
1268
+ else: # set-status
1269
+ findings = set_status(epic_dir, specs_dir, args.status)
1270
+ if findings:
1271
+ raise FindingsError(findings)
1272
+ return 0
1273
+
1274
+ raise UsageError(f"unknown command: {cmd}")
1275
+
1276
+
1277
+ def _build_parser() -> argparse.ArgumentParser:
1278
+ """Build the argparse parser with one subparser per subcommand (02 §9)."""
1279
+ parser = argparse.ArgumentParser(prog="epic-manifest.py", description=__doc__)
1280
+ sub = parser.add_subparsers(dest="cmd", required=True)
1281
+
1282
+ def add_specs_dir(p: argparse.ArgumentParser) -> None:
1283
+ p.add_argument("--specs-dir", default="./specs", help="Specs directory")
1284
+
1285
+ def add_json(p: argparse.ArgumentParser) -> None:
1286
+ p.add_argument(
1287
+ "--json", action="store_true", dest="json_output", help="Output as JSON"
1288
+ )
1289
+
1290
+ # resolve --------------------------------------------------------------- #
1291
+ p_resolve = sub.add_parser("resolve", help="Resolve a name to its directory")
1292
+ p_resolve.add_argument("name")
1293
+ add_specs_dir(p_resolve)
1294
+
1295
+ # validate -------------------------------------------------------------- #
1296
+ p_validate = sub.add_parser("validate", help="Validate an epic manifest")
1297
+ p_validate.add_argument("epic")
1298
+ add_specs_dir(p_validate)
1299
+ p_validate.add_argument(
1300
+ "--json", action="store_true", dest="json_output", help="Output as JSON"
1301
+ )
1302
+
1303
+ # check-name ------------------------------------------------------------ #
1304
+ p_check = sub.add_parser("check-name", help="Check global name uniqueness")
1305
+ p_check.add_argument("name")
1306
+ add_specs_dir(p_check)
1307
+
1308
+ # render-status --------------------------------------------------------- #
1309
+ p_render = sub.add_parser("render-status", help="Render the live epic dashboard")
1310
+ p_render.add_argument("epic")
1311
+ add_specs_dir(p_render)
1312
+ p_render.add_argument(
1313
+ "--json", action="store_true", dest="json_output", help="Output as JSON"
1314
+ )
1315
+
1316
+ # add-feature ----------------------------------------------------------- #
1317
+ p_add = sub.add_parser("add-feature", help="Add a member feature")
1318
+ p_add.add_argument("epic")
1319
+ p_add.add_argument("name")
1320
+ p_add.add_argument("--charter", required=True, help="One-paragraph charter")
1321
+ p_add.add_argument("--depends-on", dest="depends_on", default="", help="Comma list")
1322
+ add_specs_dir(p_add)
1323
+ add_json(p_add)
1324
+
1325
+ # remove-feature -------------------------------------------------------- #
1326
+ p_remove = sub.add_parser("remove-feature", help="Remove a member feature")
1327
+ p_remove.add_argument("epic")
1328
+ p_remove.add_argument("name")
1329
+ add_specs_dir(p_remove)
1330
+ add_json(p_remove)
1331
+
1332
+ # reorder --------------------------------------------------------------- #
1333
+ p_reorder = sub.add_parser("reorder", help="Reorder member features")
1334
+ p_reorder.add_argument("epic")
1335
+ p_reorder.add_argument("--order", required=True, help="Comma-separated permutation")
1336
+ add_specs_dir(p_reorder)
1337
+ add_json(p_reorder)
1338
+
1339
+ # set-dep --------------------------------------------------------------- #
1340
+ p_setdep = sub.add_parser("set-dep", help="Replace a feature's dependsOn")
1341
+ p_setdep.add_argument("epic")
1342
+ p_setdep.add_argument("name")
1343
+ p_setdep.add_argument("--depends-on", dest="depends_on", default="", help="Comma list")
1344
+ add_specs_dir(p_setdep)
1345
+ add_json(p_setdep)
1346
+
1347
+ # set-status ------------------------------------------------------------ #
1348
+ p_setstatus = sub.add_parser("set-status", help="Set the epic lifecycle status")
1349
+ p_setstatus.add_argument("epic")
1350
+ p_setstatus.add_argument(
1351
+ "--status",
1352
+ required=True,
1353
+ choices=["active", "paused", "abandoned", "complete"],
1354
+ )
1355
+ add_specs_dir(p_setstatus)
1356
+ add_json(p_setstatus)
1357
+
1358
+ return parser
1359
+
1360
+
1361
+ def main() -> int:
1362
+ parser = _build_parser()
1363
+ args = parser.parse_args()
1364
+ specs_dir = Path(args.specs_dir)
1365
+ try:
1366
+ return _dispatch(args, specs_dir)
1367
+ except UsageError as exc:
1368
+ print(f"Error: {exc.message}", file=sys.stderr)
1369
+ return 2
1370
+ except FindingsError as exc:
1371
+ _emit_findings(exc.findings, getattr(args, "json_output", False))
1372
+ return 1
1373
+ except OSError as exc:
1374
+ print(f"Error: {exc}", file=sys.stderr)
1375
+ return 2
1376
+
1377
+
1378
+ if __name__ == "__main__":
1379
+ sys.exit(main())