rai-cli 2.0.0a1__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.
- rai_cli/__init__.py +38 -0
- rai_cli/__main__.py +30 -0
- rai_cli/cli/__init__.py +3 -0
- rai_cli/cli/commands/__init__.py +3 -0
- rai_cli/cli/commands/base.py +101 -0
- rai_cli/cli/commands/discover.py +547 -0
- rai_cli/cli/commands/init.py +460 -0
- rai_cli/cli/commands/memory.py +1626 -0
- rai_cli/cli/commands/profile.py +51 -0
- rai_cli/cli/commands/session.py +264 -0
- rai_cli/cli/commands/skill.py +226 -0
- rai_cli/cli/error_handler.py +158 -0
- rai_cli/cli/main.py +137 -0
- rai_cli/config/__init__.py +11 -0
- rai_cli/config/paths.py +309 -0
- rai_cli/config/settings.py +180 -0
- rai_cli/context/__init__.py +42 -0
- rai_cli/context/analyzers/__init__.py +16 -0
- rai_cli/context/analyzers/models.py +36 -0
- rai_cli/context/analyzers/protocol.py +43 -0
- rai_cli/context/analyzers/python.py +291 -0
- rai_cli/context/builder.py +1566 -0
- rai_cli/context/diff.py +213 -0
- rai_cli/context/extractors/__init__.py +13 -0
- rai_cli/context/extractors/skills.py +121 -0
- rai_cli/context/graph.py +300 -0
- rai_cli/context/models.py +134 -0
- rai_cli/context/query.py +507 -0
- rai_cli/core/__init__.py +37 -0
- rai_cli/core/files.py +66 -0
- rai_cli/core/text.py +174 -0
- rai_cli/core/tools.py +441 -0
- rai_cli/discovery/__init__.py +50 -0
- rai_cli/discovery/analyzer.py +601 -0
- rai_cli/discovery/drift.py +355 -0
- rai_cli/discovery/scanner.py +1200 -0
- rai_cli/engines/__init__.py +3 -0
- rai_cli/exceptions.py +200 -0
- rai_cli/governance/__init__.py +11 -0
- rai_cli/governance/extractor.py +311 -0
- rai_cli/governance/models.py +132 -0
- rai_cli/governance/parsers/__init__.py +35 -0
- rai_cli/governance/parsers/adr.py +255 -0
- rai_cli/governance/parsers/backlog.py +302 -0
- rai_cli/governance/parsers/constitution.py +100 -0
- rai_cli/governance/parsers/epic.py +299 -0
- rai_cli/governance/parsers/glossary.py +297 -0
- rai_cli/governance/parsers/guardrails.py +326 -0
- rai_cli/governance/parsers/prd.py +93 -0
- rai_cli/governance/parsers/vision.py +97 -0
- rai_cli/handlers/__init__.py +3 -0
- rai_cli/memory/__init__.py +58 -0
- rai_cli/memory/loader.py +247 -0
- rai_cli/memory/migration.py +247 -0
- rai_cli/memory/models.py +169 -0
- rai_cli/memory/writer.py +485 -0
- rai_cli/onboarding/__init__.py +96 -0
- rai_cli/onboarding/bootstrap.py +164 -0
- rai_cli/onboarding/claudemd.py +209 -0
- rai_cli/onboarding/conventions.py +742 -0
- rai_cli/onboarding/detection.py +155 -0
- rai_cli/onboarding/governance.py +443 -0
- rai_cli/onboarding/manifest.py +101 -0
- rai_cli/onboarding/memory_md.py +387 -0
- rai_cli/onboarding/migration.py +207 -0
- rai_cli/onboarding/profile.py +457 -0
- rai_cli/onboarding/skills.py +114 -0
- rai_cli/output/__init__.py +28 -0
- rai_cli/output/console.py +394 -0
- rai_cli/output/formatters/__init__.py +9 -0
- rai_cli/output/formatters/discover.py +442 -0
- rai_cli/output/formatters/skill.py +293 -0
- rai_cli/rai_base/__init__.py +22 -0
- rai_cli/rai_base/framework/__init__.py +7 -0
- rai_cli/rai_base/framework/methodology.yaml +235 -0
- rai_cli/rai_base/governance/__init__.py +1 -0
- rai_cli/rai_base/governance/architecture/__init__.py +1 -0
- rai_cli/rai_base/governance/architecture/domain-model.md +20 -0
- rai_cli/rai_base/governance/architecture/system-context.md +34 -0
- rai_cli/rai_base/governance/architecture/system-design.md +24 -0
- rai_cli/rai_base/governance/backlog.md +8 -0
- rai_cli/rai_base/governance/guardrails.md +18 -0
- rai_cli/rai_base/governance/prd.md +25 -0
- rai_cli/rai_base/governance/vision.md +16 -0
- rai_cli/rai_base/identity/__init__.py +8 -0
- rai_cli/rai_base/identity/core.md +119 -0
- rai_cli/rai_base/identity/perspective.md +119 -0
- rai_cli/rai_base/memory/__init__.py +7 -0
- rai_cli/rai_base/memory/patterns-base.jsonl +20 -0
- rai_cli/schemas/__init__.py +3 -0
- rai_cli/schemas/session_state.py +106 -0
- rai_cli/session/__init__.py +5 -0
- rai_cli/session/bundle.py +389 -0
- rai_cli/session/close.py +255 -0
- rai_cli/session/state.py +108 -0
- rai_cli/skills/__init__.py +44 -0
- rai_cli/skills/locator.py +129 -0
- rai_cli/skills/name_checker.py +203 -0
- rai_cli/skills/parser.py +145 -0
- rai_cli/skills/scaffold.py +185 -0
- rai_cli/skills/schema.py +130 -0
- rai_cli/skills/validator.py +172 -0
- rai_cli/skills_base/__init__.py +59 -0
- rai_cli/skills_base/rai-debug/SKILL.md +296 -0
- rai_cli/skills_base/rai-discover-document/SKILL.md +292 -0
- rai_cli/skills_base/rai-discover-scan/SKILL.md +325 -0
- rai_cli/skills_base/rai-discover-start/SKILL.md +213 -0
- rai_cli/skills_base/rai-discover-validate/SKILL.md +310 -0
- rai_cli/skills_base/rai-epic-close/SKILL.md +369 -0
- rai_cli/skills_base/rai-epic-design/SKILL.md +622 -0
- rai_cli/skills_base/rai-epic-plan/SKILL.md +672 -0
- rai_cli/skills_base/rai-epic-plan/_references/sequencing-strategies.md +67 -0
- rai_cli/skills_base/rai-epic-start/SKILL.md +217 -0
- rai_cli/skills_base/rai-project-create/SKILL.md +455 -0
- rai_cli/skills_base/rai-project-onboard/SKILL.md +503 -0
- rai_cli/skills_base/rai-research/SKILL.md +264 -0
- rai_cli/skills_base/rai-research/references/research-prompt-template.md +317 -0
- rai_cli/skills_base/rai-session-close/SKILL.md +151 -0
- rai_cli/skills_base/rai-session-start/SKILL.md +110 -0
- rai_cli/skills_base/rai-story-close/SKILL.md +367 -0
- rai_cli/skills_base/rai-story-design/SKILL.md +339 -0
- rai_cli/skills_base/rai-story-design/references/tech-design-story-v2.md +293 -0
- rai_cli/skills_base/rai-story-implement/SKILL.md +256 -0
- rai_cli/skills_base/rai-story-plan/SKILL.md +307 -0
- rai_cli/skills_base/rai-story-review/SKILL.md +276 -0
- rai_cli/skills_base/rai-story-start/SKILL.md +288 -0
- rai_cli/telemetry/__init__.py +42 -0
- rai_cli/telemetry/schemas.py +285 -0
- rai_cli/telemetry/writer.py +210 -0
- rai_cli/viz/__init__.py +7 -0
- rai_cli/viz/generator.py +404 -0
- rai_cli-2.0.0a1.dist-info/METADATA +289 -0
- rai_cli-2.0.0a1.dist-info/RECORD +137 -0
- rai_cli-2.0.0a1.dist-info/WHEEL +4 -0
- rai_cli-2.0.0a1.dist-info/entry_points.txt +2 -0
- rai_cli-2.0.0a1.dist-info/licenses/LICENSE +190 -0
- rai_cli-2.0.0a1.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,1626 @@
|
|
|
1
|
+
"""CLI commands for Rai's memory: query, build, and manage.
|
|
2
|
+
|
|
3
|
+
Memory is the unified knowledge base containing:
|
|
4
|
+
- Governance (principles, requirements, terms)
|
|
5
|
+
- Patterns (learned behaviors and best practices)
|
|
6
|
+
- Calibration (estimation data)
|
|
7
|
+
- Sessions (work history)
|
|
8
|
+
- Skills (workflow metadata)
|
|
9
|
+
- Work (epics, stories, decisions)
|
|
10
|
+
|
|
11
|
+
The "graph" is an implementation detail — users interact with "memory".
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated, Literal
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
|
|
25
|
+
from rai_cli.cli.error_handler import cli_error
|
|
26
|
+
from rai_cli.config.paths import get_memory_dir, get_personal_dir
|
|
27
|
+
from rai_cli.context import UnifiedGraph, UnifiedGraphBuilder
|
|
28
|
+
from rai_cli.context.diff import GraphDiff, diff_graphs
|
|
29
|
+
from rai_cli.context.models import ConceptNode
|
|
30
|
+
from rai_cli.context.query import (
|
|
31
|
+
ArchitecturalContext,
|
|
32
|
+
UnifiedQuery,
|
|
33
|
+
UnifiedQueryEngine,
|
|
34
|
+
UnifiedQueryResult,
|
|
35
|
+
UnifiedQueryStrategy,
|
|
36
|
+
)
|
|
37
|
+
from rai_cli.governance import ConceptType, GovernanceExtractor
|
|
38
|
+
from rai_cli.memory import (
|
|
39
|
+
CalibrationInput,
|
|
40
|
+
MemoryScope,
|
|
41
|
+
PatternInput,
|
|
42
|
+
PatternSubType,
|
|
43
|
+
SessionInput,
|
|
44
|
+
append_calibration,
|
|
45
|
+
append_pattern,
|
|
46
|
+
append_session,
|
|
47
|
+
get_memory_dir_for_scope,
|
|
48
|
+
)
|
|
49
|
+
from rai_cli.onboarding.profile import load_developer_profile
|
|
50
|
+
from rai_cli.telemetry.schemas import (
|
|
51
|
+
CalibrationEvent,
|
|
52
|
+
SessionEvent,
|
|
53
|
+
WorkLifecycle,
|
|
54
|
+
)
|
|
55
|
+
from rai_cli.telemetry.writer import emit
|
|
56
|
+
|
|
57
|
+
# Memory types for filtering (when querying memory-only)
|
|
58
|
+
MEMORY_TYPES = ["pattern", "calibration", "session"]
|
|
59
|
+
|
|
60
|
+
# Default index file name
|
|
61
|
+
INDEX_FILE = "index.json"
|
|
62
|
+
|
|
63
|
+
memory_app = typer.Typer(
|
|
64
|
+
name="memory",
|
|
65
|
+
help="Query and manage Rai's memory",
|
|
66
|
+
no_args_is_help=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
console = Console()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _get_default_memory_dir() -> Path:
|
|
73
|
+
"""Get default memory directory (.raise/rai/memory)."""
|
|
74
|
+
return get_memory_dir()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _get_default_index_path() -> Path:
|
|
78
|
+
"""Get default memory index path (.raise/rai/memory/index.json)."""
|
|
79
|
+
return get_memory_dir() / INDEX_FILE
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# =============================================================================
|
|
83
|
+
# Query Commands
|
|
84
|
+
# =============================================================================
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@memory_app.command()
|
|
88
|
+
def query(
|
|
89
|
+
query_str: Annotated[
|
|
90
|
+
str, typer.Argument(help="Query string (keywords or concept ID)")
|
|
91
|
+
],
|
|
92
|
+
format: Annotated[
|
|
93
|
+
str,
|
|
94
|
+
typer.Option("--format", "-f", help="Output format (human or json)"),
|
|
95
|
+
] = "human",
|
|
96
|
+
output: Annotated[
|
|
97
|
+
Path | None,
|
|
98
|
+
typer.Option("--output", "-o", help="Output file path (default: stdout)"),
|
|
99
|
+
] = None,
|
|
100
|
+
strategy: Annotated[
|
|
101
|
+
str | None,
|
|
102
|
+
typer.Option(
|
|
103
|
+
"--strategy",
|
|
104
|
+
"-s",
|
|
105
|
+
help="Query strategy (keyword_search, concept_lookup)",
|
|
106
|
+
),
|
|
107
|
+
] = None,
|
|
108
|
+
types: Annotated[
|
|
109
|
+
str | None,
|
|
110
|
+
typer.Option(
|
|
111
|
+
"--types",
|
|
112
|
+
"-t",
|
|
113
|
+
help="Filter by types (comma-separated: pattern,calibration,principle,etc.)",
|
|
114
|
+
),
|
|
115
|
+
] = None,
|
|
116
|
+
edge_types: Annotated[
|
|
117
|
+
str | None,
|
|
118
|
+
typer.Option(
|
|
119
|
+
"--edge-types",
|
|
120
|
+
help="Filter by edge types (comma-separated: constrained_by,depends_on,etc.)",
|
|
121
|
+
),
|
|
122
|
+
] = None,
|
|
123
|
+
limit: Annotated[
|
|
124
|
+
int,
|
|
125
|
+
typer.Option("--limit", "-l", help="Maximum number of results"),
|
|
126
|
+
] = 10,
|
|
127
|
+
index_path: Annotated[
|
|
128
|
+
Path | None,
|
|
129
|
+
typer.Option("--index", "-i", help="Memory index path"),
|
|
130
|
+
] = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Query Rai's memory for relevant concepts.
|
|
133
|
+
|
|
134
|
+
Searches the unified memory containing all context sources:
|
|
135
|
+
- Governance (principles, requirements, terms)
|
|
136
|
+
- Memory (patterns, calibration, sessions)
|
|
137
|
+
- Skills (workflow metadata)
|
|
138
|
+
- Work (epics, stories, decisions)
|
|
139
|
+
|
|
140
|
+
Examples:
|
|
141
|
+
# Search by keywords
|
|
142
|
+
$ raise memory query "planning estimation"
|
|
143
|
+
|
|
144
|
+
# Filter to patterns only
|
|
145
|
+
$ raise memory query "testing" --types pattern,calibration
|
|
146
|
+
|
|
147
|
+
# Lookup specific concept by ID
|
|
148
|
+
$ raise memory query "PAT-001" --strategy concept_lookup
|
|
149
|
+
|
|
150
|
+
# Output as JSON
|
|
151
|
+
$ raise memory query "velocity" --format json
|
|
152
|
+
"""
|
|
153
|
+
# Load engine
|
|
154
|
+
unified_path = index_path or _get_default_index_path()
|
|
155
|
+
try:
|
|
156
|
+
engine = UnifiedQueryEngine.from_file(unified_path)
|
|
157
|
+
except FileNotFoundError as e:
|
|
158
|
+
cli_error(
|
|
159
|
+
str(e),
|
|
160
|
+
hint="Run 'raise memory build' first to create the index",
|
|
161
|
+
exit_code=4,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Parse types filter
|
|
165
|
+
types_list: list[str] | None = None
|
|
166
|
+
if types:
|
|
167
|
+
types_list = [t.strip() for t in types.split(",")]
|
|
168
|
+
|
|
169
|
+
# Determine strategy
|
|
170
|
+
query_strategy = UnifiedQueryStrategy.KEYWORD_SEARCH # Default
|
|
171
|
+
if strategy:
|
|
172
|
+
try:
|
|
173
|
+
query_strategy = UnifiedQueryStrategy(strategy)
|
|
174
|
+
except ValueError:
|
|
175
|
+
cli_error(
|
|
176
|
+
f"Invalid strategy: {strategy}",
|
|
177
|
+
hint="Valid strategies: keyword_search, concept_lookup",
|
|
178
|
+
exit_code=7,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Parse edge_types filter
|
|
182
|
+
edge_types_list: list[str] | None = None
|
|
183
|
+
if edge_types:
|
|
184
|
+
edge_types_list = [t.strip() for t in edge_types.split(",")]
|
|
185
|
+
|
|
186
|
+
# Build and execute query
|
|
187
|
+
unified_query = UnifiedQuery(
|
|
188
|
+
query=query_str,
|
|
189
|
+
strategy=query_strategy,
|
|
190
|
+
max_depth=1,
|
|
191
|
+
types=types_list, # type: ignore[arg-type]
|
|
192
|
+
edge_types=edge_types_list, # type: ignore[arg-type]
|
|
193
|
+
limit=limit,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
console.print(f"\nQuerying memory for: [cyan]{query_str}[/cyan]")
|
|
197
|
+
console.print(f"Strategy: [yellow]{query_strategy.value}[/yellow]\n")
|
|
198
|
+
|
|
199
|
+
result = engine.query(unified_query)
|
|
200
|
+
|
|
201
|
+
# Format output
|
|
202
|
+
output_text = _format_json(result) if format == "json" else _format_markdown(result)
|
|
203
|
+
|
|
204
|
+
# Write to file or stdout
|
|
205
|
+
if output:
|
|
206
|
+
output.write_text(output_text)
|
|
207
|
+
console.print(f"✓ Results written to [cyan]{output}[/cyan]")
|
|
208
|
+
console.print(f" Concepts: {result.metadata.total_concepts}")
|
|
209
|
+
console.print(f" Tokens: ~{result.metadata.token_estimate}")
|
|
210
|
+
console.print(f" Execution: {result.metadata.execution_time_ms:.2f}ms\n")
|
|
211
|
+
else:
|
|
212
|
+
console.print(output_text)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _format_markdown(result: UnifiedQueryResult) -> str:
|
|
216
|
+
"""Format query result as markdown for human consumption."""
|
|
217
|
+
lines: list[str] = []
|
|
218
|
+
|
|
219
|
+
# Header
|
|
220
|
+
lines.append("# Memory Query Results")
|
|
221
|
+
lines.append("")
|
|
222
|
+
lines.append(f"**Query:** `{result.metadata.query}`")
|
|
223
|
+
lines.append(f"**Strategy:** {result.metadata.strategy.value}")
|
|
224
|
+
|
|
225
|
+
# Types found summary
|
|
226
|
+
types_str = ", ".join(
|
|
227
|
+
f"{t}={c}" for t, c in sorted(result.metadata.types_found.items())
|
|
228
|
+
)
|
|
229
|
+
lines.append(
|
|
230
|
+
f"**Concepts:** {result.metadata.total_concepts} | "
|
|
231
|
+
f"**Tokens:** ~{result.metadata.token_estimate} | "
|
|
232
|
+
f"**Types:** {types_str}"
|
|
233
|
+
)
|
|
234
|
+
lines.append("")
|
|
235
|
+
lines.append("---")
|
|
236
|
+
lines.append("")
|
|
237
|
+
|
|
238
|
+
# No results
|
|
239
|
+
if not result.concepts:
|
|
240
|
+
lines.append("*No concepts found matching the query.*")
|
|
241
|
+
lines.append("")
|
|
242
|
+
return "\n".join(lines)
|
|
243
|
+
|
|
244
|
+
# Group concepts by type
|
|
245
|
+
by_type: dict[str, list[ConceptNode]] = {}
|
|
246
|
+
for concept in result.concepts:
|
|
247
|
+
by_type.setdefault(concept.type, []).append(concept)
|
|
248
|
+
|
|
249
|
+
# Render by type groups
|
|
250
|
+
for node_type in sorted(by_type.keys()):
|
|
251
|
+
concepts = by_type[node_type]
|
|
252
|
+
lines.append(f"## {node_type.title()} ({len(concepts)})")
|
|
253
|
+
lines.append("")
|
|
254
|
+
|
|
255
|
+
for concept in concepts:
|
|
256
|
+
# Concept header
|
|
257
|
+
lines.append(f"### {concept.id}")
|
|
258
|
+
source = concept.source_file or "unknown"
|
|
259
|
+
lines.append(f"**Source:** {source} | **Created:** {concept.created}")
|
|
260
|
+
lines.append("")
|
|
261
|
+
|
|
262
|
+
# Content (truncate if very long)
|
|
263
|
+
content = concept.content
|
|
264
|
+
if len(content) > 300:
|
|
265
|
+
content = content[:300] + "..."
|
|
266
|
+
lines.append(content)
|
|
267
|
+
lines.append("")
|
|
268
|
+
|
|
269
|
+
# Metadata annotations (if available)
|
|
270
|
+
if concept.metadata and "needs_context" in concept.metadata:
|
|
271
|
+
ctx = ", ".join(concept.metadata["needs_context"])
|
|
272
|
+
lines.append(f"*Needs context: {ctx}*")
|
|
273
|
+
lines.append("")
|
|
274
|
+
|
|
275
|
+
lines.append("---")
|
|
276
|
+
lines.append("")
|
|
277
|
+
|
|
278
|
+
# Footer with metadata
|
|
279
|
+
lines.append("**Query Metadata:**")
|
|
280
|
+
lines.append(f"- Execution time: {result.metadata.execution_time_ms:.2f}ms")
|
|
281
|
+
lines.append(f"- Token estimate: ~{result.metadata.token_estimate}")
|
|
282
|
+
lines.append("")
|
|
283
|
+
|
|
284
|
+
return "\n".join(lines)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _format_json(result: UnifiedQueryResult) -> str:
|
|
288
|
+
"""Format query result as JSON."""
|
|
289
|
+
return result.to_json()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# =============================================================================
|
|
293
|
+
# Architectural Context Command
|
|
294
|
+
# =============================================================================
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@memory_app.command("context")
|
|
298
|
+
def context_cmd(
|
|
299
|
+
module_id: Annotated[
|
|
300
|
+
str, typer.Argument(help="Module ID (e.g., mod-memory)")
|
|
301
|
+
],
|
|
302
|
+
format: Annotated[
|
|
303
|
+
str,
|
|
304
|
+
typer.Option("--format", "-f", help="Output format (human or json)"),
|
|
305
|
+
] = "human",
|
|
306
|
+
index_path: Annotated[
|
|
307
|
+
Path | None,
|
|
308
|
+
typer.Option("--index", "-i", help="Memory index path"),
|
|
309
|
+
] = None,
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Show full architectural context for a module.
|
|
312
|
+
|
|
313
|
+
Returns the module's bounded context (domain), architectural layer,
|
|
314
|
+
applicable guardrails (constraints), and module dependencies in a
|
|
315
|
+
single structured view.
|
|
316
|
+
|
|
317
|
+
Examples:
|
|
318
|
+
# Show context for memory module
|
|
319
|
+
$ raise memory context mod-memory
|
|
320
|
+
|
|
321
|
+
# JSON output for programmatic use
|
|
322
|
+
$ raise memory context mod-memory --format json
|
|
323
|
+
"""
|
|
324
|
+
unified_path = index_path or _get_default_index_path()
|
|
325
|
+
try:
|
|
326
|
+
engine = UnifiedQueryEngine.from_file(unified_path)
|
|
327
|
+
except FileNotFoundError as e:
|
|
328
|
+
cli_error(
|
|
329
|
+
str(e),
|
|
330
|
+
hint="Run 'raise memory build' first to create the index",
|
|
331
|
+
exit_code=4,
|
|
332
|
+
)
|
|
333
|
+
return # cli_error exits, but this satisfies pyright
|
|
334
|
+
|
|
335
|
+
ctx = engine.get_architectural_context(module_id)
|
|
336
|
+
if ctx is None:
|
|
337
|
+
cli_error(
|
|
338
|
+
f"Module not found: {module_id}",
|
|
339
|
+
hint="Check available modules with: raise memory query '' --types module",
|
|
340
|
+
exit_code=4,
|
|
341
|
+
)
|
|
342
|
+
return # cli_error exits, but this satisfies pyright
|
|
343
|
+
|
|
344
|
+
if format == "json":
|
|
345
|
+
console.print(_format_context_json(ctx))
|
|
346
|
+
else:
|
|
347
|
+
_print_context_human(ctx)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _format_context_json(ctx: ArchitecturalContext) -> str:
|
|
351
|
+
"""Format architectural context as JSON."""
|
|
352
|
+
return ctx.model_dump_json(indent=2)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _print_context_human(ctx: ArchitecturalContext) -> None:
|
|
356
|
+
"""Print architectural context in human-readable format."""
|
|
357
|
+
console.print(f"\n[bold]Module:[/bold] [cyan]{ctx.module.id}[/cyan]")
|
|
358
|
+
console.print(f" {ctx.module.content}")
|
|
359
|
+
|
|
360
|
+
if ctx.domain:
|
|
361
|
+
console.print(f"\n[bold]Domain:[/bold] [green]{ctx.domain.id}[/green]")
|
|
362
|
+
console.print(f" {ctx.domain.content}")
|
|
363
|
+
else:
|
|
364
|
+
console.print("\n[bold]Domain:[/bold] [dim]None[/dim]")
|
|
365
|
+
|
|
366
|
+
if ctx.layer:
|
|
367
|
+
console.print(f"\n[bold]Layer:[/bold] [green]{ctx.layer.id}[/green]")
|
|
368
|
+
console.print(f" {ctx.layer.content}")
|
|
369
|
+
else:
|
|
370
|
+
console.print("\n[bold]Layer:[/bold] [dim]None[/dim]")
|
|
371
|
+
|
|
372
|
+
if ctx.constraints:
|
|
373
|
+
must = [c for c in ctx.constraints if "MUST" in c.content]
|
|
374
|
+
should = [c for c in ctx.constraints if "SHOULD" in c.content]
|
|
375
|
+
console.print(
|
|
376
|
+
f"\n[bold]Constraints:[/bold] {len(ctx.constraints)} guardrails"
|
|
377
|
+
)
|
|
378
|
+
if must:
|
|
379
|
+
must_ids = ", ".join(c.id for c in must)
|
|
380
|
+
console.print(f" [red]MUST:[/red] {must_ids}")
|
|
381
|
+
if should:
|
|
382
|
+
should_ids = ", ".join(c.id for c in should)
|
|
383
|
+
console.print(f" [yellow]SHOULD:[/yellow] {should_ids}")
|
|
384
|
+
else:
|
|
385
|
+
console.print("\n[bold]Constraints:[/bold] [dim]None[/dim]")
|
|
386
|
+
|
|
387
|
+
if ctx.dependencies:
|
|
388
|
+
dep_ids = ", ".join(d.id for d in ctx.dependencies)
|
|
389
|
+
console.print(f"\n[bold]Dependencies:[/bold] {dep_ids}")
|
|
390
|
+
else:
|
|
391
|
+
console.print("\n[bold]Dependencies:[/bold] [dim]None[/dim]")
|
|
392
|
+
|
|
393
|
+
console.print()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# =============================================================================
|
|
397
|
+
# Generate MEMORY.md
|
|
398
|
+
# =============================================================================
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@memory_app.command("generate")
|
|
402
|
+
def generate_memory(
|
|
403
|
+
path: Annotated[
|
|
404
|
+
Path | None,
|
|
405
|
+
typer.Option(
|
|
406
|
+
"--path", "-p", help="Project root (defaults to current directory)"
|
|
407
|
+
),
|
|
408
|
+
] = None,
|
|
409
|
+
) -> None:
|
|
410
|
+
"""Generate MEMORY.md for AI editors (deprecated).
|
|
411
|
+
|
|
412
|
+
MEMORY.md generation is no longer needed — the memory graph is the
|
|
413
|
+
single source of truth. Context is delivered via `raise session start
|
|
414
|
+
--context` which assembles a token-optimized bundle from the graph.
|
|
415
|
+
|
|
416
|
+
Use `raise memory build` to rebuild the graph instead.
|
|
417
|
+
|
|
418
|
+
Examples:
|
|
419
|
+
# Build the memory graph (recommended)
|
|
420
|
+
$ raise memory build
|
|
421
|
+
"""
|
|
422
|
+
console.print(
|
|
423
|
+
"\n[yellow]Skipped:[/yellow] MEMORY.md generation is deprecated."
|
|
424
|
+
)
|
|
425
|
+
console.print(
|
|
426
|
+
" The memory graph is the single source of truth."
|
|
427
|
+
)
|
|
428
|
+
console.print(
|
|
429
|
+
" Context is delivered via [cyan]raise session start --context[/cyan]."
|
|
430
|
+
)
|
|
431
|
+
console.print(
|
|
432
|
+
" Use [cyan]raise memory build[/cyan] to rebuild the graph.\n"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# =============================================================================
|
|
437
|
+
# Build/Index Commands
|
|
438
|
+
# =============================================================================
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@memory_app.command()
|
|
442
|
+
def build(
|
|
443
|
+
output: Annotated[
|
|
444
|
+
Path | None,
|
|
445
|
+
typer.Option("--output", "-o", help="Path to save index JSON"),
|
|
446
|
+
] = None,
|
|
447
|
+
no_diff: Annotated[
|
|
448
|
+
bool,
|
|
449
|
+
typer.Option("--no-diff", help="Skip diff computation"),
|
|
450
|
+
] = False,
|
|
451
|
+
) -> None:
|
|
452
|
+
"""Build memory index from all sources.
|
|
453
|
+
|
|
454
|
+
Merges all context sources into a single queryable index:
|
|
455
|
+
- Governance documents (constitution, PRD, vision)
|
|
456
|
+
- Memory (patterns, calibration, sessions)
|
|
457
|
+
- Work tracking (epics, stories)
|
|
458
|
+
- Skills (SKILL.md metadata)
|
|
459
|
+
- Components (from discovery)
|
|
460
|
+
|
|
461
|
+
By default, diffs against the previous build and saves the diff
|
|
462
|
+
to .raise/rai/personal/last-diff.json for downstream consumers.
|
|
463
|
+
|
|
464
|
+
Examples:
|
|
465
|
+
# Build index to default location
|
|
466
|
+
$ raise memory build
|
|
467
|
+
|
|
468
|
+
# Build without diff
|
|
469
|
+
$ raise memory build --no-diff
|
|
470
|
+
|
|
471
|
+
# Save to custom location
|
|
472
|
+
$ raise memory build --output custom_index.json
|
|
473
|
+
"""
|
|
474
|
+
default_output = _get_default_index_path()
|
|
475
|
+
output_path = output or default_output
|
|
476
|
+
|
|
477
|
+
# Load old graph for diff (before building new one)
|
|
478
|
+
old_graph: UnifiedGraph | None = None
|
|
479
|
+
if not no_diff and output_path.exists():
|
|
480
|
+
old_graph = UnifiedGraph.load(output_path)
|
|
481
|
+
|
|
482
|
+
# Build unified graph
|
|
483
|
+
builder = UnifiedGraphBuilder()
|
|
484
|
+
graph = builder.build()
|
|
485
|
+
|
|
486
|
+
# Count nodes by type
|
|
487
|
+
node_counts: dict[str, int] = {}
|
|
488
|
+
for node in graph.iter_concepts():
|
|
489
|
+
node_counts[node.type] = node_counts.get(node.type, 0) + 1
|
|
490
|
+
|
|
491
|
+
# Count edges by type
|
|
492
|
+
edge_counts: dict[str, int] = {}
|
|
493
|
+
for edge in graph.iter_relationships():
|
|
494
|
+
edge_counts[edge.type] = edge_counts.get(edge.type, 0) + 1
|
|
495
|
+
|
|
496
|
+
# Save graph
|
|
497
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
498
|
+
graph.save(output_path)
|
|
499
|
+
|
|
500
|
+
# Compute and persist diff
|
|
501
|
+
diff: GraphDiff | None = None
|
|
502
|
+
if old_graph is not None:
|
|
503
|
+
diff = diff_graphs(old_graph, graph)
|
|
504
|
+
diff_path = get_personal_dir() / "last-diff.json"
|
|
505
|
+
diff_path.parent.mkdir(parents=True, exist_ok=True)
|
|
506
|
+
diff_path.write_text(diff.model_dump_json(indent=2))
|
|
507
|
+
|
|
508
|
+
# Format output
|
|
509
|
+
_format_build_result(
|
|
510
|
+
output_path=output_path,
|
|
511
|
+
node_counts=node_counts,
|
|
512
|
+
edge_counts=edge_counts,
|
|
513
|
+
total_nodes=graph.node_count,
|
|
514
|
+
total_edges=graph.edge_count,
|
|
515
|
+
diff=diff,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _format_build_result(
|
|
520
|
+
output_path: Path,
|
|
521
|
+
node_counts: dict[str, int],
|
|
522
|
+
edge_counts: dict[str, int],
|
|
523
|
+
total_nodes: int,
|
|
524
|
+
total_edges: int,
|
|
525
|
+
diff: GraphDiff | None = None,
|
|
526
|
+
) -> None:
|
|
527
|
+
"""Format and print memory build results."""
|
|
528
|
+
console.print("\n[cyan]Building memory index...[/cyan]")
|
|
529
|
+
|
|
530
|
+
# Display node counts
|
|
531
|
+
console.print("\n[bold]Concepts by type:[/bold]")
|
|
532
|
+
for node_type, count in sorted(node_counts.items()):
|
|
533
|
+
console.print(f" {node_type}: [green]{count}[/green]")
|
|
534
|
+
|
|
535
|
+
console.print(f"\n[bold]Total concepts:[/bold] [green]{total_nodes}[/green]")
|
|
536
|
+
|
|
537
|
+
# Display edge counts
|
|
538
|
+
if edge_counts:
|
|
539
|
+
console.print("\n[bold]Relationships by type:[/bold]")
|
|
540
|
+
for edge_type, count in sorted(edge_counts.items()):
|
|
541
|
+
console.print(f" {edge_type}: [green]{count}[/green]")
|
|
542
|
+
|
|
543
|
+
console.print(f"\n[bold]Total relationships:[/bold] [green]{total_edges}[/green]")
|
|
544
|
+
|
|
545
|
+
# Display diff summary
|
|
546
|
+
if diff is not None:
|
|
547
|
+
console.print(f"\n[bold]Diff:[/bold] {diff.summary}")
|
|
548
|
+
if diff.impact != "none":
|
|
549
|
+
console.print(f"[bold]Impact:[/bold] {diff.impact}")
|
|
550
|
+
|
|
551
|
+
console.print(f"\n✓ Saved to [cyan]{output_path}[/cyan]\n")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@memory_app.command()
|
|
555
|
+
def validate(
|
|
556
|
+
index_file: Annotated[
|
|
557
|
+
Path | None,
|
|
558
|
+
typer.Option("--index", "-i", help="Path to index JSON file"),
|
|
559
|
+
] = None,
|
|
560
|
+
) -> None:
|
|
561
|
+
"""Validate memory index structure and relationships.
|
|
562
|
+
|
|
563
|
+
Checks for:
|
|
564
|
+
- Cycles in depends_on relationships
|
|
565
|
+
- Valid relationship types
|
|
566
|
+
- All edge targets exist as nodes
|
|
567
|
+
|
|
568
|
+
Examples:
|
|
569
|
+
# Validate default index
|
|
570
|
+
$ raise memory validate
|
|
571
|
+
|
|
572
|
+
# Validate specific index file
|
|
573
|
+
$ raise memory validate --index custom_index.json
|
|
574
|
+
"""
|
|
575
|
+
default_index = _get_default_index_path()
|
|
576
|
+
index_path = index_file or default_index
|
|
577
|
+
|
|
578
|
+
if not index_path.exists():
|
|
579
|
+
cli_error(
|
|
580
|
+
f"Index file not found: {index_path}",
|
|
581
|
+
hint="Run 'raise memory build' first to create the index",
|
|
582
|
+
exit_code=4,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
console.print(f"\nLoading index from [cyan]{index_path}[/cyan]...")
|
|
586
|
+
graph = UnifiedGraph.load(index_path)
|
|
587
|
+
console.print(
|
|
588
|
+
f" ✓ Loaded index with {graph.node_count} concepts, {graph.edge_count} relationships"
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
console.print("\nValidating index...")
|
|
592
|
+
|
|
593
|
+
# Build node set for validation
|
|
594
|
+
node_ids = {node.id for node in graph.iter_concepts()}
|
|
595
|
+
|
|
596
|
+
# Check 1: All edge targets exist as nodes
|
|
597
|
+
valid_edges = True
|
|
598
|
+
edges_list = list(graph.iter_relationships())
|
|
599
|
+
for edge in edges_list:
|
|
600
|
+
if edge.source not in node_ids:
|
|
601
|
+
console.print(
|
|
602
|
+
f" [red]✗[/red] Invalid edge: source '{edge.source}' not in index"
|
|
603
|
+
)
|
|
604
|
+
valid_edges = False
|
|
605
|
+
if edge.target not in node_ids:
|
|
606
|
+
console.print(
|
|
607
|
+
f" [red]✗[/red] Invalid edge: target '{edge.target}' not in index"
|
|
608
|
+
)
|
|
609
|
+
valid_edges = False
|
|
610
|
+
|
|
611
|
+
if valid_edges:
|
|
612
|
+
console.print(" ✓ All relationships valid")
|
|
613
|
+
|
|
614
|
+
# Check 2: Detect cycles in depends_on relationships
|
|
615
|
+
depends_edges = [e for e in edges_list if e.type == "depends_on"]
|
|
616
|
+
if depends_edges:
|
|
617
|
+
cycles = _detect_cycles(graph, depends_edges)
|
|
618
|
+
if cycles:
|
|
619
|
+
console.print(
|
|
620
|
+
f" [yellow]⚠[/yellow] {len(cycles)} cycle(s) detected in depends_on relationships"
|
|
621
|
+
)
|
|
622
|
+
for cycle in cycles[:3]: # Show first 3
|
|
623
|
+
console.print(f" {' → '.join(cycle)}")
|
|
624
|
+
else:
|
|
625
|
+
console.print(" ✓ No cycles detected")
|
|
626
|
+
|
|
627
|
+
# Check 3: Reachability
|
|
628
|
+
console.print(f" ✓ {graph.node_count}/{graph.node_count} concepts reachable")
|
|
629
|
+
|
|
630
|
+
# Check 4: Completeness — expected node types present
|
|
631
|
+
expected_types: dict[str, int] = {
|
|
632
|
+
"architecture": 1, # ≥1 arch-* node
|
|
633
|
+
"module": 1, # ≥1 mod-* node
|
|
634
|
+
}
|
|
635
|
+
type_counts: dict[str, int] = {}
|
|
636
|
+
for node in graph.iter_concepts():
|
|
637
|
+
type_counts[node.type] = type_counts.get(node.type, 0) + 1
|
|
638
|
+
|
|
639
|
+
missing: list[tuple[str, int, int]] = []
|
|
640
|
+
for node_type, min_count in expected_types.items():
|
|
641
|
+
actual = type_counts.get(node_type, 0)
|
|
642
|
+
if actual < min_count:
|
|
643
|
+
missing.append((node_type, min_count, actual))
|
|
644
|
+
|
|
645
|
+
if missing:
|
|
646
|
+
console.print(" [yellow]⚠[/yellow] Completeness gaps:")
|
|
647
|
+
for node_type, expected, actual in missing:
|
|
648
|
+
console.print(
|
|
649
|
+
f" {node_type}: expected ≥{expected}, found {actual}"
|
|
650
|
+
)
|
|
651
|
+
else:
|
|
652
|
+
console.print(" ✓ Completeness check passed")
|
|
653
|
+
|
|
654
|
+
console.print("\n[green]Memory index is valid.[/green]\n")
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _detect_cycles(graph: UnifiedGraph, edges: list) -> list[list[str]]:
|
|
658
|
+
"""Detect cycles in a set of edges using DFS."""
|
|
659
|
+
# Build adjacency list from edges
|
|
660
|
+
adj: dict[str, list[str]] = {}
|
|
661
|
+
for edge in edges:
|
|
662
|
+
adj.setdefault(edge.source, []).append(edge.target)
|
|
663
|
+
|
|
664
|
+
cycles: list[list[str]] = []
|
|
665
|
+
visited: set[str] = set()
|
|
666
|
+
rec_stack: set[str] = set()
|
|
667
|
+
|
|
668
|
+
def dfs(node: str, path: list[str]) -> None:
|
|
669
|
+
visited.add(node)
|
|
670
|
+
rec_stack.add(node)
|
|
671
|
+
path.append(node)
|
|
672
|
+
|
|
673
|
+
for neighbor in adj.get(node, []):
|
|
674
|
+
if neighbor not in visited:
|
|
675
|
+
dfs(neighbor, path[:])
|
|
676
|
+
elif neighbor in rec_stack:
|
|
677
|
+
# Cycle detected
|
|
678
|
+
cycle_start = path.index(neighbor)
|
|
679
|
+
cycle = path[cycle_start:] + [neighbor]
|
|
680
|
+
cycles.append(cycle)
|
|
681
|
+
|
|
682
|
+
rec_stack.remove(node)
|
|
683
|
+
|
|
684
|
+
# Get all node IDs
|
|
685
|
+
node_ids = {node.id for node in graph.iter_concepts()}
|
|
686
|
+
|
|
687
|
+
for node in node_ids:
|
|
688
|
+
if node not in visited and node in adj:
|
|
689
|
+
dfs(node, [])
|
|
690
|
+
|
|
691
|
+
return cycles
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@memory_app.command()
|
|
695
|
+
def extract(
|
|
696
|
+
file_path: Annotated[
|
|
697
|
+
Path | None,
|
|
698
|
+
typer.Argument(
|
|
699
|
+
help="Path to governance file (optional, extracts all if not provided)"
|
|
700
|
+
),
|
|
701
|
+
] = None,
|
|
702
|
+
format: Annotated[
|
|
703
|
+
str,
|
|
704
|
+
typer.Option("--format", "-f", help="Output format (human or json)"),
|
|
705
|
+
] = "human",
|
|
706
|
+
) -> None:
|
|
707
|
+
"""Extract concepts from governance markdown files.
|
|
708
|
+
|
|
709
|
+
If no file path is provided, extracts from all standard governance locations:
|
|
710
|
+
- governance/prd.md (requirements)
|
|
711
|
+
- governance/vision.md (outcomes)
|
|
712
|
+
- framework/reference/constitution.md (principles)
|
|
713
|
+
|
|
714
|
+
Examples:
|
|
715
|
+
# Extract from all governance files
|
|
716
|
+
$ raise memory extract
|
|
717
|
+
|
|
718
|
+
# Extract from specific file
|
|
719
|
+
$ raise memory extract governance/prd.md
|
|
720
|
+
|
|
721
|
+
# Output as JSON
|
|
722
|
+
$ raise memory extract --format json
|
|
723
|
+
"""
|
|
724
|
+
extractor = GovernanceExtractor()
|
|
725
|
+
|
|
726
|
+
if file_path:
|
|
727
|
+
# Extract from single file
|
|
728
|
+
if not file_path.exists():
|
|
729
|
+
cli_error(f"File not found: {file_path}", exit_code=4)
|
|
730
|
+
|
|
731
|
+
concepts = extractor.extract_from_file(file_path)
|
|
732
|
+
|
|
733
|
+
if format == "json":
|
|
734
|
+
# JSON output
|
|
735
|
+
output = {
|
|
736
|
+
"concepts": [
|
|
737
|
+
{
|
|
738
|
+
"id": c.id,
|
|
739
|
+
"type": c.type.value,
|
|
740
|
+
"file": c.file,
|
|
741
|
+
"section": c.section,
|
|
742
|
+
"lines": list(c.lines),
|
|
743
|
+
"content": c.content,
|
|
744
|
+
"metadata": c.metadata,
|
|
745
|
+
}
|
|
746
|
+
for c in concepts
|
|
747
|
+
],
|
|
748
|
+
"total": len(concepts),
|
|
749
|
+
}
|
|
750
|
+
console.print(json.dumps(output, indent=2))
|
|
751
|
+
else:
|
|
752
|
+
# Human-readable output
|
|
753
|
+
console.print(
|
|
754
|
+
f"\nExtracting concepts from [cyan]{file_path.name}[/cyan]..."
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
for concept in concepts:
|
|
758
|
+
console.print(
|
|
759
|
+
f" ✓ Found {concept.metadata.get('requirement_id') or concept.metadata.get('principle_number') or concept.section}"
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
console.print(f"→ Extracted [green]{len(concepts)}[/green] concepts\n")
|
|
763
|
+
|
|
764
|
+
else:
|
|
765
|
+
# Extract from all governance files
|
|
766
|
+
result = extractor.extract_with_result()
|
|
767
|
+
|
|
768
|
+
if format == "json":
|
|
769
|
+
# JSON output
|
|
770
|
+
output = {
|
|
771
|
+
"concepts": [
|
|
772
|
+
{
|
|
773
|
+
"id": c.id,
|
|
774
|
+
"type": c.type.value,
|
|
775
|
+
"file": c.file,
|
|
776
|
+
"section": c.section,
|
|
777
|
+
"lines": list(c.lines),
|
|
778
|
+
"content": c.content,
|
|
779
|
+
"metadata": c.metadata,
|
|
780
|
+
}
|
|
781
|
+
for c in result.concepts
|
|
782
|
+
],
|
|
783
|
+
"total": result.total,
|
|
784
|
+
"files_processed": result.files_processed,
|
|
785
|
+
"errors": result.errors,
|
|
786
|
+
}
|
|
787
|
+
console.print(json.dumps(output, indent=2))
|
|
788
|
+
else:
|
|
789
|
+
# Human-readable output
|
|
790
|
+
console.print("\nExtracting concepts from governance files...")
|
|
791
|
+
|
|
792
|
+
# Group concepts by type
|
|
793
|
+
by_type: dict[ConceptType, list] = {}
|
|
794
|
+
for concept in result.concepts:
|
|
795
|
+
by_type.setdefault(concept.type, []).append(concept)
|
|
796
|
+
|
|
797
|
+
# Display by file type
|
|
798
|
+
if ConceptType.REQUIREMENT in by_type:
|
|
799
|
+
reqs = by_type[ConceptType.REQUIREMENT]
|
|
800
|
+
console.print(f" 📄 prd.md → [green]{len(reqs)}[/green] requirements")
|
|
801
|
+
|
|
802
|
+
if ConceptType.OUTCOME in by_type:
|
|
803
|
+
outcomes = by_type[ConceptType.OUTCOME]
|
|
804
|
+
console.print(
|
|
805
|
+
f" 📄 vision.md → [green]{len(outcomes)}[/green] outcomes"
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
if ConceptType.PRINCIPLE in by_type:
|
|
809
|
+
principles = by_type[ConceptType.PRINCIPLE]
|
|
810
|
+
console.print(
|
|
811
|
+
f" 📄 constitution.md → [green]{len(principles)}[/green] principles"
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
console.print(
|
|
815
|
+
f"→ Total: [green]{result.total}[/green] concepts extracted\n"
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
if result.errors:
|
|
819
|
+
console.print("[yellow]Warnings:[/yellow]")
|
|
820
|
+
for error in result.errors:
|
|
821
|
+
console.print(f" ⚠ {error}")
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
# =============================================================================
|
|
825
|
+
# List Command
|
|
826
|
+
# =============================================================================
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
@memory_app.command("list")
|
|
830
|
+
def list_memory(
|
|
831
|
+
format: Annotated[
|
|
832
|
+
str,
|
|
833
|
+
typer.Option("--format", "-f", help="Output format (human, json, or table)"),
|
|
834
|
+
] = "table",
|
|
835
|
+
output: Annotated[
|
|
836
|
+
Path | None,
|
|
837
|
+
typer.Option("--output", "-o", help="Output file path (default: stdout)"),
|
|
838
|
+
] = None,
|
|
839
|
+
index_path: Annotated[
|
|
840
|
+
Path | None,
|
|
841
|
+
typer.Option("--index", "-i", help="Memory index path"),
|
|
842
|
+
] = None,
|
|
843
|
+
memory_only: Annotated[
|
|
844
|
+
bool,
|
|
845
|
+
typer.Option(
|
|
846
|
+
"--memory-only/--all",
|
|
847
|
+
help="Show only memory types (pattern, calibration, session) or all",
|
|
848
|
+
),
|
|
849
|
+
] = False,
|
|
850
|
+
) -> None:
|
|
851
|
+
"""List concepts in memory.
|
|
852
|
+
|
|
853
|
+
Shows concepts from the memory index for inspection and debugging.
|
|
854
|
+
|
|
855
|
+
Examples:
|
|
856
|
+
# Show summary table (all concepts)
|
|
857
|
+
$ raise memory list
|
|
858
|
+
|
|
859
|
+
# Show only patterns/calibrations/sessions
|
|
860
|
+
$ raise memory list --memory-only
|
|
861
|
+
|
|
862
|
+
# Export as JSON
|
|
863
|
+
$ raise memory list --format json --output memory.json
|
|
864
|
+
|
|
865
|
+
# Export as human-readable markdown
|
|
866
|
+
$ raise memory list --format human --output memory.md
|
|
867
|
+
"""
|
|
868
|
+
# Resolve index path
|
|
869
|
+
unified_path = index_path or _get_default_index_path()
|
|
870
|
+
if not unified_path.exists():
|
|
871
|
+
cli_error(
|
|
872
|
+
f"Memory index not found: {unified_path}",
|
|
873
|
+
hint="Run 'raise memory build' first to create the index",
|
|
874
|
+
exit_code=4,
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
# Load unified graph
|
|
878
|
+
try:
|
|
879
|
+
graph = UnifiedGraph.load(unified_path)
|
|
880
|
+
except Exception as e:
|
|
881
|
+
cli_error(f"Error loading memory index: {e}")
|
|
882
|
+
|
|
883
|
+
# Filter to memory types only if requested
|
|
884
|
+
if memory_only:
|
|
885
|
+
concepts = [c for c in graph.iter_concepts() if c.type in MEMORY_TYPES]
|
|
886
|
+
else:
|
|
887
|
+
concepts = list(graph.iter_concepts())
|
|
888
|
+
|
|
889
|
+
console.print(f"\nMemory from: [cyan]{unified_path}[/cyan]")
|
|
890
|
+
console.print(f"Concepts: [yellow]{len(concepts)}[/yellow]\n")
|
|
891
|
+
|
|
892
|
+
# Format output
|
|
893
|
+
if format == "json":
|
|
894
|
+
output_text = json.dumps(
|
|
895
|
+
[c.model_dump(mode="json") for c in concepts],
|
|
896
|
+
indent=2,
|
|
897
|
+
)
|
|
898
|
+
elif format == "human":
|
|
899
|
+
output_text = _format_concepts_markdown(concepts)
|
|
900
|
+
else: # table
|
|
901
|
+
_print_concepts_table(concepts)
|
|
902
|
+
if output:
|
|
903
|
+
# For file output in table mode, use markdown
|
|
904
|
+
output_text = _format_concepts_markdown(concepts)
|
|
905
|
+
else:
|
|
906
|
+
return
|
|
907
|
+
|
|
908
|
+
# Write to file or stdout
|
|
909
|
+
if output:
|
|
910
|
+
output.write_text(output_text)
|
|
911
|
+
console.print(f"✓ Memory written to [cyan]{output}[/cyan]\n")
|
|
912
|
+
elif format != "table":
|
|
913
|
+
console.print(output_text)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _format_concepts_markdown(concepts: list[ConceptNode]) -> str:
|
|
917
|
+
"""Format concepts list as markdown."""
|
|
918
|
+
|
|
919
|
+
lines = ["# Memory Concepts\n"]
|
|
920
|
+
lines.append(f"**Total:** {len(concepts)}\n")
|
|
921
|
+
|
|
922
|
+
# Group by type
|
|
923
|
+
by_type: dict[str, list[ConceptNode]] = {}
|
|
924
|
+
for concept in concepts:
|
|
925
|
+
type_name = concept.type
|
|
926
|
+
if type_name not in by_type:
|
|
927
|
+
by_type[type_name] = []
|
|
928
|
+
by_type[type_name].append(concept)
|
|
929
|
+
|
|
930
|
+
lines.append("## Concepts by Type\n")
|
|
931
|
+
for type_name, type_concepts in sorted(by_type.items()):
|
|
932
|
+
lines.append(f"### {type_name.title()} ({len(type_concepts)})\n")
|
|
933
|
+
for concept in sorted(type_concepts, key=lambda c: c.id):
|
|
934
|
+
content = (
|
|
935
|
+
concept.content[:60] + "..."
|
|
936
|
+
if len(concept.content) > 60
|
|
937
|
+
else concept.content
|
|
938
|
+
)
|
|
939
|
+
lines.append(f"- **{concept.id}**: {content}")
|
|
940
|
+
lines.append("")
|
|
941
|
+
|
|
942
|
+
return "\n".join(lines)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _print_concepts_table(concepts: list[ConceptNode]) -> None:
|
|
946
|
+
"""Print concepts as rich table."""
|
|
947
|
+
|
|
948
|
+
table = Table(title="Memory Concepts")
|
|
949
|
+
table.add_column("ID", style="cyan")
|
|
950
|
+
table.add_column("Type", style="yellow")
|
|
951
|
+
table.add_column("Content", max_width=50)
|
|
952
|
+
table.add_column("Created")
|
|
953
|
+
|
|
954
|
+
for concept in sorted(concepts, key=lambda c: c.id):
|
|
955
|
+
content = (
|
|
956
|
+
concept.content[:47] + "..."
|
|
957
|
+
if len(concept.content) > 50
|
|
958
|
+
else concept.content
|
|
959
|
+
)
|
|
960
|
+
table.add_row(
|
|
961
|
+
concept.id,
|
|
962
|
+
concept.type,
|
|
963
|
+
content,
|
|
964
|
+
concept.created,
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
console.print(table)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
# =============================================================================
|
|
971
|
+
# Visualization Command
|
|
972
|
+
# =============================================================================
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
@memory_app.command("viz")
|
|
976
|
+
def viz(
|
|
977
|
+
output: Annotated[
|
|
978
|
+
Path | None,
|
|
979
|
+
typer.Option("--output", "-o", help="Output HTML file path"),
|
|
980
|
+
] = None,
|
|
981
|
+
index_path: Annotated[
|
|
982
|
+
Path | None,
|
|
983
|
+
typer.Option("--index", "-i", help="Memory index path"),
|
|
984
|
+
] = None,
|
|
985
|
+
open_browser: Annotated[
|
|
986
|
+
bool,
|
|
987
|
+
typer.Option("--open/--no-open", help="Open in browser after generating"),
|
|
988
|
+
] = True,
|
|
989
|
+
) -> None:
|
|
990
|
+
"""Generate interactive HTML visualization of the memory graph.
|
|
991
|
+
|
|
992
|
+
Creates a self-contained HTML file with a D3.js force-directed graph.
|
|
993
|
+
Nodes are color-coded by type, filterable, zoomable, and searchable.
|
|
994
|
+
|
|
995
|
+
Examples:
|
|
996
|
+
# Generate and open in browser
|
|
997
|
+
$ raise memory viz
|
|
998
|
+
|
|
999
|
+
# Generate to specific path
|
|
1000
|
+
$ raise memory viz --output graph.html
|
|
1001
|
+
|
|
1002
|
+
# Generate without opening
|
|
1003
|
+
$ raise memory viz --no-open
|
|
1004
|
+
"""
|
|
1005
|
+
import webbrowser
|
|
1006
|
+
|
|
1007
|
+
from rai_cli.viz import generate_viz_html
|
|
1008
|
+
|
|
1009
|
+
unified_path = index_path or _get_default_index_path()
|
|
1010
|
+
if not unified_path.exists():
|
|
1011
|
+
cli_error(
|
|
1012
|
+
f"Memory index not found: {unified_path}",
|
|
1013
|
+
hint="Run 'raise memory build' first to create the index",
|
|
1014
|
+
exit_code=4,
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
output_path = output or Path(".raise/rai/memory/graph.html")
|
|
1018
|
+
|
|
1019
|
+
console.print(f"\nGenerating visualization from [cyan]{unified_path}[/cyan]...")
|
|
1020
|
+
result_path = generate_viz_html(unified_path, output_path)
|
|
1021
|
+
console.print(f"✓ Written to [cyan]{result_path}[/cyan]\n")
|
|
1022
|
+
|
|
1023
|
+
if open_browser:
|
|
1024
|
+
webbrowser.open(f"file://{result_path.resolve()}")
|
|
1025
|
+
console.print(" Opened in browser.\n")
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
# =============================================================================
|
|
1029
|
+
# Append Commands (Add to memory)
|
|
1030
|
+
# =============================================================================
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
@memory_app.command("add-pattern")
|
|
1034
|
+
def add_pattern(
|
|
1035
|
+
content: Annotated[str, typer.Argument(help="Pattern description")],
|
|
1036
|
+
context: Annotated[
|
|
1037
|
+
str,
|
|
1038
|
+
typer.Option("--context", "-c", help="Context keywords (comma-separated)"),
|
|
1039
|
+
] = "",
|
|
1040
|
+
sub_type: Annotated[
|
|
1041
|
+
str,
|
|
1042
|
+
typer.Option(
|
|
1043
|
+
"--type",
|
|
1044
|
+
"-t",
|
|
1045
|
+
help="Pattern type (codebase, process, architecture, technical)",
|
|
1046
|
+
),
|
|
1047
|
+
] = "process",
|
|
1048
|
+
learned_from: Annotated[
|
|
1049
|
+
str | None,
|
|
1050
|
+
typer.Option("--from", "-f", help="Story/session where learned"),
|
|
1051
|
+
] = None,
|
|
1052
|
+
scope: Annotated[
|
|
1053
|
+
str,
|
|
1054
|
+
typer.Option("--scope", "-s", help="Memory scope (global, project, personal)"),
|
|
1055
|
+
] = "project",
|
|
1056
|
+
memory_dir: Annotated[
|
|
1057
|
+
Path | None,
|
|
1058
|
+
typer.Option(
|
|
1059
|
+
"--memory-dir", "-m", help="Memory directory path (overrides scope)"
|
|
1060
|
+
),
|
|
1061
|
+
] = None,
|
|
1062
|
+
) -> None:
|
|
1063
|
+
"""Add a new pattern to memory.
|
|
1064
|
+
|
|
1065
|
+
Examples:
|
|
1066
|
+
# Add a process pattern (default: project scope)
|
|
1067
|
+
$ raise memory add-pattern "HITL before commits" -c "git,workflow"
|
|
1068
|
+
|
|
1069
|
+
# Add a technical pattern
|
|
1070
|
+
$ raise memory add-pattern "Use capsys for stdout tests" -t technical -c "pytest,testing"
|
|
1071
|
+
|
|
1072
|
+
# Add with source reference
|
|
1073
|
+
$ raise memory add-pattern "BFS reuse across modules" -t architecture --from F2.3
|
|
1074
|
+
|
|
1075
|
+
# Add to global scope (universal pattern)
|
|
1076
|
+
$ raise memory add-pattern "Universal TDD pattern" --scope global
|
|
1077
|
+
|
|
1078
|
+
# Add to personal scope (my learnings)
|
|
1079
|
+
$ raise memory add-pattern "My workflow preference" --scope personal
|
|
1080
|
+
"""
|
|
1081
|
+
# Parse scope
|
|
1082
|
+
try:
|
|
1083
|
+
memory_scope = MemoryScope(scope)
|
|
1084
|
+
except ValueError:
|
|
1085
|
+
cli_error(
|
|
1086
|
+
f"Invalid scope: {scope}",
|
|
1087
|
+
hint="Valid scopes: global, project, personal",
|
|
1088
|
+
exit_code=7,
|
|
1089
|
+
)
|
|
1090
|
+
return # cli_error exits, but this satisfies pyright
|
|
1091
|
+
|
|
1092
|
+
# Determine directory (explicit dir overrides scope)
|
|
1093
|
+
mem_dir = memory_dir or get_memory_dir_for_scope(memory_scope)
|
|
1094
|
+
if not mem_dir.exists():
|
|
1095
|
+
mem_dir.mkdir(parents=True, exist_ok=True)
|
|
1096
|
+
|
|
1097
|
+
# Parse context
|
|
1098
|
+
context_list = [c.strip() for c in context.split(",") if c.strip()]
|
|
1099
|
+
|
|
1100
|
+
# Parse sub_type
|
|
1101
|
+
try:
|
|
1102
|
+
pattern_type = PatternSubType(sub_type)
|
|
1103
|
+
except ValueError:
|
|
1104
|
+
cli_error(
|
|
1105
|
+
f"Invalid pattern type: {sub_type}",
|
|
1106
|
+
hint="Valid types: codebase, process, architecture, technical",
|
|
1107
|
+
exit_code=7,
|
|
1108
|
+
)
|
|
1109
|
+
return # cli_error exits, but this satisfies pyright
|
|
1110
|
+
|
|
1111
|
+
input_data = PatternInput(
|
|
1112
|
+
content=content,
|
|
1113
|
+
sub_type=pattern_type,
|
|
1114
|
+
context=context_list,
|
|
1115
|
+
learned_from=learned_from,
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
# Load developer prefix for multi-dev safety
|
|
1119
|
+
profile = load_developer_profile()
|
|
1120
|
+
dev_prefix = profile.get_pattern_prefix() if profile else None
|
|
1121
|
+
|
|
1122
|
+
result = append_pattern(
|
|
1123
|
+
mem_dir, input_data, scope=memory_scope, developer_prefix=dev_prefix
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
if result.success:
|
|
1127
|
+
console.print(f"\n[green]✓[/green] {result.message}")
|
|
1128
|
+
console.print(f" ID: [cyan]{result.id}[/cyan]")
|
|
1129
|
+
console.print(f" Content: {content[:60]}...")
|
|
1130
|
+
if context_list:
|
|
1131
|
+
console.print(f" Context: {', '.join(context_list)}")
|
|
1132
|
+
console.print("\n[dim]Index will rebuild on next query.[/dim]\n")
|
|
1133
|
+
else:
|
|
1134
|
+
cli_error(result.message)
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
@memory_app.command("add-calibration")
|
|
1138
|
+
def add_calibration_cmd(
|
|
1139
|
+
story: Annotated[str, typer.Argument(help="Story ID (e.g., F3.5)")],
|
|
1140
|
+
name: Annotated[
|
|
1141
|
+
str,
|
|
1142
|
+
typer.Option("--name", help="Story name (required)"),
|
|
1143
|
+
],
|
|
1144
|
+
size: Annotated[
|
|
1145
|
+
str,
|
|
1146
|
+
typer.Option("--size", "-s", help="T-shirt size: XS, S, M, L, XL (required)"),
|
|
1147
|
+
],
|
|
1148
|
+
actual: Annotated[
|
|
1149
|
+
int,
|
|
1150
|
+
typer.Option("--actual", "-a", help="Actual minutes spent (required)"),
|
|
1151
|
+
],
|
|
1152
|
+
estimated: Annotated[
|
|
1153
|
+
int | None,
|
|
1154
|
+
typer.Option("--estimated", "-e", help="Estimated minutes"),
|
|
1155
|
+
] = None,
|
|
1156
|
+
sp: Annotated[
|
|
1157
|
+
int | None,
|
|
1158
|
+
typer.Option("--sp", help="Story points"),
|
|
1159
|
+
] = None,
|
|
1160
|
+
kata: Annotated[
|
|
1161
|
+
bool,
|
|
1162
|
+
typer.Option("--kata/--no-kata", help="Kata cycle followed (default: yes)"),
|
|
1163
|
+
] = True,
|
|
1164
|
+
notes: Annotated[
|
|
1165
|
+
str | None,
|
|
1166
|
+
typer.Option("--notes", "-n", help="Additional notes"),
|
|
1167
|
+
] = None,
|
|
1168
|
+
scope: Annotated[
|
|
1169
|
+
str,
|
|
1170
|
+
typer.Option("--scope", help="Memory scope (global, project, personal)"),
|
|
1171
|
+
] = "personal",
|
|
1172
|
+
memory_dir: Annotated[
|
|
1173
|
+
Path | None,
|
|
1174
|
+
typer.Option(
|
|
1175
|
+
"--memory-dir", "-m", help="Memory directory path (overrides scope)"
|
|
1176
|
+
),
|
|
1177
|
+
] = None,
|
|
1178
|
+
) -> None:
|
|
1179
|
+
"""Add calibration data for a completed story.
|
|
1180
|
+
|
|
1181
|
+
Examples:
|
|
1182
|
+
# Basic calibration (default: personal scope)
|
|
1183
|
+
$ rai memory add-calibration F3.5 --name "Skills Integration" -s XS -a 20
|
|
1184
|
+
|
|
1185
|
+
# With estimate for velocity calculation
|
|
1186
|
+
$ rai memory add-calibration F3.5 --name "Skills Integration" -s XS -a 20 -e 60
|
|
1187
|
+
|
|
1188
|
+
# Full details
|
|
1189
|
+
$ rai memory add-calibration F3.5 --name "Skills Integration" -s XS -a 20 -e 60 --sp 2 -n "Hook-assisted"
|
|
1190
|
+
|
|
1191
|
+
# Add to project scope (shared)
|
|
1192
|
+
$ rai memory add-calibration F3.5 --name "Skills" -s XS -a 20 --scope project
|
|
1193
|
+
"""
|
|
1194
|
+
# Parse scope
|
|
1195
|
+
try:
|
|
1196
|
+
memory_scope = MemoryScope(scope)
|
|
1197
|
+
except ValueError:
|
|
1198
|
+
cli_error(
|
|
1199
|
+
f"Invalid scope: {scope}",
|
|
1200
|
+
hint="Valid scopes: global, project, personal",
|
|
1201
|
+
exit_code=7,
|
|
1202
|
+
)
|
|
1203
|
+
return # cli_error exits, but this satisfies pyright
|
|
1204
|
+
|
|
1205
|
+
# Determine directory (explicit dir overrides scope)
|
|
1206
|
+
mem_dir = memory_dir or get_memory_dir_for_scope(memory_scope)
|
|
1207
|
+
if not mem_dir.exists():
|
|
1208
|
+
mem_dir.mkdir(parents=True, exist_ok=True)
|
|
1209
|
+
|
|
1210
|
+
# Validate size
|
|
1211
|
+
valid_sizes = ["XS", "S", "M", "L", "XL"]
|
|
1212
|
+
if size.upper() not in valid_sizes:
|
|
1213
|
+
cli_error(
|
|
1214
|
+
f"Invalid size: {size}",
|
|
1215
|
+
hint=f"Valid sizes: {', '.join(valid_sizes)}",
|
|
1216
|
+
exit_code=7,
|
|
1217
|
+
)
|
|
1218
|
+
return # cli_error exits, but this satisfies pyright
|
|
1219
|
+
|
|
1220
|
+
input_data = CalibrationInput(
|
|
1221
|
+
story=story,
|
|
1222
|
+
name=name,
|
|
1223
|
+
size=size.upper(),
|
|
1224
|
+
sp=sp,
|
|
1225
|
+
estimated_min=estimated,
|
|
1226
|
+
actual_min=actual,
|
|
1227
|
+
kata_cycle=kata,
|
|
1228
|
+
notes=notes,
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
result = append_calibration(mem_dir, input_data, scope=memory_scope)
|
|
1232
|
+
|
|
1233
|
+
if result.success:
|
|
1234
|
+
console.print(f"\n[green]✓[/green] {result.message}")
|
|
1235
|
+
console.print(f" ID: [cyan]{result.id}[/cyan]")
|
|
1236
|
+
console.print(f" Story: {story} ({name})")
|
|
1237
|
+
console.print(f" Size: {size.upper()}, Actual: {actual}min")
|
|
1238
|
+
if estimated:
|
|
1239
|
+
ratio = round(estimated / actual, 1)
|
|
1240
|
+
console.print(f" Velocity: {ratio}x (estimated {estimated}min)")
|
|
1241
|
+
console.print("\n[dim]Index will rebuild on next query.[/dim]\n")
|
|
1242
|
+
else:
|
|
1243
|
+
cli_error(result.message)
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
@memory_app.command("add-session")
|
|
1247
|
+
def add_session_cmd(
|
|
1248
|
+
topic: Annotated[str, typer.Argument(help="Session topic")],
|
|
1249
|
+
outcomes: Annotated[
|
|
1250
|
+
str,
|
|
1251
|
+
typer.Option("--outcomes", "-o", help="Session outcomes (comma-separated)"),
|
|
1252
|
+
] = "",
|
|
1253
|
+
session_type: Annotated[
|
|
1254
|
+
str,
|
|
1255
|
+
typer.Option("--type", "-t", help="Session type (story, research, etc.)"),
|
|
1256
|
+
] = "story",
|
|
1257
|
+
log_path: Annotated[
|
|
1258
|
+
str | None,
|
|
1259
|
+
typer.Option("--log", "-l", help="Path to session log file"),
|
|
1260
|
+
] = None,
|
|
1261
|
+
memory_dir: Annotated[
|
|
1262
|
+
Path | None,
|
|
1263
|
+
typer.Option("--memory-dir", "-m", help="Memory directory path"),
|
|
1264
|
+
] = None,
|
|
1265
|
+
) -> None:
|
|
1266
|
+
"""Add a session record to memory (personal scope).
|
|
1267
|
+
|
|
1268
|
+
Sessions are developer-specific and always written to personal directory.
|
|
1269
|
+
|
|
1270
|
+
Examples:
|
|
1271
|
+
# Basic session
|
|
1272
|
+
$ raise memory add-session "F3.5 Skills Integration"
|
|
1273
|
+
|
|
1274
|
+
# With outcomes
|
|
1275
|
+
$ raise memory add-session "F3.5 Skills Integration" -o "Writer API,Hooks setup,CLI commands"
|
|
1276
|
+
|
|
1277
|
+
# Full details
|
|
1278
|
+
$ raise memory add-session "F3.5 Skills Integration" -t story -o "Writer API,Hooks" -l "dev/sessions/2026-02-02-f3.5.md"
|
|
1279
|
+
"""
|
|
1280
|
+
# Sessions always go to personal directory (developer-specific)
|
|
1281
|
+
mem_dir = memory_dir or get_personal_dir()
|
|
1282
|
+
if not mem_dir.exists():
|
|
1283
|
+
mem_dir.mkdir(parents=True, exist_ok=True)
|
|
1284
|
+
|
|
1285
|
+
# Parse outcomes
|
|
1286
|
+
outcomes_list = [o.strip() for o in outcomes.split(",") if o.strip()]
|
|
1287
|
+
|
|
1288
|
+
input_data = SessionInput(
|
|
1289
|
+
topic=topic,
|
|
1290
|
+
session_type=session_type,
|
|
1291
|
+
outcomes=outcomes_list,
|
|
1292
|
+
log_path=log_path,
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
result = append_session(mem_dir, input_data)
|
|
1296
|
+
|
|
1297
|
+
if result.success:
|
|
1298
|
+
console.print(f"\n[green]✓[/green] {result.message}")
|
|
1299
|
+
console.print(f" ID: [cyan]{result.id}[/cyan]")
|
|
1300
|
+
console.print(f" Topic: {topic}")
|
|
1301
|
+
console.print(f" Type: {session_type}")
|
|
1302
|
+
if outcomes_list:
|
|
1303
|
+
console.print(f" Outcomes: {', '.join(outcomes_list[:3])}")
|
|
1304
|
+
console.print("\n[dim]Index will rebuild on next query.[/dim]\n")
|
|
1305
|
+
else:
|
|
1306
|
+
cli_error(result.message)
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
# =============================================================================
|
|
1310
|
+
# Emit Commands (Telemetry signals)
|
|
1311
|
+
# =============================================================================
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
@memory_app.command("emit-work")
|
|
1315
|
+
def emit_work(
|
|
1316
|
+
work_type: Annotated[
|
|
1317
|
+
str,
|
|
1318
|
+
typer.Argument(help="Work type (epic, story)"),
|
|
1319
|
+
],
|
|
1320
|
+
work_id: Annotated[
|
|
1321
|
+
str,
|
|
1322
|
+
typer.Argument(help="Work ID (e.g., E9, F9.4)"),
|
|
1323
|
+
],
|
|
1324
|
+
event_type: Annotated[
|
|
1325
|
+
str,
|
|
1326
|
+
typer.Option(
|
|
1327
|
+
"--event",
|
|
1328
|
+
"-e",
|
|
1329
|
+
help="Event type (start, complete, blocked, unblocked, abandoned)",
|
|
1330
|
+
),
|
|
1331
|
+
] = "start",
|
|
1332
|
+
phase: Annotated[
|
|
1333
|
+
str,
|
|
1334
|
+
typer.Option("--phase", "-p", help="Phase (design, plan, implement, review)"),
|
|
1335
|
+
] = "design",
|
|
1336
|
+
blocker: Annotated[
|
|
1337
|
+
str,
|
|
1338
|
+
typer.Option(
|
|
1339
|
+
"--blocker", "-b", help="Blocker description (for blocked events)"
|
|
1340
|
+
),
|
|
1341
|
+
] = "",
|
|
1342
|
+
) -> None:
|
|
1343
|
+
"""Emit a work lifecycle event for Lean flow analysis.
|
|
1344
|
+
|
|
1345
|
+
Tracks work items (epics, stories) through normalized phases to enable:
|
|
1346
|
+
- Lead time: total time from start to complete
|
|
1347
|
+
- Wait time: gaps between phases
|
|
1348
|
+
- WIP: work started but not completed
|
|
1349
|
+
- Bottlenecks: which phase takes longest
|
|
1350
|
+
- Cross-level analysis: compare epic vs story flow
|
|
1351
|
+
|
|
1352
|
+
Phases (normalized across all work types):
|
|
1353
|
+
- design: Scope definition and specification
|
|
1354
|
+
- plan: Task/story decomposition and sequencing
|
|
1355
|
+
- implement: Active development work
|
|
1356
|
+
- review: Retrospective and learnings
|
|
1357
|
+
|
|
1358
|
+
Examples:
|
|
1359
|
+
# Epic lifecycle
|
|
1360
|
+
$ raise memory emit-work epic E9 --event start --phase design
|
|
1361
|
+
$ raise memory emit-work epic E9 -e complete -p design
|
|
1362
|
+
$ raise memory emit-work epic E9 -e start -p plan
|
|
1363
|
+
|
|
1364
|
+
# Story lifecycle
|
|
1365
|
+
$ raise memory emit-work story F9.4 --event start --phase design
|
|
1366
|
+
$ raise memory emit-work story F9.4 -e complete -p implement
|
|
1367
|
+
$ raise memory emit-work story F9.4 -e start -p review
|
|
1368
|
+
|
|
1369
|
+
# Work blocked
|
|
1370
|
+
$ raise memory emit-work story F9.4 -e blocked -p plan -b "unclear requirements"
|
|
1371
|
+
|
|
1372
|
+
# Work unblocked
|
|
1373
|
+
$ raise memory emit-work story F9.4 -e unblocked -p plan
|
|
1374
|
+
"""
|
|
1375
|
+
# Validate work type
|
|
1376
|
+
valid_work_types: list[Literal["epic", "story"]] = ["epic", "story"]
|
|
1377
|
+
work_type_lower = work_type.lower()
|
|
1378
|
+
if work_type_lower not in valid_work_types:
|
|
1379
|
+
cli_error(
|
|
1380
|
+
f"Invalid work type: {work_type}",
|
|
1381
|
+
hint=f"Valid types: {', '.join(valid_work_types)}",
|
|
1382
|
+
exit_code=7,
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
# Validate event type
|
|
1386
|
+
valid_events: list[
|
|
1387
|
+
Literal["start", "complete", "blocked", "unblocked", "abandoned"]
|
|
1388
|
+
] = [
|
|
1389
|
+
"start",
|
|
1390
|
+
"complete",
|
|
1391
|
+
"blocked",
|
|
1392
|
+
"unblocked",
|
|
1393
|
+
"abandoned",
|
|
1394
|
+
]
|
|
1395
|
+
if event_type not in valid_events:
|
|
1396
|
+
cli_error(
|
|
1397
|
+
f"Invalid event: {event_type}",
|
|
1398
|
+
hint=f"Valid events: {', '.join(valid_events)}",
|
|
1399
|
+
exit_code=7,
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
# Validate phase
|
|
1403
|
+
valid_phases: list[Literal["init", "design", "plan", "implement", "review", "close"]] = [
|
|
1404
|
+
"init",
|
|
1405
|
+
"design",
|
|
1406
|
+
"plan",
|
|
1407
|
+
"implement",
|
|
1408
|
+
"review",
|
|
1409
|
+
"close",
|
|
1410
|
+
]
|
|
1411
|
+
if phase not in valid_phases:
|
|
1412
|
+
cli_error(
|
|
1413
|
+
f"Invalid phase: {phase}",
|
|
1414
|
+
hint=f"Valid phases: {', '.join(valid_phases)}",
|
|
1415
|
+
exit_code=7,
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
# Blocker is required for blocked events
|
|
1419
|
+
blocker_value = blocker if blocker else None
|
|
1420
|
+
if event_type == "blocked" and not blocker_value:
|
|
1421
|
+
console.print(
|
|
1422
|
+
"[yellow]Warning:[/yellow] No blocker description provided for blocked event"
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
# Create event
|
|
1426
|
+
lifecycle_event = WorkLifecycle(
|
|
1427
|
+
timestamp=datetime.now(UTC),
|
|
1428
|
+
work_type=work_type_lower, # type: ignore[arg-type]
|
|
1429
|
+
work_id=work_id,
|
|
1430
|
+
event=event_type, # type: ignore[arg-type]
|
|
1431
|
+
phase=phase, # type: ignore[arg-type]
|
|
1432
|
+
blocker=blocker_value,
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
# Emit signal
|
|
1436
|
+
result = emit(lifecycle_event)
|
|
1437
|
+
|
|
1438
|
+
if result.success:
|
|
1439
|
+
# Format label based on work type
|
|
1440
|
+
label = f"{work_type_lower.capitalize()} {work_id}"
|
|
1441
|
+
|
|
1442
|
+
# Format output based on event type
|
|
1443
|
+
if event_type == "start":
|
|
1444
|
+
console.print(f"\n[green]▶[/green] {label} → {phase} started")
|
|
1445
|
+
elif event_type == "complete":
|
|
1446
|
+
console.print(f"\n[green]✓[/green] {label} → {phase} complete")
|
|
1447
|
+
elif event_type == "blocked":
|
|
1448
|
+
console.print(f"\n[red]⏸[/red] {label} → {phase} blocked")
|
|
1449
|
+
if blocker_value:
|
|
1450
|
+
console.print(f" Blocker: {blocker_value}")
|
|
1451
|
+
elif event_type == "unblocked":
|
|
1452
|
+
console.print(f"\n[green]▶[/green] {label} → {phase} unblocked")
|
|
1453
|
+
elif event_type == "abandoned":
|
|
1454
|
+
console.print(f"\n[yellow]✗[/yellow] {label} → {phase} abandoned")
|
|
1455
|
+
|
|
1456
|
+
console.print(f"\n[dim]Saved to: {result.path}[/dim]\n")
|
|
1457
|
+
else:
|
|
1458
|
+
cli_error(result.error or "Failed to emit work lifecycle event")
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
@memory_app.command("emit-session")
|
|
1462
|
+
def emit_session_event(
|
|
1463
|
+
session_type: Annotated[
|
|
1464
|
+
str,
|
|
1465
|
+
typer.Option(
|
|
1466
|
+
"--type", "-t", help="Session type (e.g., story, research, maintenance)"
|
|
1467
|
+
),
|
|
1468
|
+
] = "story",
|
|
1469
|
+
outcome: Annotated[
|
|
1470
|
+
str,
|
|
1471
|
+
typer.Option(
|
|
1472
|
+
"--outcome",
|
|
1473
|
+
"-o",
|
|
1474
|
+
help="Session outcome (success, partial, abandoned)",
|
|
1475
|
+
),
|
|
1476
|
+
] = "success",
|
|
1477
|
+
duration: Annotated[
|
|
1478
|
+
int,
|
|
1479
|
+
typer.Option("--duration", "-d", help="Session duration in minutes"),
|
|
1480
|
+
] = 0,
|
|
1481
|
+
stories: Annotated[
|
|
1482
|
+
str,
|
|
1483
|
+
typer.Option("--stories", "-f", help="Stories worked on (comma-separated)"),
|
|
1484
|
+
] = "",
|
|
1485
|
+
) -> None:
|
|
1486
|
+
"""Emit a session event to telemetry.
|
|
1487
|
+
|
|
1488
|
+
Records a session completion signal for local learning and insights.
|
|
1489
|
+
Called at the end of /rai-session-close to capture session metadata.
|
|
1490
|
+
|
|
1491
|
+
Examples:
|
|
1492
|
+
# Basic session complete
|
|
1493
|
+
$ raise memory emit-session --type story --outcome success
|
|
1494
|
+
|
|
1495
|
+
# With duration and stories
|
|
1496
|
+
$ raise memory emit-session -t story -o success -d 45 -f F9.1,F9.2,F9.3
|
|
1497
|
+
|
|
1498
|
+
# Research session
|
|
1499
|
+
$ raise memory emit-session --type research --outcome partial --duration 90
|
|
1500
|
+
"""
|
|
1501
|
+
# Validate outcome
|
|
1502
|
+
valid_outcomes: list[Literal["success", "partial", "abandoned"]] = [
|
|
1503
|
+
"success",
|
|
1504
|
+
"partial",
|
|
1505
|
+
"abandoned",
|
|
1506
|
+
]
|
|
1507
|
+
if outcome not in valid_outcomes:
|
|
1508
|
+
cli_error(
|
|
1509
|
+
f"Invalid outcome: {outcome}",
|
|
1510
|
+
hint=f"Valid outcomes: {', '.join(valid_outcomes)}",
|
|
1511
|
+
exit_code=7,
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
# Parse stories
|
|
1515
|
+
stories_list = [f.strip() for f in stories.split(",") if f.strip()]
|
|
1516
|
+
|
|
1517
|
+
# Create event
|
|
1518
|
+
event = SessionEvent(
|
|
1519
|
+
timestamp=datetime.now(UTC),
|
|
1520
|
+
session_type=session_type,
|
|
1521
|
+
outcome=outcome, # type: ignore[arg-type]
|
|
1522
|
+
duration_min=duration,
|
|
1523
|
+
stories=stories_list,
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
# Emit signal
|
|
1527
|
+
result = emit(event)
|
|
1528
|
+
|
|
1529
|
+
if result.success:
|
|
1530
|
+
console.print("\n[green]✓[/green] Session event recorded")
|
|
1531
|
+
console.print(f" Type: {session_type}")
|
|
1532
|
+
console.print(f" Outcome: {outcome}")
|
|
1533
|
+
console.print(f" Duration: {duration} min")
|
|
1534
|
+
if stories_list:
|
|
1535
|
+
console.print(f" Stories: {', '.join(stories_list)}")
|
|
1536
|
+
console.print(f"\n[dim]Saved to: {result.path}[/dim]\n")
|
|
1537
|
+
else:
|
|
1538
|
+
cli_error(result.error or "Failed to emit session event")
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
@memory_app.command("emit-calibration")
|
|
1542
|
+
def emit_calibration_event(
|
|
1543
|
+
story: Annotated[
|
|
1544
|
+
str,
|
|
1545
|
+
typer.Argument(help="Story ID (e.g., F9.4)"),
|
|
1546
|
+
],
|
|
1547
|
+
size: Annotated[
|
|
1548
|
+
str,
|
|
1549
|
+
typer.Option("--size", "-s", help="T-shirt size (XS, S, M, L)"),
|
|
1550
|
+
] = "S",
|
|
1551
|
+
estimated: Annotated[
|
|
1552
|
+
int,
|
|
1553
|
+
typer.Option("--estimated", "-e", help="Estimated duration in minutes"),
|
|
1554
|
+
] = 0,
|
|
1555
|
+
actual: Annotated[
|
|
1556
|
+
int,
|
|
1557
|
+
typer.Option("--actual", "-a", help="Actual duration in minutes"),
|
|
1558
|
+
] = 0,
|
|
1559
|
+
) -> None:
|
|
1560
|
+
"""Emit a calibration event to telemetry.
|
|
1561
|
+
|
|
1562
|
+
Records estimate vs actual for velocity tracking and pattern detection.
|
|
1563
|
+
Called at the end of /rai-story-review to capture calibration data.
|
|
1564
|
+
|
|
1565
|
+
Velocity is calculated automatically: estimated / actual.
|
|
1566
|
+
- velocity > 1.0 means faster than estimated
|
|
1567
|
+
- velocity < 1.0 means slower than estimated
|
|
1568
|
+
|
|
1569
|
+
Examples:
|
|
1570
|
+
# Story completed faster than estimated
|
|
1571
|
+
$ raise memory emit-calibration F9.4 --size S --estimated 30 --actual 15
|
|
1572
|
+
|
|
1573
|
+
# Story took longer
|
|
1574
|
+
$ raise memory emit-calibration F9.4 -s M -e 60 -a 90
|
|
1575
|
+
|
|
1576
|
+
# Short form
|
|
1577
|
+
$ raise memory emit-calibration F9.4 -s S -e 30 -a 15
|
|
1578
|
+
"""
|
|
1579
|
+
# Validate size
|
|
1580
|
+
valid_sizes = ["XS", "S", "M", "L", "XL"]
|
|
1581
|
+
size_upper = size.upper()
|
|
1582
|
+
if size_upper not in valid_sizes:
|
|
1583
|
+
cli_error(
|
|
1584
|
+
f"Invalid size: {size}",
|
|
1585
|
+
hint=f"Valid sizes: {', '.join(valid_sizes)}",
|
|
1586
|
+
exit_code=7,
|
|
1587
|
+
)
|
|
1588
|
+
|
|
1589
|
+
# Validate durations
|
|
1590
|
+
if estimated <= 0:
|
|
1591
|
+
cli_error("Estimated duration must be > 0", exit_code=7)
|
|
1592
|
+
if actual <= 0:
|
|
1593
|
+
cli_error("Actual duration must be > 0", exit_code=7)
|
|
1594
|
+
|
|
1595
|
+
# Calculate velocity
|
|
1596
|
+
velocity = round(estimated / actual, 2)
|
|
1597
|
+
|
|
1598
|
+
# Create event
|
|
1599
|
+
event = CalibrationEvent(
|
|
1600
|
+
timestamp=datetime.now(UTC),
|
|
1601
|
+
story_id=story,
|
|
1602
|
+
story_size=size_upper,
|
|
1603
|
+
estimated_min=estimated,
|
|
1604
|
+
actual_min=actual,
|
|
1605
|
+
velocity=velocity,
|
|
1606
|
+
)
|
|
1607
|
+
|
|
1608
|
+
# Emit signal
|
|
1609
|
+
result = emit(event)
|
|
1610
|
+
|
|
1611
|
+
if result.success:
|
|
1612
|
+
console.print("\n[green]✓[/green] Calibration event recorded")
|
|
1613
|
+
console.print(f" Story: {story}")
|
|
1614
|
+
console.print(f" Size: {size_upper}")
|
|
1615
|
+
console.print(f" Estimated: {estimated} min")
|
|
1616
|
+
console.print(f" Actual: {actual} min")
|
|
1617
|
+
console.print(f" Velocity: {velocity}x", end="")
|
|
1618
|
+
if velocity > 1.0:
|
|
1619
|
+
console.print(" [green](faster than estimated)[/green]")
|
|
1620
|
+
elif velocity < 1.0:
|
|
1621
|
+
console.print(" [yellow](slower than estimated)[/yellow]")
|
|
1622
|
+
else:
|
|
1623
|
+
console.print(" (on target)")
|
|
1624
|
+
console.print(f"\n[dim]Saved to: {result.path}[/dim]\n")
|
|
1625
|
+
else:
|
|
1626
|
+
cli_error(result.error or "Failed to emit calibration event")
|