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,1086 @@
1
+ """CLI commands for Rai's knowledge graph: build, query, validate, and manage.
2
+
3
+ The graph group owns commands that operate on the knowledge graph structure.
4
+ These were extracted from the `memory` God Object in RAISE-247 (ADR-038).
5
+
6
+ Commands:
7
+ - build: Build the graph index from all sources
8
+ - validate: Validate graph structure and relationships
9
+ - query: Query the graph for relevant concepts
10
+ - context: Show architectural context for a module
11
+ - list: List all concepts in the graph
12
+ - viz: Generate interactive HTML visualization
13
+ - extract: Extract concepts from governance markdown files
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+ from typing import Annotated
21
+
22
+ import typer
23
+ from rich.console import Console
24
+ from rich.table import Table
25
+
26
+ from raise_cli.cli.error_handler import cli_error
27
+ from raise_cli.compat import to_file_uri
28
+ from raise_cli.config.paths import get_memory_dir, get_personal_dir
29
+ from raise_cli.context import Graph, GraphBuilder
30
+ from raise_cli.context.diff import GraphDiff, diff_graphs
31
+ from raise_cli.governance import Concept, ConceptType, GovernanceExtractor
32
+ from raise_cli.graph.backends import get_active_backend
33
+ from raise_cli.hooks.emitter import create_emitter
34
+ from raise_cli.hooks.events import GraphBuildEvent
35
+ from raise_core.graph.models import GraphEdge, GraphNode
36
+ from raise_core.graph.query import (
37
+ ArchitecturalContext,
38
+ Query,
39
+ QueryEngine,
40
+ QueryResult,
41
+ QueryStrategy,
42
+ )
43
+
44
+ # Default index file name
45
+ INDEX_FILE = "index.json"
46
+
47
+ graph_app = typer.Typer(
48
+ name="graph",
49
+ help="Build, query, and manage the knowledge graph",
50
+ no_args_is_help=True,
51
+ )
52
+
53
+ console = Console()
54
+
55
+
56
+ def _get_default_index_path() -> Path:
57
+ """Get default graph index path (.raise/rai/memory/index.json)."""
58
+ return get_memory_dir() / INDEX_FILE
59
+
60
+
61
+ # =============================================================================
62
+ # Query Commands
63
+ # =============================================================================
64
+
65
+
66
+ @graph_app.command()
67
+ def query(
68
+ query_str: Annotated[
69
+ str, typer.Argument(help="Query string (keywords or concept ID)")
70
+ ],
71
+ format: Annotated[
72
+ str,
73
+ typer.Option("--format", "-f", help="Output format (human or json)"),
74
+ ] = "human",
75
+ output: Annotated[
76
+ Path | None,
77
+ typer.Option("--output", "-o", help="Output file path (default: stdout)"),
78
+ ] = None,
79
+ strategy: Annotated[
80
+ str | None,
81
+ typer.Option(
82
+ "--strategy",
83
+ "-s",
84
+ help="Query strategy (keyword_search, concept_lookup)",
85
+ ),
86
+ ] = None,
87
+ types: Annotated[
88
+ str | None,
89
+ typer.Option(
90
+ "--types",
91
+ "-t",
92
+ help="Filter by types (comma-separated: pattern,calibration,principle,etc.)",
93
+ ),
94
+ ] = None,
95
+ edge_types: Annotated[
96
+ str | None,
97
+ typer.Option(
98
+ "--edge-types",
99
+ help="Filter by edge types (comma-separated: constrained_by,depends_on,etc.)",
100
+ ),
101
+ ] = None,
102
+ limit: Annotated[
103
+ int,
104
+ typer.Option("--limit", "-l", help="Maximum number of results"),
105
+ ] = 10,
106
+ index_path: Annotated[
107
+ Path | None,
108
+ typer.Option("--index", "-i", help="Graph index path"),
109
+ ] = None,
110
+ ) -> None:
111
+ """Query the knowledge graph for relevant concepts.
112
+
113
+ Searches the unified graph containing all context sources:
114
+ - Governance (principles, requirements, terms)
115
+ - Memory (patterns, calibration, sessions)
116
+ - Skills (workflow metadata)
117
+ - Work (epics, stories, decisions)
118
+
119
+ Examples:
120
+ # Search by keywords
121
+ $ rai graph query "planning estimation"
122
+
123
+ # Filter to patterns only
124
+ $ rai graph query "testing" --types pattern,calibration
125
+
126
+ # Lookup specific concept by ID
127
+ $ rai graph query "PAT-001" --strategy concept_lookup
128
+
129
+ # Output as JSON
130
+ $ rai graph query "velocity" --format json
131
+ """
132
+ # Load engine
133
+ unified_path = index_path or _get_default_index_path()
134
+ try:
135
+ graph = get_active_backend(unified_path).load()
136
+ engine = QueryEngine(graph)
137
+ except FileNotFoundError as e:
138
+ cli_error(
139
+ str(e),
140
+ hint="Run 'rai graph build' first to create the index",
141
+ exit_code=4,
142
+ )
143
+
144
+ # Parse types filter
145
+ types_list: list[str] | None = None
146
+ if types:
147
+ types_list = [t.strip() for t in types.split(",")]
148
+
149
+ # Determine strategy
150
+ query_strategy = QueryStrategy.KEYWORD_SEARCH # Default
151
+ if strategy:
152
+ try:
153
+ query_strategy = QueryStrategy(strategy)
154
+ except ValueError:
155
+ cli_error(
156
+ f"Invalid strategy: {strategy}",
157
+ hint="Valid strategies: keyword_search, concept_lookup",
158
+ exit_code=7,
159
+ )
160
+
161
+ # Parse edge_types filter
162
+ edge_types_list: list[str] | None = None
163
+ if edge_types:
164
+ edge_types_list = [t.strip() for t in edge_types.split(",")]
165
+
166
+ # Build and execute query
167
+ unified_query = Query(
168
+ query=query_str,
169
+ strategy=query_strategy,
170
+ max_depth=1,
171
+ types=types_list,
172
+ edge_types=edge_types_list,
173
+ limit=limit,
174
+ )
175
+
176
+ if format != "agent":
177
+ console.print(f"\nQuerying memory for: [cyan]{query_str}[/cyan]")
178
+ console.print(f"Strategy: [yellow]{query_strategy.value}[/yellow]\n")
179
+
180
+ result = engine.query(unified_query)
181
+
182
+ # Format output
183
+ if format == "agent":
184
+ output_text = _format_agent(result)
185
+ if output:
186
+ output.write_text(output_text, encoding="utf-8")
187
+ elif output_text:
188
+ print(output_text)
189
+ return
190
+ elif format == "json":
191
+ output_text = _format_json(result)
192
+ elif format == "compact":
193
+ output_text = _format_compact(result)
194
+ else:
195
+ output_text = _format_markdown(result)
196
+
197
+ # Write to file or stdout
198
+ if output:
199
+ output.write_text(output_text, encoding="utf-8")
200
+ console.print(f"✓ Results written to [cyan]{output}[/cyan]")
201
+ console.print(f" Concepts: {result.metadata.total_concepts}")
202
+ console.print(f" Tokens: ~{result.metadata.token_estimate}")
203
+ console.print(f" Execution: {result.metadata.execution_time_ms:.2f}ms\n")
204
+ else:
205
+ console.print(output_text)
206
+
207
+
208
+ def _format_markdown(result: QueryResult) -> str:
209
+ """Format query result as markdown for human consumption."""
210
+ lines: list[str] = []
211
+
212
+ # Header
213
+ lines.append("# Memory Query Results")
214
+ lines.append("")
215
+ lines.append(f"**Query:** `{result.metadata.query}`")
216
+ lines.append(f"**Strategy:** {result.metadata.strategy.value}")
217
+
218
+ # Types found summary
219
+ types_str = ", ".join(
220
+ f"{t}={c}" for t, c in sorted(result.metadata.types_found.items())
221
+ )
222
+ lines.append(
223
+ f"**Concepts:** {result.metadata.total_concepts} | "
224
+ f"**Tokens:** ~{result.metadata.token_estimate} | "
225
+ f"**Types:** {types_str}"
226
+ )
227
+ lines.append("")
228
+ lines.append("---")
229
+ lines.append("")
230
+
231
+ # No results
232
+ if not result.concepts:
233
+ lines.append("*No concepts found matching the query.*")
234
+ lines.append("")
235
+ return "\n".join(lines)
236
+
237
+ # Group concepts by type
238
+ by_type: dict[str, list[GraphNode]] = {}
239
+ for concept in result.concepts:
240
+ by_type.setdefault(concept.type, []).append(concept)
241
+
242
+ # Render by type groups
243
+ for node_type in sorted(by_type.keys()):
244
+ concepts = by_type[node_type]
245
+ lines.append(f"## {node_type.title()} ({len(concepts)})")
246
+ lines.append("")
247
+
248
+ for concept in concepts:
249
+ # Concept header
250
+ lines.append(f"### {concept.id}")
251
+ source = concept.source_file or "unknown"
252
+ lines.append(f"**Source:** {source} | **Created:** {concept.created}")
253
+ lines.append("")
254
+
255
+ # Content (truncate if very long)
256
+ content = concept.content
257
+ if len(content) > 300:
258
+ content = content[:300] + "..."
259
+ lines.append(content)
260
+ lines.append("")
261
+
262
+ # Metadata annotations (if available)
263
+ if concept.metadata and "needs_context" in concept.metadata:
264
+ ctx = ", ".join(concept.metadata["needs_context"])
265
+ lines.append(f"*Needs context: {ctx}*")
266
+ lines.append("")
267
+
268
+ lines.append("---")
269
+ lines.append("")
270
+
271
+ # Footer with metadata
272
+ lines.append("**Query Metadata:**")
273
+ lines.append(f"- Execution time: {result.metadata.execution_time_ms:.2f}ms")
274
+ lines.append(f"- Token estimate: ~{result.metadata.token_estimate}")
275
+ lines.append("")
276
+
277
+ return "\n".join(lines)
278
+
279
+
280
+ _COMPACT_CONTENT_MAX = 150
281
+
282
+
283
+ def _format_compact(result: QueryResult) -> str:
284
+ """Format query result as compact Markdown-KV for AI consumption.
285
+
286
+ One line per result: **type** id: content (truncated at 150 chars).
287
+ Header with query, count, and strategy. Truncation footer when clipped.
288
+ """
289
+ meta = result.metadata
290
+ lines: list[str] = []
291
+
292
+ # Header: # Memory: query (N results, strategy)
293
+ lines.append(
294
+ f"# Memory: {meta.query} ({meta.total_concepts} results, {meta.strategy.value})"
295
+ )
296
+
297
+ # No results
298
+ if not result.concepts:
299
+ lines.append("*No results.*")
300
+ return "\n".join(lines)
301
+
302
+ # One Markdown-KV line per concept
303
+ for concept in result.concepts:
304
+ content = concept.content
305
+ if len(content) > _COMPACT_CONTENT_MAX:
306
+ content = content[:_COMPACT_CONTENT_MAX] + "..."
307
+ lines.append(f"**{concept.type}** {concept.id}: {content}")
308
+
309
+ # Truncation footer (only when results were clipped)
310
+ remaining = meta.total_available - meta.total_concepts
311
+ if remaining > 0:
312
+ lines.append(
313
+ f"[+{remaining} more — use --limit {meta.total_available} to see all]"
314
+ )
315
+
316
+ return "\n".join(lines)
317
+
318
+
319
+ def _sanitize_pipe(value: str) -> str:
320
+ """Replace pipe characters in value to preserve agent format field boundaries."""
321
+ return value.replace("|", "¦")
322
+
323
+
324
+ def _format_agent(result: QueryResult) -> str:
325
+ """Format query result as pipe-delimited lines for agent consumption.
326
+
327
+ One line per concept: type|id|content (no truncation, no markdown).
328
+ Empty string when no results. Pipes in content replaced with ¦.
329
+ """
330
+ if not result.concepts:
331
+ return ""
332
+ lines: list[str] = []
333
+ for concept in result.concepts:
334
+ lines.append(f"{concept.type}|{concept.id}|{_sanitize_pipe(concept.content)}")
335
+ return "\n".join(lines)
336
+
337
+
338
+ def _format_json(result: QueryResult) -> str:
339
+ """Format query result as JSON."""
340
+ return result.to_json()
341
+
342
+
343
+ # =============================================================================
344
+ # Architectural Context Command
345
+ # =============================================================================
346
+
347
+
348
+ @graph_app.command("context")
349
+ def context_cmd(
350
+ module_id: Annotated[str, typer.Argument(help="Module ID (e.g., mod-memory)")],
351
+ format: Annotated[
352
+ str,
353
+ typer.Option("--format", "-f", help="Output format (human or json)"),
354
+ ] = "human",
355
+ index_path: Annotated[
356
+ Path | None,
357
+ typer.Option("--index", "-i", help="Graph index path"),
358
+ ] = None,
359
+ ) -> None:
360
+ """Show full architectural context for a module.
361
+
362
+ Returns the module's bounded context (domain), architectural layer,
363
+ applicable guardrails (constraints), and module dependencies in a
364
+ single structured view.
365
+
366
+ Examples:
367
+ # Show context for memory module
368
+ $ rai graph context mod-memory
369
+
370
+ # JSON output for programmatic use
371
+ $ rai graph context mod-memory --format json
372
+ """
373
+ unified_path = index_path or _get_default_index_path()
374
+ try:
375
+ graph = get_active_backend(unified_path).load()
376
+ engine = QueryEngine(graph)
377
+ except FileNotFoundError as e:
378
+ cli_error(
379
+ str(e),
380
+ hint="Run 'rai graph build' first to create the index",
381
+ exit_code=4,
382
+ )
383
+ return # cli_error exits, but this satisfies pyright
384
+
385
+ ctx = engine.get_architectural_context(module_id)
386
+ if ctx is None:
387
+ cli_error(
388
+ f"Module not found: {module_id}",
389
+ hint="Check available modules with: rai graph query '' --types module",
390
+ exit_code=4,
391
+ )
392
+ return # cli_error exits, but this satisfies pyright
393
+
394
+ if format == "agent":
395
+ print(_format_context_agent(ctx))
396
+ elif format == "json":
397
+ console.print(_format_context_json(ctx))
398
+ else:
399
+ _print_context_human(ctx)
400
+
401
+
402
+ def _format_context_agent(ctx: ArchitecturalContext) -> str:
403
+ """Format architectural context as pipe-delimited lines for agent consumption."""
404
+ lines: list[str] = []
405
+ lines.append(f"module|{ctx.module.id}|{_sanitize_pipe(ctx.module.content)}")
406
+
407
+ if ctx.domain:
408
+ lines.append(f"domain|{ctx.domain.id}|{_sanitize_pipe(ctx.domain.content)}")
409
+
410
+ if ctx.layer:
411
+ lines.append(f"layer|{ctx.layer.id}|{_sanitize_pipe(ctx.layer.content)}")
412
+
413
+ if ctx.constraints:
414
+ # Classify by ID convention (more robust than content string matching)
415
+ must = [c for c in ctx.constraints if "-must-" in c.id]
416
+ should = [c for c in ctx.constraints if "-should-" in c.id]
417
+ if must:
418
+ lines.append(f"must|{','.join(c.id for c in must)}")
419
+ if should:
420
+ lines.append(f"should|{','.join(c.id for c in should)}")
421
+
422
+ if ctx.dependencies:
423
+ lines.append(f"dependencies|{','.join(d.id for d in ctx.dependencies)}")
424
+
425
+ return "\n".join(lines)
426
+
427
+
428
+ def _format_context_json(ctx: ArchitecturalContext) -> str:
429
+ """Format architectural context as JSON."""
430
+ return ctx.model_dump_json(indent=2)
431
+
432
+
433
+ def _print_context_human(ctx: ArchitecturalContext) -> None:
434
+ """Print architectural context in human-readable format."""
435
+ console.print(f"\n[bold]Module:[/bold] [cyan]{ctx.module.id}[/cyan]")
436
+ console.print(f" {ctx.module.content}")
437
+
438
+ if ctx.domain:
439
+ console.print(f"\n[bold]Domain:[/bold] [green]{ctx.domain.id}[/green]")
440
+ console.print(f" {ctx.domain.content}")
441
+ else:
442
+ console.print("\n[bold]Domain:[/bold] [dim]None[/dim]")
443
+
444
+ if ctx.layer:
445
+ console.print(f"\n[bold]Layer:[/bold] [green]{ctx.layer.id}[/green]")
446
+ console.print(f" {ctx.layer.content}")
447
+ else:
448
+ console.print("\n[bold]Layer:[/bold] [dim]None[/dim]")
449
+
450
+ if ctx.constraints:
451
+ must = [c for c in ctx.constraints if "MUST" in c.content]
452
+ should = [c for c in ctx.constraints if "SHOULD" in c.content]
453
+ console.print(f"\n[bold]Constraints:[/bold] {len(ctx.constraints)} guardrails")
454
+ if must:
455
+ must_ids = ", ".join(c.id for c in must)
456
+ console.print(f" [red]MUST:[/red] {must_ids}")
457
+ if should:
458
+ should_ids = ", ".join(c.id for c in should)
459
+ console.print(f" [yellow]SHOULD:[/yellow] {should_ids}")
460
+ else:
461
+ console.print("\n[bold]Constraints:[/bold] [dim]None[/dim]")
462
+
463
+ if ctx.dependencies:
464
+ dep_ids = ", ".join(d.id for d in ctx.dependencies)
465
+ console.print(f"\n[bold]Dependencies:[/bold] {dep_ids}")
466
+ else:
467
+ console.print("\n[bold]Dependencies:[/bold] [dim]None[/dim]")
468
+
469
+ console.print()
470
+
471
+
472
+ # =============================================================================
473
+ # Build/Index Commands
474
+ # =============================================================================
475
+
476
+
477
+ @graph_app.command()
478
+ def build(
479
+ output: Annotated[
480
+ Path | None,
481
+ typer.Option("--output", "-o", help="Path to save index JSON"),
482
+ ] = None,
483
+ no_diff: Annotated[
484
+ bool,
485
+ typer.Option("--no-diff", help="Skip diff computation"),
486
+ ] = False,
487
+ ) -> None:
488
+ """Build graph index from all sources.
489
+
490
+ Merges all context sources into a single queryable index:
491
+ - Governance documents (constitution, PRD, vision)
492
+ - Memory (patterns, calibration, sessions)
493
+ - Work tracking (epics, stories)
494
+ - Skills (SKILL.md metadata)
495
+ - Components (from discovery)
496
+
497
+ By default, diffs against the previous build and saves the diff
498
+ to .raise/rai/personal/last-diff.json for downstream consumers.
499
+
500
+ Examples:
501
+ # Build index to default location
502
+ $ rai graph build
503
+
504
+ # Build without diff
505
+ $ rai graph build --no-diff
506
+
507
+ # Save to custom location
508
+ $ rai graph build --output custom_index.json
509
+ """
510
+ default_output = _get_default_index_path()
511
+ output_path = output or default_output
512
+
513
+ # Load old graph for diff (before building new one)
514
+ backend = get_active_backend(output_path)
515
+ old_graph = None
516
+ if not no_diff and output_path.exists():
517
+ old_graph = backend.load()
518
+
519
+ # Build unified graph
520
+ builder = GraphBuilder()
521
+ graph = builder.build()
522
+
523
+ # Count nodes by type
524
+ node_counts: dict[str, int] = {}
525
+ for node in graph.iter_concepts():
526
+ node_counts[node.type] = node_counts.get(node.type, 0) + 1
527
+
528
+ # Count edges by type
529
+ edge_counts: dict[str, int] = {}
530
+ for edge in graph.iter_relationships():
531
+ edge_counts[edge.type] = edge_counts.get(edge.type, 0) + 1
532
+
533
+ # Save graph via backend
534
+ backend.persist(graph)
535
+
536
+ # Emit graph:build event
537
+ emitter = create_emitter()
538
+ emitter.emit(
539
+ GraphBuildEvent(
540
+ project_path=output_path.parent,
541
+ node_count=graph.node_count,
542
+ edge_count=graph.edge_count,
543
+ )
544
+ )
545
+
546
+ # Compute and persist diff
547
+ diff: GraphDiff | None = None
548
+ if old_graph is not None:
549
+ diff = diff_graphs(old_graph, graph)
550
+ diff_path = get_personal_dir() / "last-diff.json"
551
+ diff_path.parent.mkdir(parents=True, exist_ok=True)
552
+ diff_path.write_text(diff.model_dump_json(indent=2), encoding="utf-8")
553
+
554
+ # Format output
555
+ _format_build_result(
556
+ output_path=output_path,
557
+ node_counts=node_counts,
558
+ edge_counts=edge_counts,
559
+ total_nodes=graph.node_count,
560
+ total_edges=graph.edge_count,
561
+ diff=diff,
562
+ )
563
+
564
+
565
+ def _format_build_result(
566
+ output_path: Path,
567
+ node_counts: dict[str, int],
568
+ edge_counts: dict[str, int],
569
+ total_nodes: int,
570
+ total_edges: int,
571
+ diff: GraphDiff | None = None,
572
+ ) -> None:
573
+ """Format and print graph build results."""
574
+ console.print("\n[cyan]Building graph index...[/cyan]")
575
+
576
+ # Display node counts
577
+ console.print("\n[bold]Concepts by type:[/bold]")
578
+ for node_type, count in sorted(node_counts.items()):
579
+ console.print(f" {node_type}: [green]{count}[/green]")
580
+
581
+ console.print(f"\n[bold]Total concepts:[/bold] [green]{total_nodes}[/green]")
582
+
583
+ # Display edge counts
584
+ if edge_counts:
585
+ console.print("\n[bold]Relationships by type:[/bold]")
586
+ for edge_type, count in sorted(edge_counts.items()):
587
+ console.print(f" {edge_type}: [green]{count}[/green]")
588
+
589
+ console.print(f"\n[bold]Total relationships:[/bold] [green]{total_edges}[/green]")
590
+
591
+ # Display diff summary
592
+ if diff is not None:
593
+ console.print(f"\n[bold]Diff:[/bold] {diff.summary}")
594
+ if diff.impact != "none":
595
+ console.print(f"[bold]Impact:[/bold] {diff.impact}")
596
+
597
+ console.print(f"\n✓ Saved to [cyan]{output_path}[/cyan]\n")
598
+
599
+
600
+ @graph_app.command()
601
+ def validate(
602
+ index_file: Annotated[
603
+ Path | None,
604
+ typer.Option("--index", "-i", help="Path to index JSON file"),
605
+ ] = None,
606
+ ) -> None:
607
+ """Validate graph index structure and relationships.
608
+
609
+ Checks for:
610
+ - Cycles in depends_on relationships
611
+ - Valid relationship types
612
+ - All edge targets exist as nodes
613
+
614
+ Examples:
615
+ # Validate default index
616
+ $ rai graph validate
617
+
618
+ # Validate specific index file
619
+ $ rai graph validate --index custom_index.json
620
+ """
621
+ default_index = _get_default_index_path()
622
+ index_path = index_file or default_index
623
+
624
+ if not index_path.exists():
625
+ cli_error(
626
+ f"Index file not found: {index_path}",
627
+ hint="Run 'rai graph build' first to create the index",
628
+ exit_code=4,
629
+ )
630
+
631
+ console.print(f"\nLoading index from [cyan]{index_path}[/cyan]...")
632
+ graph = get_active_backend(index_path).load()
633
+ console.print(
634
+ f" ✓ Loaded index with {graph.node_count} concepts, {graph.edge_count} relationships"
635
+ )
636
+
637
+ console.print("\nValidating index...")
638
+
639
+ # Build node set for validation
640
+ node_ids = {node.id for node in graph.iter_concepts()}
641
+
642
+ # Check 1: All edge targets exist as nodes
643
+ valid_edges = True
644
+ edges_list = list(graph.iter_relationships())
645
+ for edge in edges_list:
646
+ if edge.source not in node_ids:
647
+ console.print(
648
+ f" [red]✗[/red] Invalid edge: source '{edge.source}' not in index"
649
+ )
650
+ valid_edges = False
651
+ if edge.target not in node_ids:
652
+ console.print(
653
+ f" [red]✗[/red] Invalid edge: target '{edge.target}' not in index"
654
+ )
655
+ valid_edges = False
656
+
657
+ if valid_edges:
658
+ console.print(" ✓ All relationships valid")
659
+
660
+ # Check 2: Detect cycles in depends_on relationships
661
+ depends_edges = [e for e in edges_list if e.type == "depends_on"]
662
+ if depends_edges:
663
+ cycles = _detect_cycles(graph, depends_edges)
664
+ if cycles:
665
+ console.print(
666
+ f" [yellow]⚠[/yellow] {len(cycles)} cycle(s) detected in depends_on relationships"
667
+ )
668
+ for cycle in cycles[:3]: # Show first 3
669
+ console.print(f" {' → '.join(cycle)}")
670
+ else:
671
+ console.print(" ✓ No cycles detected")
672
+
673
+ # Check 3: Reachability
674
+ console.print(f" ✓ {graph.node_count}/{graph.node_count} concepts reachable")
675
+
676
+ # Check 4: Completeness — expected node types present
677
+ expected_types: dict[str, int] = {
678
+ "architecture": 1, # ≥1 arch-* node
679
+ "module": 1, # ≥1 mod-* node
680
+ "release": 1, # ≥1 rel-* node
681
+ }
682
+ type_counts: dict[str, int] = {}
683
+ for node in graph.iter_concepts():
684
+ type_counts[node.type] = type_counts.get(node.type, 0) + 1
685
+
686
+ missing: list[tuple[str, int, int]] = []
687
+ for node_type, min_count in expected_types.items():
688
+ actual = type_counts.get(node_type, 0)
689
+ if actual < min_count:
690
+ missing.append((node_type, min_count, actual))
691
+
692
+ if missing:
693
+ console.print(" [yellow]⚠[/yellow] Completeness gaps:")
694
+ for node_type, expected, actual in missing:
695
+ console.print(f" {node_type}: expected ≥{expected}, found {actual}")
696
+ else:
697
+ console.print(" ✓ Completeness check passed")
698
+
699
+ console.print("\n[green]Memory index is valid.[/green]\n")
700
+
701
+
702
+ def _detect_cycles(graph: Graph, edges: list[GraphEdge]) -> list[list[str]]:
703
+ """Detect cycles in a set of edges using iterative DFS.
704
+
705
+ Iterative (not recursive) to avoid RecursionError on large graphs.
706
+ Complexity: O(V + E).
707
+ """
708
+ adj: dict[str, list[str]] = {}
709
+ for edge in edges:
710
+ adj.setdefault(edge.source, []).append(edge.target)
711
+
712
+ cycles: list[list[str]] = []
713
+ node_ids = {node.id for node in graph.iter_concepts()}
714
+
715
+ for start in node_ids:
716
+ if start not in adj:
717
+ continue
718
+ visited: set[str] = set()
719
+ stack: list[tuple[str, list[str]]] = [(start, [start])]
720
+ while stack:
721
+ node, path = stack.pop()
722
+ if node in visited:
723
+ continue
724
+ visited.add(node)
725
+ for neighbor in adj.get(node, []):
726
+ if neighbor in path:
727
+ cycle_start = path.index(neighbor)
728
+ cycles.append(path[cycle_start:] + [neighbor])
729
+ else:
730
+ stack.append((neighbor, path + [neighbor]))
731
+
732
+ return cycles
733
+
734
+
735
+ @graph_app.command()
736
+ def extract(
737
+ file_path: Annotated[
738
+ Path | None,
739
+ typer.Argument(
740
+ help="Path to governance file (optional, extracts all if not provided)"
741
+ ),
742
+ ] = None,
743
+ format: Annotated[
744
+ str,
745
+ typer.Option("--format", "-f", help="Output format (human or json)"),
746
+ ] = "human",
747
+ ) -> None:
748
+ """Extract concepts from governance markdown files.
749
+
750
+ If no file path is provided, extracts from all standard governance locations:
751
+ - governance/prd.md (requirements)
752
+ - governance/vision.md (outcomes)
753
+ - framework/reference/constitution.md (principles)
754
+
755
+ Examples:
756
+ # Extract from all governance files
757
+ $ rai graph extract
758
+
759
+ # Extract from specific file
760
+ $ rai graph extract governance/prd.md
761
+
762
+ # Output as JSON
763
+ $ rai graph extract --format json
764
+ """
765
+ extractor = GovernanceExtractor()
766
+
767
+ if file_path:
768
+ # Extract from single file
769
+ if not file_path.exists():
770
+ cli_error(f"File not found: {file_path}", exit_code=4)
771
+
772
+ concepts = extractor.extract_from_file(file_path)
773
+
774
+ if format == "json":
775
+ output = {
776
+ "concepts": [
777
+ {
778
+ "id": c.id,
779
+ "type": c.type.value,
780
+ "file": c.file,
781
+ "section": c.section,
782
+ "lines": list(c.lines),
783
+ "content": c.content,
784
+ "metadata": c.metadata,
785
+ }
786
+ for c in concepts
787
+ ],
788
+ "total": len(concepts),
789
+ }
790
+ console.print(json.dumps(output, indent=2))
791
+ else:
792
+ console.print(
793
+ f"\nExtracting concepts from [cyan]{file_path.name}[/cyan]..."
794
+ )
795
+
796
+ for concept in concepts:
797
+ console.print(
798
+ f" ✓ Found {concept.metadata.get('requirement_id') or concept.metadata.get('principle_number') or concept.section}"
799
+ )
800
+
801
+ console.print(f"→ Extracted [green]{len(concepts)}[/green] concepts\n")
802
+
803
+ else:
804
+ # Extract from all governance files
805
+ result = extractor.extract_with_result()
806
+
807
+ if format == "json":
808
+ output = {
809
+ "concepts": [
810
+ {
811
+ "id": c.id,
812
+ "type": c.type.value,
813
+ "file": c.file,
814
+ "section": c.section,
815
+ "lines": list(c.lines),
816
+ "content": c.content,
817
+ "metadata": c.metadata,
818
+ }
819
+ for c in result.concepts
820
+ ],
821
+ "total": result.total,
822
+ "files_processed": result.files_processed,
823
+ "errors": result.errors,
824
+ }
825
+ console.print(json.dumps(output, indent=2))
826
+ else:
827
+ console.print("\nExtracting concepts from governance files...")
828
+
829
+ # Group concepts by type
830
+ by_type: dict[ConceptType, list[Concept]] = {}
831
+ for concept in result.concepts:
832
+ by_type.setdefault(concept.type, []).append(concept)
833
+
834
+ if ConceptType.REQUIREMENT in by_type:
835
+ reqs = by_type[ConceptType.REQUIREMENT]
836
+ console.print(f" 📄 prd.md → [green]{len(reqs)}[/green] requirements")
837
+
838
+ if ConceptType.OUTCOME in by_type:
839
+ outcomes = by_type[ConceptType.OUTCOME]
840
+ console.print(
841
+ f" 📄 vision.md → [green]{len(outcomes)}[/green] outcomes"
842
+ )
843
+
844
+ if ConceptType.PRINCIPLE in by_type:
845
+ principles = by_type[ConceptType.PRINCIPLE]
846
+ console.print(
847
+ f" 📄 constitution.md → [green]{len(principles)}[/green] principles"
848
+ )
849
+
850
+ console.print(
851
+ f"→ Total: [green]{result.total}[/green] concepts extracted\n"
852
+ )
853
+
854
+ if result.errors:
855
+ console.print("[yellow]Warnings:[/yellow]")
856
+ for error in result.errors:
857
+ console.print(f" ⚠ {error}")
858
+
859
+
860
+ # =============================================================================
861
+ # List Command
862
+ # =============================================================================
863
+
864
+
865
+ @graph_app.command("list")
866
+ def list_graph(
867
+ format: Annotated[
868
+ str,
869
+ typer.Option("--format", "-f", help="Output format (human, json, or table)"),
870
+ ] = "table",
871
+ output: Annotated[
872
+ Path | None,
873
+ typer.Option("--output", "-o", help="Output file path (default: stdout)"),
874
+ ] = None,
875
+ index_path: Annotated[
876
+ Path | None,
877
+ typer.Option("--index", "-i", help="Graph index path"),
878
+ ] = None,
879
+ memory_only: Annotated[
880
+ bool,
881
+ typer.Option(
882
+ "--memory-only/--all",
883
+ help="Show only memory types (pattern, calibration, session) or all",
884
+ ),
885
+ ] = False,
886
+ ) -> None:
887
+ """List concepts in the knowledge graph.
888
+
889
+ Shows concepts from the graph index for inspection and debugging.
890
+
891
+ Examples:
892
+ # Show summary table (all concepts)
893
+ $ rai graph list
894
+
895
+ # Show only patterns/calibrations/sessions
896
+ $ rai graph list --memory-only
897
+
898
+ # Export as JSON
899
+ $ rai graph list --format json --output graph.json
900
+
901
+ # Export as human-readable markdown
902
+ $ rai graph list --format human --output graph.md
903
+ """
904
+ # Resolve index path
905
+ unified_path = index_path or _get_default_index_path()
906
+ if not unified_path.exists():
907
+ cli_error(
908
+ f"Graph index not found: {unified_path}",
909
+ hint="Run 'rai graph build' first to create the index",
910
+ exit_code=4,
911
+ )
912
+
913
+ # Load unified graph
914
+ try:
915
+ graph = get_active_backend(unified_path).load()
916
+ except Exception as e:
917
+ cli_error(f"Error loading graph index: {e}")
918
+
919
+ # Filter to memory types only if requested (inlined — single-use constant)
920
+ if memory_only:
921
+ concepts = [
922
+ c
923
+ for c in graph.iter_concepts()
924
+ if c.type in ["pattern", "calibration", "session"]
925
+ ]
926
+ else:
927
+ concepts = list(graph.iter_concepts())
928
+
929
+ # Agent format: type|count summary, skip Rich headers
930
+ if format == "agent":
931
+ output_text = _format_concepts_agent(concepts)
932
+ if output:
933
+ output.write_text(output_text, encoding="utf-8")
934
+ elif output_text:
935
+ print(output_text)
936
+ else:
937
+ print("empty")
938
+ return
939
+
940
+ console.print(f"\nGraph from: [cyan]{unified_path}[/cyan]")
941
+ console.print(f"Concepts: [yellow]{len(concepts)}[/yellow]\n")
942
+
943
+ # Format output
944
+ if format == "json":
945
+ output_text = json.dumps(
946
+ [c.model_dump(mode="json") for c in concepts],
947
+ indent=2,
948
+ )
949
+ elif format == "human":
950
+ output_text = _format_concepts_markdown(concepts)
951
+ else: # table
952
+ _print_concepts_table(concepts)
953
+ if output:
954
+ # For file output in table mode, use markdown
955
+ output_text = _format_concepts_markdown(concepts)
956
+ else:
957
+ return
958
+
959
+ # Write to file or stdout
960
+ if output:
961
+ output.write_text(output_text, encoding="utf-8")
962
+ console.print(f"✓ Graph written to [cyan]{output}[/cyan]\n")
963
+ elif format != "table":
964
+ console.print(output_text)
965
+
966
+
967
+ def _format_concepts_agent(concepts: list[GraphNode]) -> str:
968
+ """Format concepts as type|count summary for agent consumption."""
969
+ if not concepts:
970
+ return ""
971
+ by_type: dict[str, int] = {}
972
+ for c in concepts:
973
+ by_type[c.type] = by_type.get(c.type, 0) + 1
974
+ return "\n".join(
975
+ f"{t}|{n}" for t, n in sorted(by_type.items(), key=lambda x: -x[1])
976
+ )
977
+
978
+
979
+ def _format_concepts_markdown(concepts: list[GraphNode]) -> str:
980
+ """Format concepts list as markdown."""
981
+ lines = ["# Graph Concepts\n"]
982
+ lines.append(f"**Total:** {len(concepts)}\n")
983
+
984
+ # Group by type
985
+ by_type: dict[str, list[GraphNode]] = {}
986
+ for concept in concepts:
987
+ type_name = concept.type
988
+ if type_name not in by_type:
989
+ by_type[type_name] = []
990
+ by_type[type_name].append(concept)
991
+
992
+ lines.append("## Concepts by Type\n")
993
+ for type_name, type_concepts in sorted(by_type.items()):
994
+ lines.append(f"### {type_name.title()} ({len(type_concepts)})\n")
995
+ for concept in sorted(type_concepts, key=lambda c: c.id):
996
+ content = (
997
+ concept.content[:60] + "..."
998
+ if len(concept.content) > 60
999
+ else concept.content
1000
+ )
1001
+ lines.append(f"- **{concept.id}**: {content}")
1002
+ lines.append("")
1003
+
1004
+ return "\n".join(lines)
1005
+
1006
+
1007
+ def _print_concepts_table(concepts: list[GraphNode]) -> None:
1008
+ """Print concepts as rich table."""
1009
+ table = Table(title="Graph Concepts")
1010
+ table.add_column("ID", style="cyan")
1011
+ table.add_column("Type", style="yellow")
1012
+ table.add_column("Content", max_width=50)
1013
+ table.add_column("Created")
1014
+
1015
+ for concept in sorted(concepts, key=lambda c: c.id):
1016
+ content = (
1017
+ concept.content[:47] + "..."
1018
+ if len(concept.content) > 50
1019
+ else concept.content
1020
+ )
1021
+ table.add_row(
1022
+ concept.id,
1023
+ concept.type,
1024
+ content,
1025
+ concept.created,
1026
+ )
1027
+
1028
+ console.print(table)
1029
+
1030
+
1031
+ # =============================================================================
1032
+ # Visualization Command
1033
+ # =============================================================================
1034
+
1035
+
1036
+ @graph_app.command("viz")
1037
+ def viz(
1038
+ output: Annotated[
1039
+ Path | None,
1040
+ typer.Option("--output", "-o", help="Output HTML file path"),
1041
+ ] = None,
1042
+ index_path: Annotated[
1043
+ Path | None,
1044
+ typer.Option("--index", "-i", help="Graph index path"),
1045
+ ] = None,
1046
+ open_browser: Annotated[
1047
+ bool,
1048
+ typer.Option("--open/--no-open", help="Open in browser after generating"),
1049
+ ] = True,
1050
+ ) -> None:
1051
+ """Generate interactive HTML visualization of the knowledge graph.
1052
+
1053
+ Creates a self-contained HTML file with a D3.js force-directed graph.
1054
+ Nodes are color-coded by type, filterable, zoomable, and searchable.
1055
+
1056
+ Examples:
1057
+ # Generate and open in browser
1058
+ $ rai graph viz
1059
+
1060
+ # Generate to specific path
1061
+ $ rai graph viz --output graph.html
1062
+
1063
+ # Generate without opening
1064
+ $ rai graph viz --no-open
1065
+ """
1066
+ import webbrowser
1067
+
1068
+ from raise_cli.viz import generate_viz_html
1069
+
1070
+ unified_path = index_path or _get_default_index_path()
1071
+ if not unified_path.exists():
1072
+ cli_error(
1073
+ f"Graph index not found: {unified_path}",
1074
+ hint="Run 'rai graph build' first to create the index",
1075
+ exit_code=4,
1076
+ )
1077
+
1078
+ output_path = output or Path(".raise/rai/memory/graph.html")
1079
+
1080
+ console.print(f"\nGenerating visualization from [cyan]{unified_path}[/cyan]...")
1081
+ result_path = generate_viz_html(unified_path, output_path)
1082
+ console.print(f"✓ Written to [cyan]{result_path}[/cyan]\n")
1083
+
1084
+ if open_browser:
1085
+ webbrowser.open(to_file_uri(result_path))
1086
+ console.print(" Opened in browser.\n")