@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.
- package/README.md +19 -1
- package/adapters/GENERATION-REPORT.md +12 -12
- package/adapters/claude/.feature-forge-bundle.json +6 -0
- package/adapters/claude/references/forge-config-schema.json +2 -2
- package/adapters/claude/references/portable-root.md +8 -5
- package/adapters/claude/references/process-overview.md +1 -1
- package/adapters/claude/references/shared-conventions.md +24 -5
- package/adapters/claude/references/stack-resolution.md +4 -1
- package/adapters/claude/references/stacks/go.md +1 -1
- package/adapters/claude/references/stacks/python.md +1 -1
- package/adapters/claude/references/stacks/rust.md +1 -1
- package/adapters/claude/references/stacks/typescript.md +1 -1
- package/adapters/claude/scripts/epic-manifest.py +1379 -0
- package/adapters/claude/scripts/forge-bootstrap.py +991 -0
- package/adapters/claude/scripts/forge-init.sh +44 -0
- package/adapters/claude/scripts/forge-root.sh +30 -8
- package/adapters/claude/scripts/validate-traceability.py +150 -0
- package/adapters/claude/skills/forge/SKILL.md +5 -5
- package/adapters/claude/skills/forge-0-epic/SKILL.md +6 -10
- package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +2 -2
- package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
- package/adapters/claude/skills/forge-1-prd/SKILL.md +2 -2
- package/adapters/claude/skills/forge-2-tech/SKILL.md +8 -7
- package/adapters/claude/skills/forge-2-tech/references/stack-discovery-checklist.md +4 -4
- package/adapters/claude/skills/forge-3-specs/SKILL.md +1 -1
- package/adapters/claude/skills/forge-4-backlog/SKILL.md +2 -2
- package/adapters/claude/skills/forge-5-loop/SKILL.md +2 -2
- package/adapters/claude/skills/forge-6-docs/SKILL.md +2 -2
- package/adapters/claude/skills/forge-bootstrap/SKILL.md +4 -4
- package/adapters/claude/skills/forge-fix/SKILL.md +1 -1
- package/adapters/claude/skills/forge-init/SKILL.md +1 -1
- package/adapters/claude/skills/forge-verify/SKILL.md +7 -2
- package/adapters/claude/skills/forge-verify/references/verification-checklists.md +1 -1
- package/adapters/codex/.feature-forge-bundle.json +6 -0
- package/adapters/codex/agents/{forge-researcher.md → forge-researcher.toml} +4 -4
- package/adapters/codex/agents/{forge-spec-writer.md → forge-spec-writer.toml} +4 -4
- package/adapters/codex/agents/{forge-verifier.md → forge-verifier.toml} +4 -4
- package/adapters/codex/references/forge-config-schema.json +2 -2
- package/adapters/codex/references/portable-root.md +8 -5
- package/adapters/codex/references/process-overview.md +1 -1
- package/adapters/codex/references/shared-conventions.md +24 -5
- package/adapters/codex/references/stack-resolution.md +4 -1
- package/adapters/codex/references/stacks/go.md +1 -1
- package/adapters/codex/references/stacks/python.md +1 -1
- package/adapters/codex/references/stacks/rust.md +1 -1
- package/adapters/codex/references/stacks/typescript.md +1 -1
- package/adapters/codex/scripts/epic-manifest.py +1379 -0
- package/adapters/codex/scripts/forge-bootstrap.py +991 -0
- package/adapters/codex/scripts/forge-init.sh +44 -0
- package/adapters/codex/scripts/forge-root.sh +30 -8
- package/adapters/codex/scripts/validate-traceability.py +150 -0
- package/adapters/codex/skills/forge/{forge.md → SKILL.md} +16 -6
- package/adapters/codex/skills/forge-0-epic/{forge-0-epic.md → SKILL.md} +26 -20
- package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +2 -2
- package/adapters/codex/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
- package/adapters/codex/skills/forge-1-prd/{forge-1-prd.md → SKILL.md} +18 -8
- package/adapters/codex/skills/forge-2-tech/{forge-2-tech.md → SKILL.md} +26 -15
- package/adapters/codex/skills/forge-2-tech/references/stack-discovery-checklist.md +4 -4
- package/adapters/codex/skills/forge-3-specs/{forge-3-specs.md → SKILL.md} +16 -6
- package/adapters/codex/skills/forge-4-backlog/{forge-4-backlog.md → SKILL.md} +15 -5
- package/adapters/codex/skills/forge-5-loop/{forge-5-loop.md → SKILL.md} +27 -17
- package/adapters/codex/skills/forge-6-docs/{forge-6-docs.md → SKILL.md} +17 -7
- package/adapters/codex/skills/forge-bootstrap/{forge-bootstrap.md → SKILL.md} +17 -7
- package/adapters/codex/skills/forge-fix/{forge-fix.md → SKILL.md} +12 -2
- package/adapters/codex/skills/forge-init/{forge-init.md → SKILL.md} +11 -1
- package/adapters/codex/skills/forge-verify/{forge-verify.md → SKILL.md} +24 -9
- package/adapters/codex/skills/forge-verify/references/verification-checklists.md +1 -1
- package/adapters/copilot/.feature-forge-bundle.json +6 -0
- package/adapters/copilot/references/forge-config-schema.json +2 -2
- package/adapters/copilot/references/portable-root.md +8 -5
- package/adapters/copilot/references/process-overview.md +1 -1
- package/adapters/copilot/references/shared-conventions.md +24 -5
- package/adapters/copilot/references/stack-resolution.md +4 -1
- package/adapters/copilot/references/stacks/go.md +1 -1
- package/adapters/copilot/references/stacks/python.md +1 -1
- package/adapters/copilot/references/stacks/rust.md +1 -1
- package/adapters/copilot/references/stacks/typescript.md +1 -1
- package/adapters/copilot/scripts/epic-manifest.py +1379 -0
- package/adapters/copilot/scripts/forge-bootstrap.py +991 -0
- package/adapters/copilot/scripts/forge-init.sh +44 -0
- package/adapters/copilot/scripts/forge-root.sh +30 -8
- package/adapters/copilot/scripts/validate-traceability.py +150 -0
- package/adapters/copilot/skills/forge/forge.md +16 -6
- package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +26 -20
- package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +2 -2
- package/adapters/copilot/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
- package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +18 -8
- package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +26 -15
- package/adapters/copilot/skills/forge-2-tech/references/stack-discovery-checklist.md +4 -4
- package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +16 -6
- package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +15 -5
- package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +27 -17
- package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +17 -7
- package/adapters/copilot/skills/forge-bootstrap/forge-bootstrap.md +17 -7
- package/adapters/copilot/skills/forge-fix/forge-fix.md +12 -2
- package/adapters/copilot/skills/forge-init/forge-init.md +11 -1
- package/adapters/copilot/skills/forge-verify/forge-verify.md +24 -9
- package/adapters/copilot/skills/forge-verify/references/verification-checklists.md +1 -1
- package/adapters/cursor/.feature-forge-bundle.json +6 -0
- package/adapters/cursor/references/forge-config-schema.json +2 -2
- package/adapters/cursor/references/portable-root.md +8 -5
- package/adapters/cursor/references/process-overview.md +1 -1
- package/adapters/cursor/references/shared-conventions.md +24 -5
- package/adapters/cursor/references/stack-resolution.md +4 -1
- package/adapters/cursor/references/stacks/go.md +1 -1
- package/adapters/cursor/references/stacks/python.md +1 -1
- package/adapters/cursor/references/stacks/rust.md +1 -1
- package/adapters/cursor/references/stacks/typescript.md +1 -1
- package/adapters/cursor/scripts/epic-manifest.py +1379 -0
- package/adapters/cursor/scripts/forge-bootstrap.py +991 -0
- package/adapters/cursor/scripts/forge-init.sh +44 -0
- package/adapters/cursor/scripts/forge-root.sh +30 -8
- package/adapters/cursor/scripts/validate-traceability.py +150 -0
- package/adapters/cursor/skills/forge/forge.mdc +16 -6
- package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +26 -20
- package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +2 -2
- package/adapters/cursor/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
- package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +18 -8
- package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +26 -15
- package/adapters/cursor/skills/forge-2-tech/references/stack-discovery-checklist.md +4 -4
- package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +16 -6
- package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +15 -5
- package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +27 -17
- package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +17 -7
- package/adapters/cursor/skills/forge-bootstrap/forge-bootstrap.mdc +17 -7
- package/adapters/cursor/skills/forge-fix/forge-fix.mdc +12 -2
- package/adapters/cursor/skills/forge-init/forge-init.mdc +11 -1
- package/adapters/cursor/skills/forge-verify/forge-verify.mdc +24 -9
- package/adapters/cursor/skills/forge-verify/references/verification-checklists.md +1 -1
- package/adapters/gemini/.feature-forge-bundle.json +6 -0
- package/adapters/gemini/gemini-extension.json +1 -1
- package/adapters/gemini/references/forge-config-schema.json +2 -2
- package/adapters/gemini/references/portable-root.md +8 -5
- package/adapters/gemini/references/process-overview.md +1 -1
- package/adapters/gemini/references/shared-conventions.md +24 -5
- package/adapters/gemini/references/stack-resolution.md +4 -1
- package/adapters/gemini/references/stacks/go.md +1 -1
- package/adapters/gemini/references/stacks/python.md +1 -1
- package/adapters/gemini/references/stacks/rust.md +1 -1
- package/adapters/gemini/references/stacks/typescript.md +1 -1
- package/adapters/gemini/scripts/epic-manifest.py +1379 -0
- package/adapters/gemini/scripts/forge-bootstrap.py +991 -0
- package/adapters/gemini/scripts/forge-init.sh +44 -0
- package/adapters/gemini/scripts/forge-root.sh +30 -8
- package/adapters/gemini/scripts/validate-traceability.py +150 -0
- package/adapters/gemini/skills/forge/forge.md +16 -6
- package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +26 -20
- package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +2 -2
- package/adapters/gemini/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
- package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +18 -8
- package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +26 -15
- package/adapters/gemini/skills/forge-2-tech/references/stack-discovery-checklist.md +4 -4
- package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +16 -6
- package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +15 -5
- package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +27 -17
- package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +17 -7
- package/adapters/gemini/skills/forge-bootstrap/forge-bootstrap.md +17 -7
- package/adapters/gemini/skills/forge-fix/forge-fix.md +12 -2
- package/adapters/gemini/skills/forge-init/forge-init.md +11 -1
- package/adapters/gemini/skills/forge-verify/forge-verify.md +24 -9
- package/adapters/gemini/skills/forge-verify/references/verification-checklists.md +1 -1
- package/dist/agent-targets.d.ts +20 -4
- package/dist/agent-targets.js +29 -4
- package/dist/apply.js +245 -18
- package/dist/cli.js +12 -6
- package/dist/hash.d.ts +5 -0
- package/dist/hash.js +7 -0
- package/dist/manifest.d.ts +4 -2
- package/dist/manifest.js +58 -2
- package/dist/placements.d.ts +69 -0
- package/dist/placements.js +116 -0
- package/dist/plan.d.ts +7 -0
- package/dist/plan.js +87 -1
- package/dist/rauf.d.ts +4 -4
- package/dist/rauf.js +3 -3
- package/dist/report.js +21 -0
- package/dist/source.d.ts +4 -3
- package/dist/source.js +4 -3
- package/dist/types.d.ts +163 -19
- package/dist/types.js +42 -11
- package/package.json +1 -1
- 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())
|