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.
Files changed (137) hide show
  1. rai_cli/__init__.py +38 -0
  2. rai_cli/__main__.py +30 -0
  3. rai_cli/cli/__init__.py +3 -0
  4. rai_cli/cli/commands/__init__.py +3 -0
  5. rai_cli/cli/commands/base.py +101 -0
  6. rai_cli/cli/commands/discover.py +547 -0
  7. rai_cli/cli/commands/init.py +460 -0
  8. rai_cli/cli/commands/memory.py +1626 -0
  9. rai_cli/cli/commands/profile.py +51 -0
  10. rai_cli/cli/commands/session.py +264 -0
  11. rai_cli/cli/commands/skill.py +226 -0
  12. rai_cli/cli/error_handler.py +158 -0
  13. rai_cli/cli/main.py +137 -0
  14. rai_cli/config/__init__.py +11 -0
  15. rai_cli/config/paths.py +309 -0
  16. rai_cli/config/settings.py +180 -0
  17. rai_cli/context/__init__.py +42 -0
  18. rai_cli/context/analyzers/__init__.py +16 -0
  19. rai_cli/context/analyzers/models.py +36 -0
  20. rai_cli/context/analyzers/protocol.py +43 -0
  21. rai_cli/context/analyzers/python.py +291 -0
  22. rai_cli/context/builder.py +1566 -0
  23. rai_cli/context/diff.py +213 -0
  24. rai_cli/context/extractors/__init__.py +13 -0
  25. rai_cli/context/extractors/skills.py +121 -0
  26. rai_cli/context/graph.py +300 -0
  27. rai_cli/context/models.py +134 -0
  28. rai_cli/context/query.py +507 -0
  29. rai_cli/core/__init__.py +37 -0
  30. rai_cli/core/files.py +66 -0
  31. rai_cli/core/text.py +174 -0
  32. rai_cli/core/tools.py +441 -0
  33. rai_cli/discovery/__init__.py +50 -0
  34. rai_cli/discovery/analyzer.py +601 -0
  35. rai_cli/discovery/drift.py +355 -0
  36. rai_cli/discovery/scanner.py +1200 -0
  37. rai_cli/engines/__init__.py +3 -0
  38. rai_cli/exceptions.py +200 -0
  39. rai_cli/governance/__init__.py +11 -0
  40. rai_cli/governance/extractor.py +311 -0
  41. rai_cli/governance/models.py +132 -0
  42. rai_cli/governance/parsers/__init__.py +35 -0
  43. rai_cli/governance/parsers/adr.py +255 -0
  44. rai_cli/governance/parsers/backlog.py +302 -0
  45. rai_cli/governance/parsers/constitution.py +100 -0
  46. rai_cli/governance/parsers/epic.py +299 -0
  47. rai_cli/governance/parsers/glossary.py +297 -0
  48. rai_cli/governance/parsers/guardrails.py +326 -0
  49. rai_cli/governance/parsers/prd.py +93 -0
  50. rai_cli/governance/parsers/vision.py +97 -0
  51. rai_cli/handlers/__init__.py +3 -0
  52. rai_cli/memory/__init__.py +58 -0
  53. rai_cli/memory/loader.py +247 -0
  54. rai_cli/memory/migration.py +247 -0
  55. rai_cli/memory/models.py +169 -0
  56. rai_cli/memory/writer.py +485 -0
  57. rai_cli/onboarding/__init__.py +96 -0
  58. rai_cli/onboarding/bootstrap.py +164 -0
  59. rai_cli/onboarding/claudemd.py +209 -0
  60. rai_cli/onboarding/conventions.py +742 -0
  61. rai_cli/onboarding/detection.py +155 -0
  62. rai_cli/onboarding/governance.py +443 -0
  63. rai_cli/onboarding/manifest.py +101 -0
  64. rai_cli/onboarding/memory_md.py +387 -0
  65. rai_cli/onboarding/migration.py +207 -0
  66. rai_cli/onboarding/profile.py +457 -0
  67. rai_cli/onboarding/skills.py +114 -0
  68. rai_cli/output/__init__.py +28 -0
  69. rai_cli/output/console.py +394 -0
  70. rai_cli/output/formatters/__init__.py +9 -0
  71. rai_cli/output/formatters/discover.py +442 -0
  72. rai_cli/output/formatters/skill.py +293 -0
  73. rai_cli/rai_base/__init__.py +22 -0
  74. rai_cli/rai_base/framework/__init__.py +7 -0
  75. rai_cli/rai_base/framework/methodology.yaml +235 -0
  76. rai_cli/rai_base/governance/__init__.py +1 -0
  77. rai_cli/rai_base/governance/architecture/__init__.py +1 -0
  78. rai_cli/rai_base/governance/architecture/domain-model.md +20 -0
  79. rai_cli/rai_base/governance/architecture/system-context.md +34 -0
  80. rai_cli/rai_base/governance/architecture/system-design.md +24 -0
  81. rai_cli/rai_base/governance/backlog.md +8 -0
  82. rai_cli/rai_base/governance/guardrails.md +18 -0
  83. rai_cli/rai_base/governance/prd.md +25 -0
  84. rai_cli/rai_base/governance/vision.md +16 -0
  85. rai_cli/rai_base/identity/__init__.py +8 -0
  86. rai_cli/rai_base/identity/core.md +119 -0
  87. rai_cli/rai_base/identity/perspective.md +119 -0
  88. rai_cli/rai_base/memory/__init__.py +7 -0
  89. rai_cli/rai_base/memory/patterns-base.jsonl +20 -0
  90. rai_cli/schemas/__init__.py +3 -0
  91. rai_cli/schemas/session_state.py +106 -0
  92. rai_cli/session/__init__.py +5 -0
  93. rai_cli/session/bundle.py +389 -0
  94. rai_cli/session/close.py +255 -0
  95. rai_cli/session/state.py +108 -0
  96. rai_cli/skills/__init__.py +44 -0
  97. rai_cli/skills/locator.py +129 -0
  98. rai_cli/skills/name_checker.py +203 -0
  99. rai_cli/skills/parser.py +145 -0
  100. rai_cli/skills/scaffold.py +185 -0
  101. rai_cli/skills/schema.py +130 -0
  102. rai_cli/skills/validator.py +172 -0
  103. rai_cli/skills_base/__init__.py +59 -0
  104. rai_cli/skills_base/rai-debug/SKILL.md +296 -0
  105. rai_cli/skills_base/rai-discover-document/SKILL.md +292 -0
  106. rai_cli/skills_base/rai-discover-scan/SKILL.md +325 -0
  107. rai_cli/skills_base/rai-discover-start/SKILL.md +213 -0
  108. rai_cli/skills_base/rai-discover-validate/SKILL.md +310 -0
  109. rai_cli/skills_base/rai-epic-close/SKILL.md +369 -0
  110. rai_cli/skills_base/rai-epic-design/SKILL.md +622 -0
  111. rai_cli/skills_base/rai-epic-plan/SKILL.md +672 -0
  112. rai_cli/skills_base/rai-epic-plan/_references/sequencing-strategies.md +67 -0
  113. rai_cli/skills_base/rai-epic-start/SKILL.md +217 -0
  114. rai_cli/skills_base/rai-project-create/SKILL.md +455 -0
  115. rai_cli/skills_base/rai-project-onboard/SKILL.md +503 -0
  116. rai_cli/skills_base/rai-research/SKILL.md +264 -0
  117. rai_cli/skills_base/rai-research/references/research-prompt-template.md +317 -0
  118. rai_cli/skills_base/rai-session-close/SKILL.md +151 -0
  119. rai_cli/skills_base/rai-session-start/SKILL.md +110 -0
  120. rai_cli/skills_base/rai-story-close/SKILL.md +367 -0
  121. rai_cli/skills_base/rai-story-design/SKILL.md +339 -0
  122. rai_cli/skills_base/rai-story-design/references/tech-design-story-v2.md +293 -0
  123. rai_cli/skills_base/rai-story-implement/SKILL.md +256 -0
  124. rai_cli/skills_base/rai-story-plan/SKILL.md +307 -0
  125. rai_cli/skills_base/rai-story-review/SKILL.md +276 -0
  126. rai_cli/skills_base/rai-story-start/SKILL.md +288 -0
  127. rai_cli/telemetry/__init__.py +42 -0
  128. rai_cli/telemetry/schemas.py +285 -0
  129. rai_cli/telemetry/writer.py +210 -0
  130. rai_cli/viz/__init__.py +7 -0
  131. rai_cli/viz/generator.py +404 -0
  132. rai_cli-2.0.0a1.dist-info/METADATA +289 -0
  133. rai_cli-2.0.0a1.dist-info/RECORD +137 -0
  134. rai_cli-2.0.0a1.dist-info/WHEEL +4 -0
  135. rai_cli-2.0.0a1.dist-info/entry_points.txt +2 -0
  136. rai_cli-2.0.0a1.dist-info/licenses/LICENSE +190 -0
  137. 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")