raise-cli 2.2.1__py3-none-any.whl

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 (264) hide show
  1. raise_cli/__init__.py +38 -0
  2. raise_cli/__main__.py +30 -0
  3. raise_cli/adapters/__init__.py +91 -0
  4. raise_cli/adapters/declarative/__init__.py +26 -0
  5. raise_cli/adapters/declarative/adapter.py +267 -0
  6. raise_cli/adapters/declarative/discovery.py +94 -0
  7. raise_cli/adapters/declarative/expressions.py +150 -0
  8. raise_cli/adapters/declarative/reference/__init__.py +1 -0
  9. raise_cli/adapters/declarative/reference/github.yaml +143 -0
  10. raise_cli/adapters/declarative/schema.py +98 -0
  11. raise_cli/adapters/filesystem.py +299 -0
  12. raise_cli/adapters/mcp_bridge.py +10 -0
  13. raise_cli/adapters/mcp_confluence.py +246 -0
  14. raise_cli/adapters/mcp_jira.py +405 -0
  15. raise_cli/adapters/models.py +205 -0
  16. raise_cli/adapters/protocols.py +180 -0
  17. raise_cli/adapters/registry.py +90 -0
  18. raise_cli/adapters/sync.py +149 -0
  19. raise_cli/agents/__init__.py +14 -0
  20. raise_cli/agents/antigravity.yaml +8 -0
  21. raise_cli/agents/claude.yaml +8 -0
  22. raise_cli/agents/copilot.yaml +8 -0
  23. raise_cli/agents/copilot_plugin.py +124 -0
  24. raise_cli/agents/cursor.yaml +7 -0
  25. raise_cli/agents/roo.yaml +8 -0
  26. raise_cli/agents/windsurf.yaml +8 -0
  27. raise_cli/artifacts/__init__.py +30 -0
  28. raise_cli/artifacts/models.py +43 -0
  29. raise_cli/artifacts/reader.py +55 -0
  30. raise_cli/artifacts/renderer.py +104 -0
  31. raise_cli/artifacts/story_design.py +69 -0
  32. raise_cli/artifacts/writer.py +45 -0
  33. raise_cli/backlog/__init__.py +1 -0
  34. raise_cli/backlog/sync.py +115 -0
  35. raise_cli/cli/__init__.py +3 -0
  36. raise_cli/cli/commands/__init__.py +3 -0
  37. raise_cli/cli/commands/_resolve.py +153 -0
  38. raise_cli/cli/commands/adapters.py +362 -0
  39. raise_cli/cli/commands/artifact.py +137 -0
  40. raise_cli/cli/commands/backlog.py +333 -0
  41. raise_cli/cli/commands/base.py +31 -0
  42. raise_cli/cli/commands/discover.py +551 -0
  43. raise_cli/cli/commands/docs.py +130 -0
  44. raise_cli/cli/commands/doctor.py +177 -0
  45. raise_cli/cli/commands/gate.py +223 -0
  46. raise_cli/cli/commands/graph.py +1086 -0
  47. raise_cli/cli/commands/info.py +81 -0
  48. raise_cli/cli/commands/init.py +746 -0
  49. raise_cli/cli/commands/journal.py +167 -0
  50. raise_cli/cli/commands/mcp.py +524 -0
  51. raise_cli/cli/commands/memory.py +467 -0
  52. raise_cli/cli/commands/pattern.py +348 -0
  53. raise_cli/cli/commands/profile.py +59 -0
  54. raise_cli/cli/commands/publish.py +80 -0
  55. raise_cli/cli/commands/release.py +338 -0
  56. raise_cli/cli/commands/session.py +528 -0
  57. raise_cli/cli/commands/signal.py +410 -0
  58. raise_cli/cli/commands/skill.py +350 -0
  59. raise_cli/cli/commands/skill_set.py +145 -0
  60. raise_cli/cli/error_handler.py +158 -0
  61. raise_cli/cli/main.py +163 -0
  62. raise_cli/compat.py +66 -0
  63. raise_cli/config/__init__.py +41 -0
  64. raise_cli/config/agent_plugin.py +105 -0
  65. raise_cli/config/agent_registry.py +233 -0
  66. raise_cli/config/agents.py +120 -0
  67. raise_cli/config/ide.py +32 -0
  68. raise_cli/config/paths.py +379 -0
  69. raise_cli/config/settings.py +180 -0
  70. raise_cli/context/__init__.py +42 -0
  71. raise_cli/context/analyzers/__init__.py +16 -0
  72. raise_cli/context/analyzers/models.py +36 -0
  73. raise_cli/context/analyzers/protocol.py +43 -0
  74. raise_cli/context/analyzers/python.py +292 -0
  75. raise_cli/context/builder.py +1569 -0
  76. raise_cli/context/diff.py +213 -0
  77. raise_cli/context/extractors/__init__.py +13 -0
  78. raise_cli/context/extractors/skills.py +121 -0
  79. raise_cli/core/__init__.py +37 -0
  80. raise_cli/core/files.py +66 -0
  81. raise_cli/core/text.py +174 -0
  82. raise_cli/core/tools.py +441 -0
  83. raise_cli/discovery/__init__.py +50 -0
  84. raise_cli/discovery/analyzer.py +691 -0
  85. raise_cli/discovery/drift.py +355 -0
  86. raise_cli/discovery/scanner.py +1687 -0
  87. raise_cli/doctor/__init__.py +4 -0
  88. raise_cli/doctor/checks/__init__.py +1 -0
  89. raise_cli/doctor/checks/environment.py +110 -0
  90. raise_cli/doctor/checks/project.py +238 -0
  91. raise_cli/doctor/fix.py +80 -0
  92. raise_cli/doctor/models.py +56 -0
  93. raise_cli/doctor/protocol.py +43 -0
  94. raise_cli/doctor/registry.py +100 -0
  95. raise_cli/doctor/report.py +141 -0
  96. raise_cli/doctor/runner.py +95 -0
  97. raise_cli/engines/__init__.py +3 -0
  98. raise_cli/exceptions.py +215 -0
  99. raise_cli/gates/__init__.py +19 -0
  100. raise_cli/gates/builtin/__init__.py +1 -0
  101. raise_cli/gates/builtin/coverage.py +52 -0
  102. raise_cli/gates/builtin/lint.py +48 -0
  103. raise_cli/gates/builtin/tests.py +48 -0
  104. raise_cli/gates/builtin/types.py +48 -0
  105. raise_cli/gates/models.py +40 -0
  106. raise_cli/gates/protocol.py +41 -0
  107. raise_cli/gates/registry.py +141 -0
  108. raise_cli/governance/__init__.py +11 -0
  109. raise_cli/governance/extractor.py +412 -0
  110. raise_cli/governance/models.py +134 -0
  111. raise_cli/governance/parsers/__init__.py +35 -0
  112. raise_cli/governance/parsers/_convert.py +38 -0
  113. raise_cli/governance/parsers/adr.py +274 -0
  114. raise_cli/governance/parsers/backlog.py +356 -0
  115. raise_cli/governance/parsers/constitution.py +119 -0
  116. raise_cli/governance/parsers/epic.py +323 -0
  117. raise_cli/governance/parsers/glossary.py +316 -0
  118. raise_cli/governance/parsers/guardrails.py +345 -0
  119. raise_cli/governance/parsers/prd.py +112 -0
  120. raise_cli/governance/parsers/roadmap.py +118 -0
  121. raise_cli/governance/parsers/vision.py +116 -0
  122. raise_cli/graph/__init__.py +1 -0
  123. raise_cli/graph/backends/__init__.py +57 -0
  124. raise_cli/graph/backends/api.py +137 -0
  125. raise_cli/graph/backends/dual.py +139 -0
  126. raise_cli/graph/backends/pending.py +84 -0
  127. raise_cli/handlers/__init__.py +3 -0
  128. raise_cli/hooks/__init__.py +54 -0
  129. raise_cli/hooks/builtin/__init__.py +1 -0
  130. raise_cli/hooks/builtin/backlog.py +216 -0
  131. raise_cli/hooks/builtin/gate_bridge.py +83 -0
  132. raise_cli/hooks/builtin/jira_sync.py +127 -0
  133. raise_cli/hooks/builtin/memory.py +117 -0
  134. raise_cli/hooks/builtin/telemetry.py +72 -0
  135. raise_cli/hooks/emitter.py +184 -0
  136. raise_cli/hooks/events.py +262 -0
  137. raise_cli/hooks/protocol.py +38 -0
  138. raise_cli/hooks/registry.py +117 -0
  139. raise_cli/mcp/__init__.py +33 -0
  140. raise_cli/mcp/bridge.py +218 -0
  141. raise_cli/mcp/models.py +43 -0
  142. raise_cli/mcp/registry.py +77 -0
  143. raise_cli/mcp/schema.py +41 -0
  144. raise_cli/memory/__init__.py +58 -0
  145. raise_cli/memory/loader.py +247 -0
  146. raise_cli/memory/migration.py +241 -0
  147. raise_cli/memory/models.py +169 -0
  148. raise_cli/memory/writer.py +598 -0
  149. raise_cli/onboarding/__init__.py +103 -0
  150. raise_cli/onboarding/bootstrap.py +324 -0
  151. raise_cli/onboarding/claudemd.py +17 -0
  152. raise_cli/onboarding/conventions.py +742 -0
  153. raise_cli/onboarding/detection.py +374 -0
  154. raise_cli/onboarding/governance.py +443 -0
  155. raise_cli/onboarding/instructions.py +672 -0
  156. raise_cli/onboarding/manifest.py +201 -0
  157. raise_cli/onboarding/memory_md.py +399 -0
  158. raise_cli/onboarding/migration.py +207 -0
  159. raise_cli/onboarding/profile.py +624 -0
  160. raise_cli/onboarding/skill_conflict.py +100 -0
  161. raise_cli/onboarding/skill_manifest.py +176 -0
  162. raise_cli/onboarding/skills.py +437 -0
  163. raise_cli/onboarding/workflows.py +101 -0
  164. raise_cli/output/__init__.py +28 -0
  165. raise_cli/output/console.py +394 -0
  166. raise_cli/output/formatters/__init__.py +9 -0
  167. raise_cli/output/formatters/adapters.py +135 -0
  168. raise_cli/output/formatters/discover.py +439 -0
  169. raise_cli/output/formatters/skill.py +298 -0
  170. raise_cli/publish/__init__.py +3 -0
  171. raise_cli/publish/changelog.py +80 -0
  172. raise_cli/publish/check.py +179 -0
  173. raise_cli/publish/version.py +172 -0
  174. raise_cli/rai_base/__init__.py +22 -0
  175. raise_cli/rai_base/framework/__init__.py +7 -0
  176. raise_cli/rai_base/framework/methodology.yaml +233 -0
  177. raise_cli/rai_base/governance/__init__.py +1 -0
  178. raise_cli/rai_base/governance/architecture/__init__.py +1 -0
  179. raise_cli/rai_base/governance/architecture/domain-model.md +20 -0
  180. raise_cli/rai_base/governance/architecture/system-context.md +34 -0
  181. raise_cli/rai_base/governance/architecture/system-design.md +24 -0
  182. raise_cli/rai_base/governance/backlog.md +8 -0
  183. raise_cli/rai_base/governance/guardrails.md +17 -0
  184. raise_cli/rai_base/governance/prd.md +25 -0
  185. raise_cli/rai_base/governance/vision.md +16 -0
  186. raise_cli/rai_base/identity/__init__.py +8 -0
  187. raise_cli/rai_base/identity/core.md +119 -0
  188. raise_cli/rai_base/identity/perspective.md +119 -0
  189. raise_cli/rai_base/memory/__init__.py +7 -0
  190. raise_cli/rai_base/memory/patterns-base.jsonl +55 -0
  191. raise_cli/schemas/__init__.py +3 -0
  192. raise_cli/schemas/journal.py +49 -0
  193. raise_cli/schemas/session_state.py +117 -0
  194. raise_cli/session/__init__.py +5 -0
  195. raise_cli/session/bundle.py +820 -0
  196. raise_cli/session/close.py +268 -0
  197. raise_cli/session/journal.py +119 -0
  198. raise_cli/session/resolver.py +126 -0
  199. raise_cli/session/state.py +187 -0
  200. raise_cli/skills/__init__.py +44 -0
  201. raise_cli/skills/locator.py +141 -0
  202. raise_cli/skills/name_checker.py +199 -0
  203. raise_cli/skills/parser.py +145 -0
  204. raise_cli/skills/scaffold.py +212 -0
  205. raise_cli/skills/schema.py +132 -0
  206. raise_cli/skills/skillsets.py +195 -0
  207. raise_cli/skills/validator.py +197 -0
  208. raise_cli/skills_base/__init__.py +80 -0
  209. raise_cli/skills_base/contract-template.md +60 -0
  210. raise_cli/skills_base/preamble.md +37 -0
  211. raise_cli/skills_base/rai-architecture-review/SKILL.md +137 -0
  212. raise_cli/skills_base/rai-debug/SKILL.md +171 -0
  213. raise_cli/skills_base/rai-discover/SKILL.md +167 -0
  214. raise_cli/skills_base/rai-discover-document/SKILL.md +128 -0
  215. raise_cli/skills_base/rai-discover-scan/SKILL.md +147 -0
  216. raise_cli/skills_base/rai-discover-start/SKILL.md +145 -0
  217. raise_cli/skills_base/rai-discover-validate/SKILL.md +142 -0
  218. raise_cli/skills_base/rai-docs-update/SKILL.md +142 -0
  219. raise_cli/skills_base/rai-doctor/SKILL.md +120 -0
  220. raise_cli/skills_base/rai-epic-close/SKILL.md +165 -0
  221. raise_cli/skills_base/rai-epic-close/templates/retrospective.md +68 -0
  222. raise_cli/skills_base/rai-epic-design/SKILL.md +146 -0
  223. raise_cli/skills_base/rai-epic-design/templates/design.md +24 -0
  224. raise_cli/skills_base/rai-epic-design/templates/scope.md +76 -0
  225. raise_cli/skills_base/rai-epic-plan/SKILL.md +153 -0
  226. raise_cli/skills_base/rai-epic-plan/_references/sequencing-strategies.md +67 -0
  227. raise_cli/skills_base/rai-epic-plan/templates/plan-section.md +49 -0
  228. raise_cli/skills_base/rai-epic-run/SKILL.md +208 -0
  229. raise_cli/skills_base/rai-epic-start/SKILL.md +136 -0
  230. raise_cli/skills_base/rai-epic-start/templates/brief.md +34 -0
  231. raise_cli/skills_base/rai-mcp-add/SKILL.md +176 -0
  232. raise_cli/skills_base/rai-mcp-remove/SKILL.md +120 -0
  233. raise_cli/skills_base/rai-mcp-status/SKILL.md +147 -0
  234. raise_cli/skills_base/rai-problem-shape/SKILL.md +138 -0
  235. raise_cli/skills_base/rai-project-create/SKILL.md +144 -0
  236. raise_cli/skills_base/rai-project-onboard/SKILL.md +162 -0
  237. raise_cli/skills_base/rai-quality-review/SKILL.md +189 -0
  238. raise_cli/skills_base/rai-research/SKILL.md +143 -0
  239. raise_cli/skills_base/rai-research/references/research-prompt-template.md +317 -0
  240. raise_cli/skills_base/rai-session-close/SKILL.md +176 -0
  241. raise_cli/skills_base/rai-session-start/SKILL.md +110 -0
  242. raise_cli/skills_base/rai-story-close/SKILL.md +198 -0
  243. raise_cli/skills_base/rai-story-design/SKILL.md +203 -0
  244. raise_cli/skills_base/rai-story-design/references/tech-design-story-v2.md +293 -0
  245. raise_cli/skills_base/rai-story-implement/SKILL.md +115 -0
  246. raise_cli/skills_base/rai-story-plan/SKILL.md +135 -0
  247. raise_cli/skills_base/rai-story-review/SKILL.md +178 -0
  248. raise_cli/skills_base/rai-story-run/SKILL.md +282 -0
  249. raise_cli/skills_base/rai-story-start/SKILL.md +166 -0
  250. raise_cli/skills_base/rai-story-start/templates/story.md +38 -0
  251. raise_cli/skills_base/rai-welcome/SKILL.md +134 -0
  252. raise_cli/telemetry/__init__.py +42 -0
  253. raise_cli/telemetry/schemas.py +285 -0
  254. raise_cli/telemetry/writer.py +217 -0
  255. raise_cli/tier/__init__.py +0 -0
  256. raise_cli/tier/context.py +134 -0
  257. raise_cli/viz/__init__.py +7 -0
  258. raise_cli/viz/generator.py +406 -0
  259. raise_cli-2.2.1.dist-info/METADATA +433 -0
  260. raise_cli-2.2.1.dist-info/RECORD +264 -0
  261. raise_cli-2.2.1.dist-info/WHEEL +4 -0
  262. raise_cli-2.2.1.dist-info/entry_points.txt +40 -0
  263. raise_cli-2.2.1.dist-info/licenses/LICENSE +190 -0
  264. raise_cli-2.2.1.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,356 @@
1
+ """Parser for project backlog files.
2
+
3
+ Extracts Project and Epic concepts from governance/backlog.md.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from pathlib import Path
10
+
11
+ from raise_cli.adapters.models import ArtifactLocator, CoreArtifactType
12
+ from raise_cli.compat import portable_path
13
+ from raise_cli.governance.models import Concept, ConceptType
14
+ from raise_cli.governance.parsers._convert import concept_to_node
15
+ from raise_core.graph.models import GraphNode
16
+
17
+
18
+ def normalize_status(raw: str) -> str:
19
+ """Normalize epic status to standard values.
20
+
21
+ Converts various status representations (emoji, text) to lowercase
22
+ standard values compatible with WorkStatus enum.
23
+
24
+ Args:
25
+ raw: Raw status string from backlog table.
26
+
27
+ Returns:
28
+ Normalized status: 'complete', 'draft', 'deferred', 'in_progress', or 'pending'.
29
+
30
+ Examples:
31
+ >>> normalize_status("✅ Complete")
32
+ 'complete'
33
+ >>> normalize_status("📋 DRAFT")
34
+ 'draft'
35
+ >>> normalize_status("Deferred")
36
+ 'deferred'
37
+ >>> normalize_status("→ Replaced by E9")
38
+ 'deferred'
39
+ """
40
+ raw_lower = raw.lower().strip()
41
+ if "complete" in raw_lower or "✅" in raw:
42
+ return "complete"
43
+ if "draft" in raw_lower or "📋" in raw:
44
+ return "draft"
45
+ if "deferred" in raw_lower or "replaced" in raw_lower or "→" in raw:
46
+ return "deferred"
47
+ if "progress" in raw_lower:
48
+ return "in_progress"
49
+ if "planning" in raw_lower:
50
+ return "planning"
51
+ return "pending"
52
+
53
+
54
+ def _extract_current_focus(text: str) -> str | None:
55
+ """Extract current epic focus from backlog content.
56
+
57
+ Looks for patterns like:
58
+ - **F&F Scope (Feb 9):** E8 → E7 → E9
59
+ - Epic: E8
60
+
61
+ Args:
62
+ text: Full backlog file content.
63
+
64
+ Returns:
65
+ Epic ID (e.g., 'E8') if found, None otherwise.
66
+ """
67
+ # Try F&F Scope pattern first
68
+ match = re.search(r"\*\*F&F Scope[^:]*:\*\*\s*(E\d+)", text)
69
+ if match:
70
+ return match.group(1)
71
+
72
+ # Try explicit Epic: pattern
73
+ match = re.search(r"^Epic:\s*(E\d+)", text, re.MULTILINE)
74
+ if match:
75
+ return match.group(1)
76
+
77
+ # Try "P0 (next)" pattern in table
78
+ match = re.search(
79
+ r"\|\s*(E\d+)\s*\|[^|]*\|[^|]*\|[^|]*\|\s*\*\*P0\s*\(next\)", text
80
+ )
81
+ if match:
82
+ return match.group(1)
83
+
84
+ return None
85
+
86
+
87
+ def _extract_target_date(text: str) -> str | None:
88
+ """Extract target date from backlog content.
89
+
90
+ Looks for patterns like:
91
+ - **F&F Scope (Feb 9):**
92
+ - Target: 2026-02-09
93
+
94
+ Args:
95
+ text: Full backlog file content.
96
+
97
+ Returns:
98
+ Date string if found, None otherwise.
99
+ """
100
+ # Try F&F Scope with date
101
+ match = re.search(r"\*\*F&F Scope\s*\(([^)]+)\)", text)
102
+ if match:
103
+ return match.group(1)
104
+
105
+ # Try explicit Target: pattern
106
+ match = re.search(r"Target:\s*(\d{4}-\d{2}-\d{2})", text)
107
+ if match:
108
+ return match.group(1)
109
+
110
+ return None
111
+
112
+
113
+ def extract_project(
114
+ file_path: Path, project_root: Path | None = None
115
+ ) -> Concept | None:
116
+ """Extract Project concept from backlog.md file.
117
+
118
+ Parses the backlog file header to extract project metadata including
119
+ name, status, current focus, and target date.
120
+
121
+ Args:
122
+ file_path: Path to backlog.md file.
123
+ project_root: Project root for relative path calculation.
124
+ If None, uses file_path.parent.parent.
125
+
126
+ Returns:
127
+ Project Concept if successfully parsed, None if file doesn't exist
128
+ or can't be parsed.
129
+
130
+ Examples:
131
+ >>> from pathlib import Path
132
+ >>> backlog = Path("governance/backlog.md")
133
+ >>> project = extract_project(backlog)
134
+ >>> project.id
135
+ 'project-raise-cli'
136
+ >>> project.metadata["current_epic"]
137
+ 'E8'
138
+ """
139
+ if not file_path.exists():
140
+ return None
141
+
142
+ if project_root is None:
143
+ # governance/backlog.md -> project root is 2 levels up
144
+ project_root = file_path.parent.parent
145
+
146
+ text = file_path.read_text(encoding="utf-8")
147
+ lines = text.split("\n")
148
+
149
+ # Extract project name from H1: # Backlog: {name}
150
+ project_name = None
151
+ header_line = 1
152
+ for i, line in enumerate(lines, 1):
153
+ match = re.match(r"^# Backlog:\s*(.+)$", line)
154
+ if match:
155
+ project_name = match.group(1).strip()
156
+ header_line = i
157
+ break
158
+
159
+ # Fallback: extract from path
160
+ if not project_name:
161
+ project_name = file_path.parent.name
162
+
163
+ # Extract metadata
164
+ current_epic = _extract_current_focus(text)
165
+ target_date = _extract_target_date(text)
166
+
167
+ # Count epics in table (both E{N} and [RAISE-XXX](url) formats)
168
+ epic_count = len(
169
+ re.findall(
170
+ r"^\|\s*(?:E\d+|\[[A-Z]+-\d+\])\s*(?:\([^)]+\)\s*)?\|", text, re.MULTILINE
171
+ )
172
+ )
173
+
174
+ # Calculate relative file path
175
+ try:
176
+ relative_path = portable_path(file_path, project_root)
177
+ except ValueError:
178
+ relative_path = file_path.name
179
+
180
+ # Extract frontmatter for status
181
+ status = "active"
182
+ status_match = re.search(r">\s*\*\*Status\*\*:\s*(\w+)", text)
183
+ if status_match:
184
+ status = status_match.group(1).lower()
185
+
186
+ # Build content summary
187
+ content = f"Project backlog for {project_name}"
188
+ if current_epic:
189
+ content += f". Current focus: {current_epic}"
190
+ if target_date:
191
+ content += f". Target: {target_date}"
192
+
193
+ return Concept(
194
+ id=f"project-{project_name}",
195
+ type=ConceptType.PROJECT,
196
+ file=relative_path,
197
+ section=f"Backlog: {project_name}",
198
+ lines=(header_line, min(header_line + 10, len(lines))),
199
+ content=content,
200
+ metadata={
201
+ "name": project_name,
202
+ "status": status,
203
+ "current_epic": current_epic,
204
+ "target_date": target_date,
205
+ "epic_count": epic_count,
206
+ },
207
+ )
208
+
209
+
210
+ def extract_epics(file_path: Path, project_root: Path | None = None) -> list[Concept]:
211
+ """Extract Epic concepts from backlog.md Epics Overview table.
212
+
213
+ Parses the "Epics Overview" table to extract epic metadata. This extracts
214
+ the epic index only - full epic details come from epic scope docs (F8.2).
215
+
216
+ Args:
217
+ file_path: Path to backlog.md file.
218
+ project_root: Project root for relative path calculation.
219
+ If None, uses file_path.parent.parent.parent.parent.
220
+
221
+ Returns:
222
+ List of Epic Concepts extracted from the table. Returns empty list
223
+ if file doesn't exist or no epics found.
224
+
225
+ Examples:
226
+ >>> from pathlib import Path
227
+ >>> backlog = Path("governance/backlog.md")
228
+ >>> epics = extract_epics(backlog)
229
+ >>> len(epics)
230
+ 9
231
+ >>> epics[0].metadata["epic_id"]
232
+ 'E1'
233
+ """
234
+ if not file_path.exists():
235
+ return []
236
+
237
+ if project_root is None:
238
+ project_root = file_path.parent.parent
239
+
240
+ text = file_path.read_text(encoding="utf-8")
241
+ lines = text.split("\n")
242
+
243
+ # Extract project name from H1: # Backlog: {name}
244
+ project_name = file_path.parent.name # Fallback
245
+ for line in lines:
246
+ h1_match = re.match(r"^# Backlog:\s*(.+)$", line)
247
+ if h1_match:
248
+ project_name = h1_match.group(1).strip()
249
+ break
250
+
251
+ # Calculate relative file path
252
+ try:
253
+ relative_path = portable_path(file_path, project_root)
254
+ except ValueError:
255
+ relative_path = file_path.name
256
+
257
+ concepts: list[Concept] = []
258
+
259
+ # Parse epic table rows
260
+ # Supports two ID formats:
261
+ # Simple: | E1 | **Name** | Status | ...
262
+ # Jira link: | [RAISE-275](url) | **Name** | Status | ...
263
+ epic_pattern = re.compile(
264
+ r"^\|\s*"
265
+ r"(?:\[([A-Z]+-\d+)\]\([^)]+\)|(E\d+))" # Jira link OR simple ID
266
+ r"\s*\|"
267
+ r"\s*\*?\*?([^|*]+?)\*?\*?\s*\|" # Epic name (with optional bold)
268
+ r"\s*([^|]+?)\s*\|" # Status
269
+ r"\s*([^|]*?)\s*\|" # Scope doc (optional)
270
+ r"\s*([^|]*?)\s*\|" # Priority (optional)
271
+ )
272
+
273
+ for i, line in enumerate(lines, 1):
274
+ match = epic_pattern.match(line)
275
+ if match:
276
+ # Group 1 = Jira link ID (e.g., "RAISE-275"), Group 2 = simple ID (e.g., "E1")
277
+ epic_id = (match.group(1) or match.group(2)).strip()
278
+
279
+ # Yield to EpicScopeParser when a scope doc exists for this epic.
280
+ # The scope doc is the authoritative source (richer data); the backlog
281
+ # row is only an index entry. Without this check, both BacklogParser
282
+ # and EpicScopeParser produce the same node ID, causing duplicate
283
+ # warnings in `rai graph build`.
284
+ # Only applies to local epic IDs (E{N}), never to Jira keys (RAISE-275).
285
+ e_match = re.match(r"^E0*(\d+)$", epic_id, re.IGNORECASE)
286
+ if e_match:
287
+ canon = int(e_match.group(1)) # E08 -> 8, E1 -> 1
288
+ scope_hit = any(
289
+ re.search(r"^e0*(\d+)", d.name, re.IGNORECASE)
290
+ and int(re.search(r"^e0*(\d+)", d.name, re.IGNORECASE).group(1)) == canon # type: ignore[union-attr]
291
+ for d in project_root.glob("work/epics/e*")
292
+ if (d / "scope.md").exists()
293
+ )
294
+ if scope_hit:
295
+ continue
296
+
297
+ name = match.group(3).strip()
298
+ raw_status = match.group(4).strip()
299
+ scope_doc = match.group(5).strip()
300
+ priority = match.group(6).strip()
301
+
302
+ # Clean up scope doc (remove backticks)
303
+ scope_doc = scope_doc.strip("`").strip()
304
+ if scope_doc == "—" or scope_doc == "-" or not scope_doc:
305
+ scope_doc = None
306
+
307
+ # Clean up priority
308
+ priority = priority.strip("*").strip()
309
+ if priority == "—" or priority == "-" or not priority:
310
+ priority = None
311
+
312
+ # Normalize status
313
+ status = normalize_status(raw_status)
314
+
315
+ # Build content
316
+ content = f"{name} - {raw_status.strip()}"
317
+
318
+ concept = Concept(
319
+ id=f"epic-{epic_id.lower()}",
320
+ type=ConceptType.EPIC,
321
+ file=relative_path,
322
+ section=f"{epic_id}: {name}",
323
+ lines=(i, i),
324
+ content=content,
325
+ metadata={
326
+ "epic_id": epic_id,
327
+ "name": name,
328
+ "status": status,
329
+ "scope_doc": scope_doc,
330
+ "priority": priority,
331
+ "project_id": project_name,
332
+ },
333
+ )
334
+ concepts.append(concept)
335
+
336
+ return concepts
337
+
338
+
339
+ class BacklogParser:
340
+ """GovernanceParser wrapper for Backlog (project + epics)."""
341
+
342
+ def can_parse(self, locator: ArtifactLocator) -> bool:
343
+ """Match Backlog artifact type."""
344
+ return locator.artifact_type == CoreArtifactType.BACKLOG
345
+
346
+ def parse(self, locator: ArtifactLocator) -> list[GraphNode]:
347
+ """Parse Backlog file into GraphNode list (project + epics)."""
348
+ root = Path(locator.metadata["project_root"])
349
+ path = root / locator.path
350
+ nodes: list[GraphNode] = []
351
+ project = extract_project(path, root)
352
+ if project:
353
+ nodes.append(concept_to_node(project))
354
+ epics = extract_epics(path, root)
355
+ nodes.extend(concept_to_node(e) for e in epics)
356
+ return nodes
@@ -0,0 +1,119 @@
1
+ """Parser for Constitution documents.
2
+
3
+ Extracts principles in §N format from Constitution markdown files.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from raise_cli.adapters.models import ArtifactLocator, CoreArtifactType
13
+ from raise_cli.compat import portable_path
14
+ from raise_cli.core.text import sanitize_id
15
+ from raise_cli.governance.models import Concept, ConceptType
16
+ from raise_cli.governance.parsers._convert import concept_to_node
17
+ from raise_core.graph.models import GraphNode
18
+
19
+
20
+ def extract_principles(
21
+ file_path: Path, project_root: Path | None = None
22
+ ) -> list[Concept]:
23
+ """Extract §N principles from Constitution markdown file.
24
+
25
+ Parses Constitution markdown files looking for principle sections with the
26
+ format "### §N. Title" and extracts the principle content along with metadata.
27
+
28
+ Args:
29
+ file_path: Path to Constitution markdown file.
30
+ project_root: Project root for relative path calculation.
31
+ If None, uses file_path.parent.parent.
32
+
33
+ Returns:
34
+ List of Concept objects representing principles.
35
+
36
+ Examples:
37
+ >>> from pathlib import Path
38
+ >>> constitution = Path("framework/reference/constitution.md")
39
+ >>> principles = extract_principles(constitution)
40
+ >>> len(principles)
41
+ 8
42
+ >>> principles[0].type
43
+ <ConceptType.PRINCIPLE: 'principle'>
44
+ """
45
+ if not file_path.exists():
46
+ return []
47
+
48
+ if project_root is None:
49
+ project_root = file_path.parent.parent
50
+
51
+ text = file_path.read_text(encoding="utf-8")
52
+ lines = text.split("\n")
53
+ concepts: list[Concept] = []
54
+
55
+ for i, line in enumerate(lines, 1):
56
+ # Match principle headers: ### §2. Governance as Code
57
+ match = re.match(r"^### §(\d+)\.\s*(.+)$", line)
58
+
59
+ if match:
60
+ principle_num = match.group(1) # "2"
61
+ principle_name = match.group(2).strip() # "Governance as Code"
62
+
63
+ # Extract section content until next ### heading
64
+ content_lines: list[str] = []
65
+ j = i
66
+ while j < len(lines) and not (lines[j].startswith("###") and j > i):
67
+ content_lines.append(lines[j])
68
+ j += 1
69
+
70
+ # Truncate to first ~30 lines or 500 chars
71
+ content = "\n".join(content_lines[:30])
72
+ if len(content) > 500:
73
+ content = content[:500] + "..."
74
+
75
+ # Generate ID from principle name
76
+ principle_id = sanitize_id(principle_name)
77
+
78
+ # Calculate relative file path
79
+ try:
80
+ relative_path = portable_path(file_path, project_root)
81
+ except ValueError:
82
+ relative_path = file_path.name
83
+
84
+ metadata: dict[str, Any] = {
85
+ "principle_number": principle_num,
86
+ "principle_name": principle_name,
87
+ }
88
+
89
+ # Tag core principles as always_on (S15.8)
90
+ if principle_num in {"1", "3", "7"}:
91
+ metadata["always_on"] = True
92
+
93
+ concept = Concept(
94
+ id=f"principle-{principle_id}",
95
+ type=ConceptType.PRINCIPLE,
96
+ file=relative_path,
97
+ section=f"§{principle_num}. {principle_name}",
98
+ lines=(i, min(i + len(content_lines[:30]), len(lines))),
99
+ content=content.strip(),
100
+ metadata=metadata,
101
+ )
102
+ concepts.append(concept)
103
+
104
+ return concepts
105
+
106
+
107
+ class ConstitutionParser:
108
+ """GovernanceParser wrapper for Constitution principles."""
109
+
110
+ def can_parse(self, locator: ArtifactLocator) -> bool:
111
+ """Match Constitution artifact type."""
112
+ return locator.artifact_type == CoreArtifactType.CONSTITUTION
113
+
114
+ def parse(self, locator: ArtifactLocator) -> list[GraphNode]:
115
+ """Parse Constitution file into GraphNode list."""
116
+ root = Path(locator.metadata["project_root"])
117
+ path = root / locator.path
118
+ concepts = extract_principles(path, root)
119
+ return [concept_to_node(c) for c in concepts]