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,1569 @@
1
+ """Unified graph builder for context integration.
2
+
3
+ This module provides the GraphBuilder class that merges governance,
4
+ memory, work, and skills into a single Graph for context queries.
5
+
6
+ Architecture: ADR-019 Unified Context Graph Architecture
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any, cast
16
+
17
+ from raise_cli.compat import portable_path
18
+ from raise_cli.config.agents import AgentConfig, get_agent_config
19
+ from raise_cli.config.paths import get_global_rai_dir, get_memory_dir, get_personal_dir
20
+ from raise_cli.context.extractors.skills import extract_all_skills
21
+ from raise_cli.core.text import STOPWORDS
22
+ from raise_cli.memory.models import MemoryScope
23
+ from raise_core.graph.engine import Graph
24
+ from raise_core.graph.models import GraphEdge, GraphNode
25
+
26
+ if TYPE_CHECKING:
27
+ from raise_cli.governance.extractor import GovernanceExtractor
28
+ from raise_cli.governance.models import Concept
29
+
30
+
31
+ class GraphBuilder:
32
+ """Builds unified context graph from all sources.
33
+
34
+ Merges governance documents, memory JSONL files, work tracking,
35
+ and skill metadata into a single queryable graph.
36
+
37
+ Attributes:
38
+ project_root: Root directory for the project.
39
+
40
+ Examples:
41
+ >>> builder = GraphBuilder(Path("."))
42
+ >>> graph = builder.build()
43
+ >>> graph.node_count
44
+ 50
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ project_root: Path | None = None,
50
+ *,
51
+ agent_config: AgentConfig | None = None,
52
+ ) -> None:
53
+ """Initialize builder with project root.
54
+
55
+ Args:
56
+ project_root: Root directory for the project. Defaults to cwd.
57
+ agent_config: Agent configuration. Defaults to Claude.
58
+ """
59
+ self.project_root = project_root or Path.cwd()
60
+ self.ide_config = agent_config or get_agent_config()
61
+
62
+ def build(self) -> Graph:
63
+ """Build unified graph from all sources.
64
+
65
+ Loads concepts from governance, memory, work, skills, and components,
66
+ then builds a Graph with all nodes. After all nodes are loaded,
67
+ extracts structural nodes (bounded contexts, layers) and their edges.
68
+
69
+ Returns:
70
+ Graph containing all concepts.
71
+ """
72
+ graph = Graph()
73
+
74
+ # Load all sources
75
+ all_nodes: list[GraphNode] = []
76
+ all_nodes.extend(self.load_governance())
77
+ all_nodes.extend(self.load_memory())
78
+ all_nodes.extend(self.load_skills())
79
+ all_nodes.extend(self.load_components())
80
+ all_nodes.extend(self.load_artifacts())
81
+ all_nodes.extend(self.load_architecture())
82
+ all_nodes.extend(self.load_identity())
83
+
84
+ # Enrich module nodes with real code analysis (S16.1)
85
+ # Must run before add_concept() so graph gets enriched copies
86
+ self.load_code_structure(all_nodes)
87
+
88
+ # Warn on duplicate node IDs before adding (silent overwrites lose data)
89
+ seen_ids: dict[str, str] = {}
90
+ for node in all_nodes:
91
+ if node.id in seen_ids:
92
+ logging.warning(
93
+ "Duplicate node ID '%s' — '%s' will overwrite '%s'",
94
+ node.id,
95
+ node.source_file or "unknown",
96
+ seen_ids[node.id],
97
+ )
98
+ seen_ids[node.id] = node.source_file or "unknown"
99
+
100
+ # Add nodes to graph
101
+ for node in all_nodes:
102
+ graph.add_concept(node)
103
+
104
+ # Extract structural nodes and edges (E15 — bounded contexts, layers)
105
+ # Runs after all nodes loaded so module nodes exist for edge safety
106
+ node_by_id: dict[str, GraphNode] = {n.id: n for n in all_nodes}
107
+ structural_nodes: list[GraphNode] = []
108
+ structural_edges: list[GraphEdge] = []
109
+
110
+ bc_nodes, bc_edges = self._extract_bounded_contexts(all_nodes, node_by_id)
111
+ structural_nodes.extend(bc_nodes)
112
+ structural_edges.extend(bc_edges)
113
+
114
+ lyr_nodes, lyr_edges = self._extract_layers(all_nodes, node_by_id)
115
+ structural_nodes.extend(lyr_nodes)
116
+ structural_edges.extend(lyr_edges)
117
+
118
+ for node in structural_nodes:
119
+ graph.add_concept(node)
120
+ all_nodes.extend(structural_nodes)
121
+
122
+ # Update node_by_id with structural nodes for constraint edge safety
123
+ node_by_id.update({n.id: n for n in structural_nodes})
124
+
125
+ # Extract constraint edges (S15.3 — guardrail → BC/layer)
126
+ constraint_edges = self._extract_constraints(all_nodes, node_by_id)
127
+ structural_edges.extend(constraint_edges)
128
+
129
+ # Infer and add relationships
130
+ edges = self.infer_relationships(all_nodes)
131
+ for edge in edges:
132
+ graph.add_relationship(edge)
133
+
134
+ # Add structural edges (explicit, not inferred)
135
+ for edge in structural_edges:
136
+ graph.add_relationship(edge)
137
+
138
+ return graph
139
+
140
+ def load_governance(self) -> list[GraphNode]:
141
+ """Load concepts from governance documents.
142
+
143
+ Uses GovernanceExtractor with registry-discovered parsers.
144
+ extract_all() returns list[GraphNode] directly — no conversion needed.
145
+
146
+ Returns:
147
+ List of GraphNode for governance concepts.
148
+ """
149
+ try:
150
+ extractor = self._get_governance_extractor()
151
+ return extractor.extract_all()
152
+ except Exception:
153
+ # Graceful degradation if governance extraction fails
154
+ return []
155
+
156
+ def load_memory(self) -> list[GraphNode]:
157
+ """Load concepts from memory JSONL files across all tiers.
158
+
159
+ Loads from three directories with scope tracking:
160
+ - Global (~/.rai/): Universal patterns and calibration
161
+ - Project (.raise/rai/memory/): Shared project patterns
162
+ - Personal (.raise/rai/personal/): Developer-specific data
163
+
164
+ Sessions are only loaded from personal directory (developer-specific).
165
+
166
+ Returns:
167
+ List of GraphNode for memory concepts with scope metadata.
168
+ """
169
+ nodes: list[GraphNode] = []
170
+
171
+ # 1. Load from global directory (~/.rai/)
172
+ global_dir = get_global_rai_dir()
173
+ if global_dir.exists():
174
+ nodes.extend(
175
+ self._load_memory_from_dir(
176
+ global_dir, MemoryScope.GLOBAL, sessions=False
177
+ )
178
+ )
179
+
180
+ # 2. Load from project directory (.raise/rai/memory/)
181
+ project_dir = get_memory_dir(self.project_root)
182
+ if project_dir.exists():
183
+ nodes.extend(
184
+ self._load_memory_from_dir(
185
+ project_dir, MemoryScope.PROJECT, sessions=False
186
+ )
187
+ )
188
+
189
+ # 3. Load from personal directory (.raise/rai/personal/)
190
+ personal_dir = get_personal_dir(self.project_root)
191
+ if personal_dir.exists():
192
+ nodes.extend(
193
+ self._load_memory_from_dir(
194
+ personal_dir, MemoryScope.PERSONAL, sessions=True
195
+ )
196
+ )
197
+
198
+ # Apply precedence: personal > project > global
199
+ return self._deduplicate_by_precedence(nodes)
200
+
201
+ def _load_memory_from_dir(
202
+ self,
203
+ memory_dir: Path,
204
+ scope: MemoryScope,
205
+ sessions: bool = True,
206
+ ) -> list[GraphNode]:
207
+ """Load memory concepts from a single directory with scope.
208
+
209
+ Args:
210
+ memory_dir: Directory containing JSONL files.
211
+ scope: Scope to assign to loaded concepts.
212
+ sessions: Whether to load sessions from this directory.
213
+
214
+ Returns:
215
+ List of GraphNode with scope in metadata.
216
+ """
217
+ nodes: list[GraphNode] = []
218
+
219
+ # Load patterns
220
+ patterns_file = memory_dir / "patterns.jsonl"
221
+ if patterns_file.exists():
222
+ nodes.extend(self._load_jsonl(patterns_file, "pattern", scope))
223
+
224
+ # Load calibration
225
+ calibration_file = memory_dir / "calibration.jsonl"
226
+ if calibration_file.exists():
227
+ nodes.extend(self._load_jsonl(calibration_file, "calibration", scope))
228
+
229
+ # Load sessions (only if requested)
230
+ if sessions:
231
+ sessions_file = memory_dir / "sessions" / "index.jsonl"
232
+ if sessions_file.exists():
233
+ nodes.extend(self._load_jsonl(sessions_file, "session", scope))
234
+
235
+ return nodes
236
+
237
+ def _deduplicate_by_precedence(self, nodes: list[GraphNode]) -> list[GraphNode]:
238
+ """Deduplicate nodes by ID using scope precedence.
239
+
240
+ When the same ID appears in multiple tiers, keep only the
241
+ highest-precedence version: personal > project > global.
242
+
243
+ Args:
244
+ nodes: List of nodes potentially with duplicate IDs.
245
+
246
+ Returns:
247
+ Deduplicated list with highest-precedence version of each ID.
248
+ """
249
+ # Precedence order: higher number = higher priority
250
+ scope_priority = {
251
+ MemoryScope.GLOBAL.value: 1,
252
+ MemoryScope.PROJECT.value: 2,
253
+ MemoryScope.PERSONAL.value: 3,
254
+ }
255
+
256
+ # Track best node for each ID
257
+ best_by_id: dict[str, GraphNode] = {}
258
+
259
+ for node in nodes:
260
+ node_scope = node.metadata.get("scope", MemoryScope.PROJECT.value)
261
+ node_priority = scope_priority.get(node_scope, 0)
262
+
263
+ if node.id not in best_by_id:
264
+ best_by_id[node.id] = node
265
+ else:
266
+ existing_scope = best_by_id[node.id].metadata.get(
267
+ "scope", MemoryScope.PROJECT.value
268
+ )
269
+ existing_priority = scope_priority.get(existing_scope, 0)
270
+
271
+ if node_priority > existing_priority:
272
+ best_by_id[node.id] = node
273
+
274
+ return list(best_by_id.values())
275
+
276
+ def load_skills(self) -> list[GraphNode]:
277
+ """Load concepts from skill YAML frontmatter.
278
+
279
+ Parses SKILL.md files in the IDE's skill directory.
280
+
281
+ Returns:
282
+ List of GraphNode for skill concepts.
283
+ """
284
+ raw_skills_dir = self.ide_config.skills_dir or ".claude/skills"
285
+ skills_dir = self.project_root / raw_skills_dir
286
+ return extract_all_skills(skills_dir)
287
+
288
+ def load_components(self) -> list[GraphNode]:
289
+ """Load discovered components from validated JSON.
290
+
291
+ Reads components-validated.json from work/discovery directory.
292
+
293
+ Returns:
294
+ List of GraphNode for component concepts.
295
+ """
296
+ validated_file = (
297
+ self.project_root / "work" / "discovery" / "components-validated.json"
298
+ )
299
+ if not validated_file.exists():
300
+ return []
301
+
302
+ try:
303
+ raw: Any = json.loads(
304
+ validated_file.read_text(encoding="utf-8")
305
+ )
306
+ # Accept both {"components": [...]} wrapper and bare [...] array
307
+ if isinstance(raw, list):
308
+ components_list: list[dict[str, Any]] = raw # type: ignore[assignment]
309
+ else:
310
+ components_list = raw.get("components", [])
311
+
312
+ nodes: list[GraphNode] = []
313
+ for comp in components_list:
314
+ node = GraphNode(
315
+ id=comp.get("id", ""),
316
+ type="component",
317
+ content=comp.get("content", ""),
318
+ source_file=comp.get("source_file"),
319
+ created=comp.get("created", datetime.now(tz=UTC).isoformat()),
320
+ metadata=comp.get("metadata", {}),
321
+ )
322
+ nodes.append(node)
323
+
324
+ return nodes
325
+ except (json.JSONDecodeError, KeyError):
326
+ return []
327
+
328
+ def load_artifacts(self) -> list[GraphNode]:
329
+ """Load typed skill artifacts from ``.raise/artifacts/``.
330
+
331
+ Each artifact becomes a GraphNode with type ``artifact``.
332
+
333
+ Returns:
334
+ List of GraphNode for artifact concepts.
335
+ """
336
+ from raise_cli.artifacts.reader import read_all_artifacts
337
+ from raise_cli.artifacts.writer import (
338
+ _artifact_filename, # pyright: ignore[reportPrivateUsage]
339
+ )
340
+
341
+ artifacts_dir = self.project_root / ".raise" / "artifacts"
342
+ artifacts = read_all_artifacts(artifacts_dir)
343
+
344
+ nodes: list[GraphNode] = []
345
+ for artifact in artifacts:
346
+ work_id = (artifact.story or artifact.epic or "unknown").lower()
347
+ node_id = f"artifact-{work_id}-{artifact.artifact_type.value}"
348
+
349
+ # Extract summary from content (typed or dict)
350
+ if isinstance(artifact.content, dict): # pyright: ignore[reportUnnecessaryIsInstance]
351
+ summary = artifact.content.get("summary", str(artifact.content))
352
+ else:
353
+ summary = getattr(artifact.content, "summary", str(artifact.content))
354
+
355
+ filename = _artifact_filename(artifact)
356
+
357
+ node = GraphNode(
358
+ id=node_id,
359
+ type="artifact",
360
+ content=summary,
361
+ source_file=f".raise/artifacts/{filename}",
362
+ created=artifact.created.isoformat(),
363
+ metadata={
364
+ "artifact_type": artifact.artifact_type.value,
365
+ "skill": artifact.skill,
366
+ "story": artifact.story,
367
+ "epic": artifact.epic,
368
+ "version": artifact.version,
369
+ },
370
+ )
371
+ nodes.append(node)
372
+
373
+ return nodes
374
+
375
+ def load_architecture(self) -> list[GraphNode]:
376
+ """Load architecture nodes from documentation.
377
+
378
+ Scans both governance/architecture/*.md (architecture docs) and
379
+ governance/architecture/modules/*.md (module docs). Type-dispatches
380
+ by frontmatter ``type`` field.
381
+
382
+ Returns:
383
+ List of GraphNode for architecture and module concepts.
384
+ """
385
+ arch_dir = self.project_root / "governance" / "architecture"
386
+ if not arch_dir.exists():
387
+ return []
388
+
389
+ nodes: list[GraphNode] = []
390
+
391
+ # Scan parent directory for architecture docs
392
+ for md_file in sorted(arch_dir.glob("*.md")):
393
+ node = self._parse_architecture_doc(md_file)
394
+ if node:
395
+ nodes.append(node)
396
+
397
+ # Scan modules subdirectory for module docs
398
+ modules_dir = arch_dir / "modules"
399
+ if modules_dir.exists():
400
+ for md_file in sorted(modules_dir.glob("*.md")):
401
+ node = self._parse_architecture_doc(md_file)
402
+ if node:
403
+ nodes.append(node)
404
+
405
+ return nodes
406
+
407
+ def load_identity(self) -> list[GraphNode]:
408
+ """Load Rai identity values and boundaries from core.md.
409
+
410
+ Extracts values (### N. Title) and boundaries (### I Will / ### I Won't)
411
+ as principle nodes tagged with always_on=True.
412
+
413
+ Returns:
414
+ List of GraphNode for identity concepts.
415
+ """
416
+ identity_file = self.project_root / ".raise" / "rai" / "identity" / "core.md"
417
+ if not identity_file.exists():
418
+ return []
419
+
420
+ try:
421
+ text = identity_file.read_text(encoding="utf-8")
422
+ except OSError:
423
+ return []
424
+
425
+ try:
426
+ source_file = portable_path(identity_file, self.project_root)
427
+ except ValueError:
428
+ source_file = str(identity_file)
429
+
430
+ now = datetime.now(tz=UTC).isoformat()
431
+ nodes: list[GraphNode] = []
432
+
433
+ nodes.extend(self._extract_identity_values(text, source_file, now))
434
+ nodes.extend(self._extract_identity_boundaries(text, source_file, now))
435
+
436
+ return nodes
437
+
438
+ def load_code_structure(self, all_nodes: list[GraphNode]) -> None:
439
+ """Enrich module nodes with real code analysis data.
440
+
441
+ Runs detected analyzers against the project source and merges
442
+ results into existing mod-* node metadata under code_* keys.
443
+ Does not create new nodes — only enriches existing ones.
444
+
445
+ Args:
446
+ all_nodes: All nodes loaded so far (mutated in place).
447
+ """
448
+ from raise_cli.context.analyzers.models import ModuleInfo
449
+ from raise_cli.context.analyzers.python import PythonAnalyzer
450
+
451
+ analyzers = [PythonAnalyzer(src_dir="src/raise_cli")]
452
+
453
+ code_modules: list[ModuleInfo] = []
454
+ for analyzer in analyzers:
455
+ if analyzer.detect(self.project_root):
456
+ code_modules.extend(analyzer.analyze_modules(self.project_root))
457
+
458
+ if not code_modules:
459
+ return
460
+
461
+ # Build lookup: module name → ModuleInfo
462
+ code_by_name: dict[str, ModuleInfo] = {m.name: m for m in code_modules}
463
+
464
+ # Enrich existing mod-* nodes
465
+ for node in all_nodes:
466
+ if node.type != "module":
467
+ continue
468
+
469
+ # Extract module name from node ID (mod-<name> → <name>)
470
+ mod_name = node.id.removeprefix("mod-")
471
+ info = code_by_name.get(mod_name)
472
+ if info is None:
473
+ continue
474
+
475
+ node.metadata["code_imports"] = info.imports
476
+ node.metadata["code_exports"] = info.exports
477
+ node.metadata["code_components"] = info.component_count
478
+
479
+ def _extract_identity_values(
480
+ self, text: str, source_file: str, now: str
481
+ ) -> list[GraphNode]:
482
+ """Extract values from identity core.md.
483
+
484
+ Matches ### N. Title patterns under ## Values section.
485
+
486
+ Args:
487
+ text: Full file content.
488
+ source_file: Relative source path.
489
+ now: ISO timestamp.
490
+
491
+ Returns:
492
+ List of value GraphNodes.
493
+ """
494
+ import re
495
+
496
+ nodes: list[GraphNode] = []
497
+
498
+ # Find values section
499
+ values_match = re.search(r"^## Values\b", text, re.MULTILINE)
500
+ if not values_match:
501
+ return nodes
502
+
503
+ # Find end of values section (next ## heading or EOF)
504
+ next_section = re.search(r"^## ", text[values_match.end() :], re.MULTILINE)
505
+ values_end = (
506
+ values_match.end() + next_section.start() if next_section else len(text)
507
+ )
508
+ values_text = text[values_match.end() : values_end]
509
+
510
+ # Match ### N. Title patterns
511
+ value_pattern = re.compile(r"^### (\d+)\.\s+(.+)$", re.MULTILINE)
512
+ matches = list(value_pattern.finditer(values_text))
513
+
514
+ for i, match in enumerate(matches):
515
+ num = match.group(1)
516
+ title = match.group(2).strip()
517
+
518
+ # Extract first bullet point as description
519
+ start = match.end()
520
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(values_text)
521
+ section_text = values_text[start:end]
522
+
523
+ bullet_match = re.search(r"^- (.+)$", section_text, re.MULTILINE)
524
+ description = bullet_match.group(1).strip() if bullet_match else ""
525
+
526
+ content = f"{title} — {description}" if description else title
527
+
528
+ nodes.append(
529
+ GraphNode(
530
+ id=f"RAI-VAL-{num}",
531
+ type="principle",
532
+ content=content,
533
+ source_file=source_file,
534
+ created=now,
535
+ metadata={
536
+ "always_on": True,
537
+ "identity_type": "value",
538
+ "value_number": num,
539
+ "value_name": title,
540
+ },
541
+ )
542
+ )
543
+
544
+ return nodes
545
+
546
+ def _extract_identity_boundaries(
547
+ self, text: str, source_file: str, now: str
548
+ ) -> list[GraphNode]:
549
+ """Extract boundaries from identity core.md.
550
+
551
+ Matches ### I Will and ### I Won't sections, extracts bullet items.
552
+
553
+ Args:
554
+ text: Full file content.
555
+ source_file: Relative source path.
556
+ now: ISO timestamp.
557
+
558
+ Returns:
559
+ List of boundary GraphNodes.
560
+ """
561
+ import re
562
+
563
+ nodes: list[GraphNode] = []
564
+
565
+ # Find boundaries section
566
+ boundaries_match = re.search(r"^## Boundaries\b", text, re.MULTILINE)
567
+ if not boundaries_match:
568
+ return nodes
569
+
570
+ # Find end of boundaries section (next ## heading or EOF)
571
+ next_section = re.search(r"^## ", text[boundaries_match.end() :], re.MULTILINE)
572
+ boundaries_end = (
573
+ boundaries_match.end() + next_section.start() if next_section else len(text)
574
+ )
575
+ boundaries_text = text[boundaries_match.end() : boundaries_end]
576
+
577
+ # Extract "I Will" bullets
578
+ will_match = re.search(r"^### I Will\b", boundaries_text, re.MULTILINE)
579
+ wont_match = re.search(r"^### I Won't\b", boundaries_text, re.MULTILINE)
580
+
581
+ counter = 1
582
+
583
+ if will_match:
584
+ start = will_match.end()
585
+ end = wont_match.start() if wont_match else len(boundaries_text)
586
+ will_text = boundaries_text[start:end]
587
+
588
+ for bullet in re.finditer(r"^- (.+)$", will_text, re.MULTILINE):
589
+ nodes.append(
590
+ GraphNode(
591
+ id=f"RAI-BND-{counter}",
592
+ type="principle",
593
+ content=bullet.group(1).strip(),
594
+ source_file=source_file,
595
+ created=now,
596
+ metadata={
597
+ "always_on": True,
598
+ "identity_type": "boundary",
599
+ "boundary_kind": "will",
600
+ },
601
+ )
602
+ )
603
+ counter += 1
604
+
605
+ if wont_match:
606
+ start = wont_match.end()
607
+ # Find next ### or end
608
+ next_heading = re.search(r"^### ", boundaries_text[start:], re.MULTILINE)
609
+ end = start + next_heading.start() if next_heading else len(boundaries_text)
610
+ wont_text = boundaries_text[start:end]
611
+
612
+ for bullet in re.finditer(r"^- (.+)$", wont_text, re.MULTILINE):
613
+ nodes.append(
614
+ GraphNode(
615
+ id=f"RAI-BND-{counter}",
616
+ type="principle",
617
+ content=bullet.group(1).strip(),
618
+ source_file=source_file,
619
+ created=now,
620
+ metadata={
621
+ "always_on": True,
622
+ "identity_type": "boundary",
623
+ "boundary_kind": "wont",
624
+ },
625
+ )
626
+ )
627
+ counter += 1
628
+
629
+ return nodes
630
+
631
+ def _parse_architecture_doc(self, file_path: Path) -> GraphNode | None:
632
+ """Parse an architecture doc's YAML frontmatter into a GraphNode.
633
+
634
+ Dispatches by frontmatter ``type`` field to produce the appropriate
635
+ node type (module or architecture).
636
+
637
+ Args:
638
+ file_path: Path to the markdown file.
639
+
640
+ Returns:
641
+ GraphNode if valid frontmatter found, None otherwise.
642
+ """
643
+ try:
644
+ text = file_path.read_text(encoding="utf-8")
645
+ except OSError:
646
+ return None
647
+
648
+ # Extract YAML frontmatter between --- delimiters
649
+ if not text.startswith("---"):
650
+ return None
651
+
652
+ end = text.find("---", 3)
653
+ if end == -1:
654
+ return None
655
+
656
+ frontmatter_text = text[3:end].strip()
657
+
658
+ try:
659
+ import yaml
660
+
661
+ frontmatter_raw: Any = yaml.safe_load(frontmatter_text)
662
+ except Exception:
663
+ return None
664
+
665
+ if not isinstance(frontmatter_raw, dict):
666
+ return None
667
+ frontmatter = cast(dict[str, Any], frontmatter_raw)
668
+
669
+ # Build relative source path
670
+ try:
671
+ source_file = portable_path(file_path, self.project_root)
672
+ except ValueError:
673
+ source_file = str(file_path)
674
+
675
+ doc_type = frontmatter.get("type", "")
676
+
677
+ # Type-dispatch by frontmatter type
678
+ if doc_type == "module":
679
+ return self._parse_module_doc(frontmatter, source_file)
680
+ if doc_type == "architecture_context":
681
+ return self._parse_architecture_context(frontmatter, source_file)
682
+ if doc_type == "architecture_design":
683
+ return self._parse_architecture_design(frontmatter, source_file)
684
+ if doc_type == "architecture_domain_model":
685
+ return self._parse_architecture_domain_model(frontmatter, source_file)
686
+ # Skip architecture_index and unknown types
687
+ return None
688
+
689
+ def _parse_module_doc(
690
+ self, frontmatter: dict[str, Any], source_file: str
691
+ ) -> GraphNode | None:
692
+ """Parse a module-type architecture doc.
693
+
694
+ Args:
695
+ frontmatter: Parsed YAML frontmatter dict.
696
+ source_file: Relative path to the source file.
697
+
698
+ Returns:
699
+ GraphNode with type "module", or None if invalid.
700
+ """
701
+ name = frontmatter.get("name", "")
702
+ if not name:
703
+ return None
704
+
705
+ metadata: dict[str, Any] = {}
706
+ for key in (
707
+ "depends_on",
708
+ "depended_by",
709
+ "entry_points",
710
+ "public_api",
711
+ "components",
712
+ "constraints",
713
+ "status",
714
+ ):
715
+ if key in frontmatter:
716
+ metadata[key] = frontmatter[key]
717
+
718
+ return GraphNode(
719
+ id=f"mod-{name}",
720
+ type="module",
721
+ content=frontmatter.get("purpose", ""),
722
+ source_file=source_file,
723
+ created=frontmatter.get("last_validated", datetime.now(tz=UTC).isoformat()),
724
+ metadata=metadata,
725
+ )
726
+
727
+ def _parse_architecture_context(
728
+ self, frontmatter: dict[str, Any], source_file: str
729
+ ) -> GraphNode:
730
+ """Parse an architecture_context doc (system-context.md).
731
+
732
+ Synthesizes content from tech stack and external dependencies.
733
+
734
+ Args:
735
+ frontmatter: Parsed YAML frontmatter dict.
736
+ source_file: Relative path to the source file.
737
+
738
+ Returns:
739
+ GraphNode with type "architecture".
740
+ """
741
+ # Synthesize content from tech stack
742
+ tech_stack: dict[str, str] = frontmatter.get("tech_stack", {})
743
+ tech_parts = [f"{k}: {v}" for k, v in tech_stack.items()]
744
+ tech_summary = ", ".join(tech_parts) if tech_parts else "No tech stack defined"
745
+
746
+ ext_deps: list[str] = frontmatter.get("external_dependencies", [])
747
+ deps_summary = ", ".join(ext_deps) if ext_deps else "none"
748
+
749
+ content = (
750
+ f"System context: {tech_summary}. External dependencies: {deps_summary}."
751
+ )
752
+
753
+ # Store all structured data in metadata
754
+ metadata: dict[str, Any] = {"arch_type": "architecture_context"}
755
+ for key in (
756
+ "tech_stack",
757
+ "external_dependencies",
758
+ "users",
759
+ "governed_by",
760
+ "project",
761
+ "version",
762
+ "status",
763
+ ):
764
+ if key in frontmatter:
765
+ metadata[key] = frontmatter[key]
766
+
767
+ return GraphNode(
768
+ id="arch-context",
769
+ type="architecture",
770
+ content=content,
771
+ source_file=source_file,
772
+ created=datetime.now(tz=UTC).isoformat(),
773
+ metadata=metadata,
774
+ )
775
+
776
+ def _parse_architecture_design(
777
+ self, frontmatter: dict[str, Any], source_file: str
778
+ ) -> GraphNode:
779
+ """Parse an architecture_design doc (system-design.md).
780
+
781
+ Synthesizes content from layers and their module assignments.
782
+
783
+ Args:
784
+ frontmatter: Parsed YAML frontmatter dict.
785
+ source_file: Relative path to the source file.
786
+
787
+ Returns:
788
+ GraphNode with type "architecture".
789
+ """
790
+ # Synthesize content from layers
791
+ layers: list[dict[str, Any]] = frontmatter.get("layers", [])
792
+ layer_parts: list[str] = []
793
+ for layer in layers:
794
+ name = layer.get("name", "unknown")
795
+ modules: list[str] = layer.get("modules", [])
796
+ layer_parts.append(f"{name}: {', '.join(modules)}")
797
+
798
+ layers_summary = ". ".join(layer_parts) if layer_parts else "No layers defined"
799
+ layer_names = ", ".join(layer.get("name", "") for layer in layers)
800
+ content = (
801
+ f"System design: {len(layers)} layers ({layer_names}). {layers_summary}."
802
+ )
803
+
804
+ # Store all structured data in metadata
805
+ metadata: dict[str, Any] = {"arch_type": "architecture_design"}
806
+ for key in (
807
+ "layers",
808
+ "architectural_decisions",
809
+ "distribution",
810
+ "guardrails_reference",
811
+ "constitution_reference",
812
+ "project",
813
+ "status",
814
+ ):
815
+ if key in frontmatter:
816
+ metadata[key] = frontmatter[key]
817
+
818
+ return GraphNode(
819
+ id="arch-design",
820
+ type="architecture",
821
+ content=content,
822
+ source_file=source_file,
823
+ created=datetime.now(tz=UTC).isoformat(),
824
+ metadata=metadata,
825
+ )
826
+
827
+ def _parse_architecture_domain_model(
828
+ self, frontmatter: dict[str, Any], source_file: str
829
+ ) -> GraphNode:
830
+ """Parse an architecture_domain_model doc (domain-model.md).
831
+
832
+ Synthesizes content from bounded contexts and shared kernel.
833
+
834
+ Args:
835
+ frontmatter: Parsed YAML frontmatter dict.
836
+ source_file: Relative path to the source file.
837
+
838
+ Returns:
839
+ GraphNode with type "architecture".
840
+ """
841
+ # Synthesize content from bounded contexts
842
+ bcs: list[Any] = frontmatter.get("bounded_contexts", [])
843
+ bc_names: list[str] = [bc.get("name", "unknown") if isinstance(bc, dict) else str(bc) for bc in bcs]
844
+ bc_summary = ", ".join(bc_names) if bc_names else "none defined"
845
+
846
+ shared: dict[str, Any] = frontmatter.get("shared_kernel", {})
847
+ shared_modules: list[str] = shared.get("modules", [])
848
+ shared_summary = ", ".join(shared_modules) if shared_modules else "none"
849
+
850
+ content = (
851
+ f"Domain model: {len(bcs)} bounded contexts — {bc_summary}. "
852
+ f"Shared kernel: {shared_summary}."
853
+ )
854
+
855
+ # Store all structured data in metadata
856
+ metadata: dict[str, Any] = {"arch_type": "architecture_domain_model"}
857
+ for key in (
858
+ "bounded_contexts",
859
+ "shared_kernel",
860
+ "application_layer",
861
+ "distribution",
862
+ "project",
863
+ "status",
864
+ ):
865
+ if key in frontmatter:
866
+ metadata[key] = frontmatter[key]
867
+
868
+ return GraphNode(
869
+ id="arch-domain-model",
870
+ type="architecture",
871
+ content=content,
872
+ source_file=source_file,
873
+ created=datetime.now(tz=UTC).isoformat(),
874
+ metadata=metadata,
875
+ )
876
+
877
+ def _extract_bounded_contexts(
878
+ self,
879
+ all_nodes: list[GraphNode],
880
+ node_by_id: dict[str, GraphNode],
881
+ ) -> tuple[list[GraphNode], list[GraphEdge]]:
882
+ """Extract bounded context nodes and belongs_to edges from domain model.
883
+
884
+ Reads the arch-domain-model node's metadata to create bounded_context
885
+ nodes for each DDD context, shared kernel, application layer, and
886
+ distribution grouping.
887
+
888
+ Args:
889
+ all_nodes: All nodes loaded so far.
890
+ node_by_id: Lookup dict by node ID.
891
+
892
+ Returns:
893
+ Tuple of (bounded_context nodes, belongs_to edges).
894
+ """
895
+ nodes: list[GraphNode] = []
896
+ edges: list[GraphEdge] = []
897
+
898
+ # Find the arch-domain-model node
899
+ dm_node = node_by_id.get("arch-domain-model")
900
+ if dm_node is None:
901
+ return nodes, edges
902
+
903
+ now = datetime.now(tz=UTC).isoformat()
904
+
905
+ # Extract bounded contexts
906
+ bcs: list[dict[str, Any]] = dm_node.metadata.get("bounded_contexts", [])
907
+ for bc in bcs:
908
+ bc_name: str = bc.get("name", "")
909
+ if not bc_name:
910
+ continue
911
+ bc_id = f"bc-{bc_name}"
912
+ nodes.append(
913
+ GraphNode(
914
+ id=bc_id,
915
+ type="bounded_context",
916
+ content=bc.get("description", ""),
917
+ source_file=dm_node.source_file,
918
+ created=now,
919
+ metadata={
920
+ "bc_type": "bounded_context",
921
+ "modules": bc.get("modules", []),
922
+ },
923
+ )
924
+ )
925
+ # Create belongs_to edges for modules in this BC
926
+ modules: list[str] = bc.get("modules", [])
927
+ for mod_name in modules:
928
+ mod_id = f"mod-{mod_name}"
929
+ if mod_id in node_by_id:
930
+ edges.append(
931
+ GraphEdge(
932
+ source=mod_id, target=bc_id, type="belongs_to", weight=1.0
933
+ )
934
+ )
935
+
936
+ # Extract shared kernel as a BC node
937
+ shared: dict[str, Any] = dm_node.metadata.get("shared_kernel", {})
938
+ if shared:
939
+ nodes.append(
940
+ GraphNode(
941
+ id="bc-shared-kernel",
942
+ type="bounded_context",
943
+ content=shared.get("description", ""),
944
+ source_file=dm_node.source_file,
945
+ created=now,
946
+ metadata={
947
+ "bc_type": "shared_kernel",
948
+ "modules": shared.get("modules", []),
949
+ },
950
+ )
951
+ )
952
+ for mod_name in shared.get("modules", []):
953
+ mod_id = f"mod-{mod_name}"
954
+ if mod_id in node_by_id:
955
+ edges.append(
956
+ GraphEdge(
957
+ source=mod_id,
958
+ target="bc-shared-kernel",
959
+ type="belongs_to",
960
+ weight=1.0,
961
+ )
962
+ )
963
+
964
+ # Extract application layer as a BC node
965
+ app_layer: dict[str, Any] = dm_node.metadata.get("application_layer", {})
966
+ if app_layer:
967
+ nodes.append(
968
+ GraphNode(
969
+ id="bc-application-layer",
970
+ type="bounded_context",
971
+ content=app_layer.get("description", ""),
972
+ source_file=dm_node.source_file,
973
+ created=now,
974
+ metadata={
975
+ "bc_type": "application_layer",
976
+ "modules": app_layer.get("modules", []),
977
+ },
978
+ )
979
+ )
980
+ for mod_name in app_layer.get("modules", []):
981
+ mod_id = f"mod-{mod_name}"
982
+ if mod_id in node_by_id:
983
+ edges.append(
984
+ GraphEdge(
985
+ source=mod_id,
986
+ target="bc-application-layer",
987
+ type="belongs_to",
988
+ weight=1.0,
989
+ )
990
+ )
991
+
992
+ # Extract distribution as a BC node
993
+ dist: dict[str, Any] = dm_node.metadata.get("distribution", {})
994
+ if dist:
995
+ nodes.append(
996
+ GraphNode(
997
+ id="bc-distribution",
998
+ type="bounded_context",
999
+ content=dist.get("description", ""),
1000
+ source_file=dm_node.source_file,
1001
+ created=now,
1002
+ metadata={
1003
+ "bc_type": "distribution",
1004
+ "modules": dist.get("modules", []),
1005
+ },
1006
+ )
1007
+ )
1008
+ for mod_name in dist.get("modules", []):
1009
+ mod_id = f"mod-{mod_name}"
1010
+ if mod_id in node_by_id:
1011
+ edges.append(
1012
+ GraphEdge(
1013
+ source=mod_id,
1014
+ target="bc-distribution",
1015
+ type="belongs_to",
1016
+ weight=1.0,
1017
+ )
1018
+ )
1019
+
1020
+ return nodes, edges
1021
+
1022
+ def _extract_layers(
1023
+ self,
1024
+ all_nodes: list[GraphNode],
1025
+ node_by_id: dict[str, GraphNode],
1026
+ ) -> tuple[list[GraphNode], list[GraphEdge]]:
1027
+ """Extract layer nodes and in_layer edges from system design.
1028
+
1029
+ Reads the arch-design node's metadata to create layer nodes and
1030
+ in_layer edges linking modules to their architectural layer.
1031
+
1032
+ Args:
1033
+ all_nodes: All nodes loaded so far.
1034
+ node_by_id: Lookup dict by node ID.
1035
+
1036
+ Returns:
1037
+ Tuple of (layer nodes, in_layer edges).
1038
+ """
1039
+ nodes: list[GraphNode] = []
1040
+ edges: list[GraphEdge] = []
1041
+
1042
+ # Find the arch-design node
1043
+ design_node = node_by_id.get("arch-design")
1044
+ if design_node is None:
1045
+ return nodes, edges
1046
+
1047
+ now = datetime.now(tz=UTC).isoformat()
1048
+
1049
+ layers: list[dict[str, Any]] = design_node.metadata.get("layers", [])
1050
+ for layer in layers:
1051
+ layer_name: str = layer.get("name", "")
1052
+ if not layer_name:
1053
+ continue
1054
+ layer_id = f"lyr-{layer_name}"
1055
+ nodes.append(
1056
+ GraphNode(
1057
+ id=layer_id,
1058
+ type="layer",
1059
+ content=layer.get("description", ""),
1060
+ source_file=design_node.source_file,
1061
+ created=now,
1062
+ metadata={"modules": layer.get("modules", [])},
1063
+ )
1064
+ )
1065
+ # Create in_layer edges for modules in this layer
1066
+ modules: list[str] = layer.get("modules", [])
1067
+ for mod_name in modules:
1068
+ mod_id = f"mod-{mod_name}"
1069
+ if mod_id in node_by_id:
1070
+ edges.append(
1071
+ GraphEdge(
1072
+ source=mod_id, target=layer_id, type="in_layer", weight=1.0
1073
+ )
1074
+ )
1075
+
1076
+ return nodes, edges
1077
+
1078
+ def _extract_constraints(
1079
+ self,
1080
+ all_nodes: list[GraphNode],
1081
+ node_by_id: dict[str, GraphNode],
1082
+ ) -> list[GraphEdge]:
1083
+ """Extract constrained_by edges from guardrail scope metadata.
1084
+
1085
+ Reads ``constraint_scope`` from each guardrail node's metadata
1086
+ (set by the guardrails parser from YAML frontmatter). Creates
1087
+ ``constrained_by`` edges from target nodes (BCs or layers) to
1088
+ guardrail nodes.
1089
+
1090
+ Args:
1091
+ all_nodes: All nodes loaded so far (including structural).
1092
+ node_by_id: Lookup dict by node ID.
1093
+
1094
+ Returns:
1095
+ List of constrained_by edges.
1096
+ """
1097
+ edges: list[GraphEdge] = []
1098
+ bc_ids = [n.id for n in node_by_id.values() if n.type == "bounded_context"]
1099
+
1100
+ for node in all_nodes:
1101
+ if node.type != "guardrail":
1102
+ continue
1103
+
1104
+ scope: Any = node.metadata.get("constraint_scope")
1105
+ if scope is None:
1106
+ continue
1107
+
1108
+ if scope == "all_bounded_contexts":
1109
+ targets = bc_ids
1110
+ elif isinstance(scope, list):
1111
+ targets = [t for t in cast(list[str], scope) if t in node_by_id]
1112
+ else:
1113
+ continue
1114
+
1115
+ for target_id in targets:
1116
+ edges.append(
1117
+ GraphEdge(
1118
+ source=target_id,
1119
+ target=node.id,
1120
+ type="constrained_by",
1121
+ weight=1.0,
1122
+ )
1123
+ )
1124
+
1125
+ return edges
1126
+
1127
+ def _get_governance_extractor(self) -> GovernanceExtractor:
1128
+ """Get governance extractor instance.
1129
+
1130
+ Returns:
1131
+ GovernanceExtractor for this project.
1132
+ """
1133
+ from raise_cli.governance.extractor import GovernanceExtractor
1134
+
1135
+ return GovernanceExtractor(project_root=self.project_root)
1136
+
1137
+ def _concept_to_node(self, concept: Concept) -> GraphNode:
1138
+ """Convert governance Concept to GraphNode.
1139
+
1140
+ Args:
1141
+ concept: Governance concept to convert.
1142
+
1143
+ Returns:
1144
+ GraphNode with mapped fields.
1145
+ """
1146
+ return GraphNode(
1147
+ id=concept.id,
1148
+ type=concept.type.value, # type: ignore[arg-type]
1149
+ content=concept.content,
1150
+ source_file=concept.file,
1151
+ created=datetime.now(tz=UTC).isoformat(),
1152
+ metadata=concept.metadata,
1153
+ )
1154
+
1155
+ def _load_jsonl(
1156
+ self,
1157
+ file_path: Path,
1158
+ node_type: str,
1159
+ scope: MemoryScope = MemoryScope.PROJECT,
1160
+ ) -> list[GraphNode]:
1161
+ """Load concepts from a JSONL file.
1162
+
1163
+ Args:
1164
+ file_path: Path to JSONL file.
1165
+ node_type: Type to assign to nodes (pattern, calibration, session).
1166
+ scope: Memory scope to assign to loaded concepts.
1167
+
1168
+ Returns:
1169
+ List of GraphNode parsed from file.
1170
+ """
1171
+ nodes: list[GraphNode] = []
1172
+
1173
+ # Try to make path relative, fallback to absolute
1174
+ try:
1175
+ source_file = portable_path(file_path, self.project_root)
1176
+ except ValueError:
1177
+ source_file = str(file_path)
1178
+
1179
+ for line in file_path.read_text(encoding="utf-8").splitlines():
1180
+ if not line.strip():
1181
+ continue
1182
+
1183
+ try:
1184
+ record: dict[str, Any] = json.loads(line)
1185
+ except json.JSONDecodeError:
1186
+ continue
1187
+
1188
+ node = self._memory_record_to_node(record, node_type, source_file, scope)
1189
+ if node:
1190
+ nodes.append(node)
1191
+
1192
+ return nodes
1193
+
1194
+ def _memory_record_to_node(
1195
+ self,
1196
+ record: dict[str, Any],
1197
+ node_type: str,
1198
+ source_file: str,
1199
+ scope: MemoryScope = MemoryScope.PROJECT,
1200
+ ) -> GraphNode | None:
1201
+ """Convert memory JSONL record to GraphNode.
1202
+
1203
+ Args:
1204
+ record: Parsed JSON record.
1205
+ node_type: Type of memory concept.
1206
+ source_file: Source file path.
1207
+ scope: Memory scope for this concept.
1208
+
1209
+ Returns:
1210
+ GraphNode or None if record is invalid.
1211
+ """
1212
+ record_id = record.get("id")
1213
+ if not record_id:
1214
+ return None
1215
+
1216
+ # Build content based on type
1217
+ if node_type == "pattern":
1218
+ content = record.get("content", "")
1219
+ elif node_type == "calibration":
1220
+ # Calibration uses story + name (backward compat: old "feature" key)
1221
+ story = record.get("story") or record.get("feature", "")
1222
+ name = record.get("name", "")
1223
+ content = f"{story}: {name}" if story else name
1224
+ elif node_type == "session":
1225
+ content = record.get("topic", record.get("summary", ""))
1226
+ else:
1227
+ content = record.get("content", "")
1228
+
1229
+ # Get created date
1230
+ created = record.get("created") or record.get("date", "")
1231
+ if not created:
1232
+ created = datetime.now(tz=UTC).isoformat()
1233
+
1234
+ # Core fields to exclude from metadata
1235
+ core_fields = {"id", "type", "content", "created", "date"}
1236
+
1237
+ # Build metadata from remaining fields
1238
+ metadata: dict[str, Any] = {
1239
+ k: v for k, v in record.items() if k not in core_fields
1240
+ }
1241
+
1242
+ # Add scope to metadata
1243
+ metadata["scope"] = scope.value
1244
+
1245
+ return GraphNode(
1246
+ id=str(record_id),
1247
+ type=node_type, # type: ignore[arg-type]
1248
+ content=str(content),
1249
+ source_file=source_file,
1250
+ created=str(created),
1251
+ metadata=metadata,
1252
+ )
1253
+
1254
+ def infer_relationships(self, nodes: list[GraphNode]) -> list[GraphEdge]:
1255
+ """Infer relationships between concepts.
1256
+
1257
+ Creates explicit edges (weight=1.0) from known fields and
1258
+ inferred edges (weight<1.0) from heuristics.
1259
+
1260
+ Args:
1261
+ nodes: List of concept nodes to analyze.
1262
+
1263
+ Returns:
1264
+ List of inferred GraphEdge objects.
1265
+ """
1266
+ if not nodes:
1267
+ return []
1268
+
1269
+ edges: list[GraphEdge] = []
1270
+
1271
+ # Build lookup by ID for target resolution
1272
+ node_by_id: dict[str, GraphNode] = {n.id: n for n in nodes}
1273
+
1274
+ # Infer explicit edges
1275
+ edges.extend(self._infer_learned_from(nodes, node_by_id))
1276
+ edges.extend(self._infer_part_of(nodes, node_by_id))
1277
+ edges.extend(self._infer_skill_edges(nodes, node_by_id))
1278
+ edges.extend(self._infer_depends_on(nodes, node_by_id))
1279
+ edges.extend(self._infer_release_part_of(nodes, node_by_id))
1280
+
1281
+ # Infer heuristic edges
1282
+ edges.extend(self._infer_keyword_relationships(nodes))
1283
+
1284
+ return edges
1285
+
1286
+ def _infer_learned_from(
1287
+ self,
1288
+ nodes: list[GraphNode],
1289
+ node_by_id: dict[str, GraphNode],
1290
+ ) -> list[GraphEdge]:
1291
+ """Infer learned_from edges from pattern metadata.
1292
+
1293
+ Args:
1294
+ nodes: All concept nodes.
1295
+ node_by_id: Lookup dict by node ID.
1296
+
1297
+ Returns:
1298
+ List of learned_from edges.
1299
+ """
1300
+ edges: list[GraphEdge] = []
1301
+
1302
+ for node in nodes:
1303
+ if node.type != "pattern":
1304
+ continue
1305
+
1306
+ learned_from = node.metadata.get("learned_from")
1307
+ if not learned_from:
1308
+ continue
1309
+
1310
+ # Find matching session by topic/story reference
1311
+ for candidate in nodes:
1312
+ if candidate.type != "session":
1313
+ continue
1314
+
1315
+ # Check if session topic mentions the story
1316
+ if str(learned_from) in candidate.content:
1317
+ edges.append(
1318
+ GraphEdge(
1319
+ source=node.id,
1320
+ target=candidate.id,
1321
+ type="learned_from",
1322
+ weight=1.0,
1323
+ )
1324
+ )
1325
+ break
1326
+
1327
+ return edges
1328
+
1329
+ def _infer_part_of(
1330
+ self,
1331
+ nodes: list[GraphNode],
1332
+ node_by_id: dict[str, GraphNode],
1333
+ ) -> list[GraphEdge]:
1334
+ """Infer part_of edges from story to epic.
1335
+
1336
+ Args:
1337
+ nodes: All concept nodes.
1338
+ node_by_id: Lookup dict by node ID.
1339
+
1340
+ Returns:
1341
+ List of part_of edges.
1342
+ """
1343
+ edges: list[GraphEdge] = []
1344
+
1345
+ for node in nodes:
1346
+ if node.type != "story":
1347
+ continue
1348
+
1349
+ # Extract epic ID from story ID (e.g., F11.2 -> E11)
1350
+ story_id = node.id
1351
+ if story_id.startswith("F"):
1352
+ # Parse epic number from story ID
1353
+ parts = story_id[1:].split(".")
1354
+ if parts:
1355
+ epic_id = f"E{parts[0]}"
1356
+ if epic_id in node_by_id:
1357
+ edges.append(
1358
+ GraphEdge(
1359
+ source=node.id,
1360
+ target=epic_id,
1361
+ type="part_of",
1362
+ weight=1.0,
1363
+ )
1364
+ )
1365
+
1366
+ return edges
1367
+
1368
+ def _infer_skill_edges(
1369
+ self,
1370
+ nodes: list[GraphNode],
1371
+ node_by_id: dict[str, GraphNode],
1372
+ ) -> list[GraphEdge]:
1373
+ """Infer edges from skill metadata (prerequisites, next).
1374
+
1375
+ Args:
1376
+ nodes: All concept nodes.
1377
+ node_by_id: Lookup dict by node ID.
1378
+
1379
+ Returns:
1380
+ List of skill relationship edges.
1381
+ """
1382
+ edges: list[GraphEdge] = []
1383
+
1384
+ for node in nodes:
1385
+ if node.type != "skill":
1386
+ continue
1387
+
1388
+ # Prerequisites -> needs_context
1389
+ prereq = node.metadata.get("raise.prerequisites")
1390
+ if prereq:
1391
+ prereq_id = f"/{prereq}" if not str(prereq).startswith("/") else prereq
1392
+ if prereq_id in node_by_id:
1393
+ edges.append(
1394
+ GraphEdge(
1395
+ source=node.id,
1396
+ target=prereq_id,
1397
+ type="needs_context",
1398
+ weight=1.0,
1399
+ )
1400
+ )
1401
+
1402
+ # Next -> related_to
1403
+ next_skill = node.metadata.get("raise.next")
1404
+ if next_skill:
1405
+ next_id = (
1406
+ f"/{next_skill}"
1407
+ if not str(next_skill).startswith("/")
1408
+ else next_skill
1409
+ )
1410
+ if next_id in node_by_id:
1411
+ edges.append(
1412
+ GraphEdge(
1413
+ source=node.id,
1414
+ target=next_id,
1415
+ type="related_to",
1416
+ weight=1.0,
1417
+ )
1418
+ )
1419
+
1420
+ return edges
1421
+
1422
+ def _infer_depends_on(
1423
+ self,
1424
+ nodes: list[GraphNode],
1425
+ node_by_id: dict[str, GraphNode],
1426
+ ) -> list[GraphEdge]:
1427
+ """Infer depends_on edges from module metadata.
1428
+
1429
+ Args:
1430
+ nodes: All concept nodes.
1431
+ node_by_id: Lookup dict by node ID.
1432
+
1433
+ Returns:
1434
+ List of depends_on edges between modules.
1435
+ """
1436
+ edges: list[GraphEdge] = []
1437
+
1438
+ for node in nodes:
1439
+ if node.type != "module":
1440
+ continue
1441
+
1442
+ raw_deps: Any = node.metadata.get("depends_on", [])
1443
+ if not isinstance(raw_deps, list):
1444
+ continue
1445
+ deps = cast(list[str], raw_deps)
1446
+
1447
+ for dep_name in deps:
1448
+ target_id = f"mod-{dep_name}"
1449
+ if target_id in node_by_id:
1450
+ edges.append(
1451
+ GraphEdge(
1452
+ source=node.id,
1453
+ target=target_id,
1454
+ type="depends_on",
1455
+ weight=1.0,
1456
+ )
1457
+ )
1458
+
1459
+ return edges
1460
+
1461
+ def _infer_release_part_of(
1462
+ self,
1463
+ nodes: list[GraphNode],
1464
+ node_by_id: dict[str, GraphNode],
1465
+ ) -> list[GraphEdge]:
1466
+ """Infer part_of edges from epics to releases.
1467
+
1468
+ Uses the ``epics`` list in release node metadata to create
1469
+ part_of edges. Skips edges where the epic node doesn't exist.
1470
+
1471
+ Args:
1472
+ nodes: All concept nodes.
1473
+ node_by_id: Lookup dict by node ID.
1474
+
1475
+ Returns:
1476
+ List of part_of edges from epic to release.
1477
+ """
1478
+ edges: list[GraphEdge] = []
1479
+
1480
+ for node in nodes:
1481
+ if node.type != "release":
1482
+ continue
1483
+
1484
+ epic_refs: Any = node.metadata.get("epics", [])
1485
+ if not isinstance(epic_refs, list):
1486
+ continue
1487
+
1488
+ for epic_ref in cast(list[str], epic_refs):
1489
+ epic_id = f"epic-{epic_ref.lower()}"
1490
+ if epic_id in node_by_id:
1491
+ edges.append(
1492
+ GraphEdge(
1493
+ source=epic_id,
1494
+ target=node.id,
1495
+ type="part_of",
1496
+ weight=1.0,
1497
+ )
1498
+ )
1499
+
1500
+ return edges
1501
+
1502
+ def _infer_keyword_relationships(
1503
+ self,
1504
+ nodes: list[GraphNode],
1505
+ ) -> list[GraphEdge]:
1506
+ """Infer related_to edges from shared keywords.
1507
+
1508
+ Args:
1509
+ nodes: All concept nodes.
1510
+
1511
+ Returns:
1512
+ List of keyword-based relationship edges.
1513
+ """
1514
+ edges: list[GraphEdge] = []
1515
+
1516
+ # Extract keywords for each node
1517
+ node_keywords: dict[str, set[str]] = {}
1518
+ for node in nodes:
1519
+ keywords = self._extract_keywords(node)
1520
+ if keywords:
1521
+ node_keywords[node.id] = keywords
1522
+
1523
+ # Find pairs with shared keywords (at least 2)
1524
+ node_ids = list(node_keywords.keys())
1525
+ for i, id1 in enumerate(node_ids):
1526
+ for id2 in node_ids[i + 1 :]:
1527
+ shared = node_keywords[id1] & node_keywords[id2]
1528
+ if len(shared) >= 2:
1529
+ edges.append(
1530
+ GraphEdge(
1531
+ source=id1,
1532
+ target=id2,
1533
+ type="related_to",
1534
+ weight=0.5,
1535
+ metadata={"shared_keywords": list(shared)},
1536
+ )
1537
+ )
1538
+
1539
+ return edges
1540
+
1541
+ def _extract_keywords(self, node: GraphNode) -> set[str]:
1542
+ """Extract keywords from a concept node.
1543
+
1544
+ Args:
1545
+ node: Concept node to extract keywords from.
1546
+
1547
+ Returns:
1548
+ Set of lowercase keywords.
1549
+ """
1550
+ keywords: set[str] = set()
1551
+
1552
+ # From content
1553
+ if node.content:
1554
+ words = node.content.lower().split()
1555
+ for word in words:
1556
+ # Clean word (keep only alphanumeric)
1557
+ clean = "".join(c for c in word if c.isalnum())
1558
+ if len(clean) >= 4 and clean not in STOPWORDS:
1559
+ keywords.add(clean)
1560
+
1561
+ # From context metadata (for patterns)
1562
+ context_value: Any = node.metadata.get("context", [])
1563
+ if isinstance(context_value, list):
1564
+ context_list = cast(list[Any], context_value)
1565
+ for ctx in context_list:
1566
+ if isinstance(ctx, str):
1567
+ keywords.add(ctx.lower())
1568
+
1569
+ return keywords