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.
- raise_cli/__init__.py +38 -0
- raise_cli/__main__.py +30 -0
- raise_cli/adapters/__init__.py +91 -0
- raise_cli/adapters/declarative/__init__.py +26 -0
- raise_cli/adapters/declarative/adapter.py +267 -0
- raise_cli/adapters/declarative/discovery.py +94 -0
- raise_cli/adapters/declarative/expressions.py +150 -0
- raise_cli/adapters/declarative/reference/__init__.py +1 -0
- raise_cli/adapters/declarative/reference/github.yaml +143 -0
- raise_cli/adapters/declarative/schema.py +98 -0
- raise_cli/adapters/filesystem.py +299 -0
- raise_cli/adapters/mcp_bridge.py +10 -0
- raise_cli/adapters/mcp_confluence.py +246 -0
- raise_cli/adapters/mcp_jira.py +405 -0
- raise_cli/adapters/models.py +205 -0
- raise_cli/adapters/protocols.py +180 -0
- raise_cli/adapters/registry.py +90 -0
- raise_cli/adapters/sync.py +149 -0
- raise_cli/agents/__init__.py +14 -0
- raise_cli/agents/antigravity.yaml +8 -0
- raise_cli/agents/claude.yaml +8 -0
- raise_cli/agents/copilot.yaml +8 -0
- raise_cli/agents/copilot_plugin.py +124 -0
- raise_cli/agents/cursor.yaml +7 -0
- raise_cli/agents/roo.yaml +8 -0
- raise_cli/agents/windsurf.yaml +8 -0
- raise_cli/artifacts/__init__.py +30 -0
- raise_cli/artifacts/models.py +43 -0
- raise_cli/artifacts/reader.py +55 -0
- raise_cli/artifacts/renderer.py +104 -0
- raise_cli/artifacts/story_design.py +69 -0
- raise_cli/artifacts/writer.py +45 -0
- raise_cli/backlog/__init__.py +1 -0
- raise_cli/backlog/sync.py +115 -0
- raise_cli/cli/__init__.py +3 -0
- raise_cli/cli/commands/__init__.py +3 -0
- raise_cli/cli/commands/_resolve.py +153 -0
- raise_cli/cli/commands/adapters.py +362 -0
- raise_cli/cli/commands/artifact.py +137 -0
- raise_cli/cli/commands/backlog.py +333 -0
- raise_cli/cli/commands/base.py +31 -0
- raise_cli/cli/commands/discover.py +551 -0
- raise_cli/cli/commands/docs.py +130 -0
- raise_cli/cli/commands/doctor.py +177 -0
- raise_cli/cli/commands/gate.py +223 -0
- raise_cli/cli/commands/graph.py +1086 -0
- raise_cli/cli/commands/info.py +81 -0
- raise_cli/cli/commands/init.py +746 -0
- raise_cli/cli/commands/journal.py +167 -0
- raise_cli/cli/commands/mcp.py +524 -0
- raise_cli/cli/commands/memory.py +467 -0
- raise_cli/cli/commands/pattern.py +348 -0
- raise_cli/cli/commands/profile.py +59 -0
- raise_cli/cli/commands/publish.py +80 -0
- raise_cli/cli/commands/release.py +338 -0
- raise_cli/cli/commands/session.py +528 -0
- raise_cli/cli/commands/signal.py +410 -0
- raise_cli/cli/commands/skill.py +350 -0
- raise_cli/cli/commands/skill_set.py +145 -0
- raise_cli/cli/error_handler.py +158 -0
- raise_cli/cli/main.py +163 -0
- raise_cli/compat.py +66 -0
- raise_cli/config/__init__.py +41 -0
- raise_cli/config/agent_plugin.py +105 -0
- raise_cli/config/agent_registry.py +233 -0
- raise_cli/config/agents.py +120 -0
- raise_cli/config/ide.py +32 -0
- raise_cli/config/paths.py +379 -0
- raise_cli/config/settings.py +180 -0
- raise_cli/context/__init__.py +42 -0
- raise_cli/context/analyzers/__init__.py +16 -0
- raise_cli/context/analyzers/models.py +36 -0
- raise_cli/context/analyzers/protocol.py +43 -0
- raise_cli/context/analyzers/python.py +292 -0
- raise_cli/context/builder.py +1569 -0
- raise_cli/context/diff.py +213 -0
- raise_cli/context/extractors/__init__.py +13 -0
- raise_cli/context/extractors/skills.py +121 -0
- raise_cli/core/__init__.py +37 -0
- raise_cli/core/files.py +66 -0
- raise_cli/core/text.py +174 -0
- raise_cli/core/tools.py +441 -0
- raise_cli/discovery/__init__.py +50 -0
- raise_cli/discovery/analyzer.py +691 -0
- raise_cli/discovery/drift.py +355 -0
- raise_cli/discovery/scanner.py +1687 -0
- raise_cli/doctor/__init__.py +4 -0
- raise_cli/doctor/checks/__init__.py +1 -0
- raise_cli/doctor/checks/environment.py +110 -0
- raise_cli/doctor/checks/project.py +238 -0
- raise_cli/doctor/fix.py +80 -0
- raise_cli/doctor/models.py +56 -0
- raise_cli/doctor/protocol.py +43 -0
- raise_cli/doctor/registry.py +100 -0
- raise_cli/doctor/report.py +141 -0
- raise_cli/doctor/runner.py +95 -0
- raise_cli/engines/__init__.py +3 -0
- raise_cli/exceptions.py +215 -0
- raise_cli/gates/__init__.py +19 -0
- raise_cli/gates/builtin/__init__.py +1 -0
- raise_cli/gates/builtin/coverage.py +52 -0
- raise_cli/gates/builtin/lint.py +48 -0
- raise_cli/gates/builtin/tests.py +48 -0
- raise_cli/gates/builtin/types.py +48 -0
- raise_cli/gates/models.py +40 -0
- raise_cli/gates/protocol.py +41 -0
- raise_cli/gates/registry.py +141 -0
- raise_cli/governance/__init__.py +11 -0
- raise_cli/governance/extractor.py +412 -0
- raise_cli/governance/models.py +134 -0
- raise_cli/governance/parsers/__init__.py +35 -0
- raise_cli/governance/parsers/_convert.py +38 -0
- raise_cli/governance/parsers/adr.py +274 -0
- raise_cli/governance/parsers/backlog.py +356 -0
- raise_cli/governance/parsers/constitution.py +119 -0
- raise_cli/governance/parsers/epic.py +323 -0
- raise_cli/governance/parsers/glossary.py +316 -0
- raise_cli/governance/parsers/guardrails.py +345 -0
- raise_cli/governance/parsers/prd.py +112 -0
- raise_cli/governance/parsers/roadmap.py +118 -0
- raise_cli/governance/parsers/vision.py +116 -0
- raise_cli/graph/__init__.py +1 -0
- raise_cli/graph/backends/__init__.py +57 -0
- raise_cli/graph/backends/api.py +137 -0
- raise_cli/graph/backends/dual.py +139 -0
- raise_cli/graph/backends/pending.py +84 -0
- raise_cli/handlers/__init__.py +3 -0
- raise_cli/hooks/__init__.py +54 -0
- raise_cli/hooks/builtin/__init__.py +1 -0
- raise_cli/hooks/builtin/backlog.py +216 -0
- raise_cli/hooks/builtin/gate_bridge.py +83 -0
- raise_cli/hooks/builtin/jira_sync.py +127 -0
- raise_cli/hooks/builtin/memory.py +117 -0
- raise_cli/hooks/builtin/telemetry.py +72 -0
- raise_cli/hooks/emitter.py +184 -0
- raise_cli/hooks/events.py +262 -0
- raise_cli/hooks/protocol.py +38 -0
- raise_cli/hooks/registry.py +117 -0
- raise_cli/mcp/__init__.py +33 -0
- raise_cli/mcp/bridge.py +218 -0
- raise_cli/mcp/models.py +43 -0
- raise_cli/mcp/registry.py +77 -0
- raise_cli/mcp/schema.py +41 -0
- raise_cli/memory/__init__.py +58 -0
- raise_cli/memory/loader.py +247 -0
- raise_cli/memory/migration.py +241 -0
- raise_cli/memory/models.py +169 -0
- raise_cli/memory/writer.py +598 -0
- raise_cli/onboarding/__init__.py +103 -0
- raise_cli/onboarding/bootstrap.py +324 -0
- raise_cli/onboarding/claudemd.py +17 -0
- raise_cli/onboarding/conventions.py +742 -0
- raise_cli/onboarding/detection.py +374 -0
- raise_cli/onboarding/governance.py +443 -0
- raise_cli/onboarding/instructions.py +672 -0
- raise_cli/onboarding/manifest.py +201 -0
- raise_cli/onboarding/memory_md.py +399 -0
- raise_cli/onboarding/migration.py +207 -0
- raise_cli/onboarding/profile.py +624 -0
- raise_cli/onboarding/skill_conflict.py +100 -0
- raise_cli/onboarding/skill_manifest.py +176 -0
- raise_cli/onboarding/skills.py +437 -0
- raise_cli/onboarding/workflows.py +101 -0
- raise_cli/output/__init__.py +28 -0
- raise_cli/output/console.py +394 -0
- raise_cli/output/formatters/__init__.py +9 -0
- raise_cli/output/formatters/adapters.py +135 -0
- raise_cli/output/formatters/discover.py +439 -0
- raise_cli/output/formatters/skill.py +298 -0
- raise_cli/publish/__init__.py +3 -0
- raise_cli/publish/changelog.py +80 -0
- raise_cli/publish/check.py +179 -0
- raise_cli/publish/version.py +172 -0
- raise_cli/rai_base/__init__.py +22 -0
- raise_cli/rai_base/framework/__init__.py +7 -0
- raise_cli/rai_base/framework/methodology.yaml +233 -0
- raise_cli/rai_base/governance/__init__.py +1 -0
- raise_cli/rai_base/governance/architecture/__init__.py +1 -0
- raise_cli/rai_base/governance/architecture/domain-model.md +20 -0
- raise_cli/rai_base/governance/architecture/system-context.md +34 -0
- raise_cli/rai_base/governance/architecture/system-design.md +24 -0
- raise_cli/rai_base/governance/backlog.md +8 -0
- raise_cli/rai_base/governance/guardrails.md +17 -0
- raise_cli/rai_base/governance/prd.md +25 -0
- raise_cli/rai_base/governance/vision.md +16 -0
- raise_cli/rai_base/identity/__init__.py +8 -0
- raise_cli/rai_base/identity/core.md +119 -0
- raise_cli/rai_base/identity/perspective.md +119 -0
- raise_cli/rai_base/memory/__init__.py +7 -0
- raise_cli/rai_base/memory/patterns-base.jsonl +55 -0
- raise_cli/schemas/__init__.py +3 -0
- raise_cli/schemas/journal.py +49 -0
- raise_cli/schemas/session_state.py +117 -0
- raise_cli/session/__init__.py +5 -0
- raise_cli/session/bundle.py +820 -0
- raise_cli/session/close.py +268 -0
- raise_cli/session/journal.py +119 -0
- raise_cli/session/resolver.py +126 -0
- raise_cli/session/state.py +187 -0
- raise_cli/skills/__init__.py +44 -0
- raise_cli/skills/locator.py +141 -0
- raise_cli/skills/name_checker.py +199 -0
- raise_cli/skills/parser.py +145 -0
- raise_cli/skills/scaffold.py +212 -0
- raise_cli/skills/schema.py +132 -0
- raise_cli/skills/skillsets.py +195 -0
- raise_cli/skills/validator.py +197 -0
- raise_cli/skills_base/__init__.py +80 -0
- raise_cli/skills_base/contract-template.md +60 -0
- raise_cli/skills_base/preamble.md +37 -0
- raise_cli/skills_base/rai-architecture-review/SKILL.md +137 -0
- raise_cli/skills_base/rai-debug/SKILL.md +171 -0
- raise_cli/skills_base/rai-discover/SKILL.md +167 -0
- raise_cli/skills_base/rai-discover-document/SKILL.md +128 -0
- raise_cli/skills_base/rai-discover-scan/SKILL.md +147 -0
- raise_cli/skills_base/rai-discover-start/SKILL.md +145 -0
- raise_cli/skills_base/rai-discover-validate/SKILL.md +142 -0
- raise_cli/skills_base/rai-docs-update/SKILL.md +142 -0
- raise_cli/skills_base/rai-doctor/SKILL.md +120 -0
- raise_cli/skills_base/rai-epic-close/SKILL.md +165 -0
- raise_cli/skills_base/rai-epic-close/templates/retrospective.md +68 -0
- raise_cli/skills_base/rai-epic-design/SKILL.md +146 -0
- raise_cli/skills_base/rai-epic-design/templates/design.md +24 -0
- raise_cli/skills_base/rai-epic-design/templates/scope.md +76 -0
- raise_cli/skills_base/rai-epic-plan/SKILL.md +153 -0
- raise_cli/skills_base/rai-epic-plan/_references/sequencing-strategies.md +67 -0
- raise_cli/skills_base/rai-epic-plan/templates/plan-section.md +49 -0
- raise_cli/skills_base/rai-epic-run/SKILL.md +208 -0
- raise_cli/skills_base/rai-epic-start/SKILL.md +136 -0
- raise_cli/skills_base/rai-epic-start/templates/brief.md +34 -0
- raise_cli/skills_base/rai-mcp-add/SKILL.md +176 -0
- raise_cli/skills_base/rai-mcp-remove/SKILL.md +120 -0
- raise_cli/skills_base/rai-mcp-status/SKILL.md +147 -0
- raise_cli/skills_base/rai-problem-shape/SKILL.md +138 -0
- raise_cli/skills_base/rai-project-create/SKILL.md +144 -0
- raise_cli/skills_base/rai-project-onboard/SKILL.md +162 -0
- raise_cli/skills_base/rai-quality-review/SKILL.md +189 -0
- raise_cli/skills_base/rai-research/SKILL.md +143 -0
- raise_cli/skills_base/rai-research/references/research-prompt-template.md +317 -0
- raise_cli/skills_base/rai-session-close/SKILL.md +176 -0
- raise_cli/skills_base/rai-session-start/SKILL.md +110 -0
- raise_cli/skills_base/rai-story-close/SKILL.md +198 -0
- raise_cli/skills_base/rai-story-design/SKILL.md +203 -0
- raise_cli/skills_base/rai-story-design/references/tech-design-story-v2.md +293 -0
- raise_cli/skills_base/rai-story-implement/SKILL.md +115 -0
- raise_cli/skills_base/rai-story-plan/SKILL.md +135 -0
- raise_cli/skills_base/rai-story-review/SKILL.md +178 -0
- raise_cli/skills_base/rai-story-run/SKILL.md +282 -0
- raise_cli/skills_base/rai-story-start/SKILL.md +166 -0
- raise_cli/skills_base/rai-story-start/templates/story.md +38 -0
- raise_cli/skills_base/rai-welcome/SKILL.md +134 -0
- raise_cli/telemetry/__init__.py +42 -0
- raise_cli/telemetry/schemas.py +285 -0
- raise_cli/telemetry/writer.py +217 -0
- raise_cli/tier/__init__.py +0 -0
- raise_cli/tier/context.py +134 -0
- raise_cli/viz/__init__.py +7 -0
- raise_cli/viz/generator.py +406 -0
- raise_cli-2.2.1.dist-info/METADATA +433 -0
- raise_cli-2.2.1.dist-info/RECORD +264 -0
- raise_cli-2.2.1.dist-info/WHEEL +4 -0
- raise_cli-2.2.1.dist-info/entry_points.txt +40 -0
- raise_cli-2.2.1.dist-info/licenses/LICENSE +190 -0
- 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")
|