raise-cli 2.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. raise_cli/__init__.py +38 -0
  2. raise_cli/__main__.py +30 -0
  3. raise_cli/adapters/__init__.py +91 -0
  4. raise_cli/adapters/declarative/__init__.py +26 -0
  5. raise_cli/adapters/declarative/adapter.py +267 -0
  6. raise_cli/adapters/declarative/discovery.py +94 -0
  7. raise_cli/adapters/declarative/expressions.py +150 -0
  8. raise_cli/adapters/declarative/reference/__init__.py +1 -0
  9. raise_cli/adapters/declarative/reference/github.yaml +143 -0
  10. raise_cli/adapters/declarative/schema.py +98 -0
  11. raise_cli/adapters/filesystem.py +299 -0
  12. raise_cli/adapters/mcp_bridge.py +10 -0
  13. raise_cli/adapters/mcp_confluence.py +246 -0
  14. raise_cli/adapters/mcp_jira.py +405 -0
  15. raise_cli/adapters/models.py +205 -0
  16. raise_cli/adapters/protocols.py +180 -0
  17. raise_cli/adapters/registry.py +90 -0
  18. raise_cli/adapters/sync.py +149 -0
  19. raise_cli/agents/__init__.py +14 -0
  20. raise_cli/agents/antigravity.yaml +8 -0
  21. raise_cli/agents/claude.yaml +8 -0
  22. raise_cli/agents/copilot.yaml +8 -0
  23. raise_cli/agents/copilot_plugin.py +124 -0
  24. raise_cli/agents/cursor.yaml +7 -0
  25. raise_cli/agents/roo.yaml +8 -0
  26. raise_cli/agents/windsurf.yaml +8 -0
  27. raise_cli/artifacts/__init__.py +30 -0
  28. raise_cli/artifacts/models.py +43 -0
  29. raise_cli/artifacts/reader.py +55 -0
  30. raise_cli/artifacts/renderer.py +104 -0
  31. raise_cli/artifacts/story_design.py +69 -0
  32. raise_cli/artifacts/writer.py +45 -0
  33. raise_cli/backlog/__init__.py +1 -0
  34. raise_cli/backlog/sync.py +115 -0
  35. raise_cli/cli/__init__.py +3 -0
  36. raise_cli/cli/commands/__init__.py +3 -0
  37. raise_cli/cli/commands/_resolve.py +153 -0
  38. raise_cli/cli/commands/adapters.py +362 -0
  39. raise_cli/cli/commands/artifact.py +137 -0
  40. raise_cli/cli/commands/backlog.py +333 -0
  41. raise_cli/cli/commands/base.py +31 -0
  42. raise_cli/cli/commands/discover.py +551 -0
  43. raise_cli/cli/commands/docs.py +130 -0
  44. raise_cli/cli/commands/doctor.py +177 -0
  45. raise_cli/cli/commands/gate.py +223 -0
  46. raise_cli/cli/commands/graph.py +1086 -0
  47. raise_cli/cli/commands/info.py +81 -0
  48. raise_cli/cli/commands/init.py +746 -0
  49. raise_cli/cli/commands/journal.py +167 -0
  50. raise_cli/cli/commands/mcp.py +524 -0
  51. raise_cli/cli/commands/memory.py +467 -0
  52. raise_cli/cli/commands/pattern.py +348 -0
  53. raise_cli/cli/commands/profile.py +59 -0
  54. raise_cli/cli/commands/publish.py +80 -0
  55. raise_cli/cli/commands/release.py +338 -0
  56. raise_cli/cli/commands/session.py +528 -0
  57. raise_cli/cli/commands/signal.py +410 -0
  58. raise_cli/cli/commands/skill.py +350 -0
  59. raise_cli/cli/commands/skill_set.py +145 -0
  60. raise_cli/cli/error_handler.py +158 -0
  61. raise_cli/cli/main.py +163 -0
  62. raise_cli/compat.py +66 -0
  63. raise_cli/config/__init__.py +41 -0
  64. raise_cli/config/agent_plugin.py +105 -0
  65. raise_cli/config/agent_registry.py +233 -0
  66. raise_cli/config/agents.py +120 -0
  67. raise_cli/config/ide.py +32 -0
  68. raise_cli/config/paths.py +379 -0
  69. raise_cli/config/settings.py +180 -0
  70. raise_cli/context/__init__.py +42 -0
  71. raise_cli/context/analyzers/__init__.py +16 -0
  72. raise_cli/context/analyzers/models.py +36 -0
  73. raise_cli/context/analyzers/protocol.py +43 -0
  74. raise_cli/context/analyzers/python.py +292 -0
  75. raise_cli/context/builder.py +1569 -0
  76. raise_cli/context/diff.py +213 -0
  77. raise_cli/context/extractors/__init__.py +13 -0
  78. raise_cli/context/extractors/skills.py +121 -0
  79. raise_cli/core/__init__.py +37 -0
  80. raise_cli/core/files.py +66 -0
  81. raise_cli/core/text.py +174 -0
  82. raise_cli/core/tools.py +441 -0
  83. raise_cli/discovery/__init__.py +50 -0
  84. raise_cli/discovery/analyzer.py +691 -0
  85. raise_cli/discovery/drift.py +355 -0
  86. raise_cli/discovery/scanner.py +1687 -0
  87. raise_cli/doctor/__init__.py +4 -0
  88. raise_cli/doctor/checks/__init__.py +1 -0
  89. raise_cli/doctor/checks/environment.py +110 -0
  90. raise_cli/doctor/checks/project.py +238 -0
  91. raise_cli/doctor/fix.py +80 -0
  92. raise_cli/doctor/models.py +56 -0
  93. raise_cli/doctor/protocol.py +43 -0
  94. raise_cli/doctor/registry.py +100 -0
  95. raise_cli/doctor/report.py +141 -0
  96. raise_cli/doctor/runner.py +95 -0
  97. raise_cli/engines/__init__.py +3 -0
  98. raise_cli/exceptions.py +215 -0
  99. raise_cli/gates/__init__.py +19 -0
  100. raise_cli/gates/builtin/__init__.py +1 -0
  101. raise_cli/gates/builtin/coverage.py +52 -0
  102. raise_cli/gates/builtin/lint.py +48 -0
  103. raise_cli/gates/builtin/tests.py +48 -0
  104. raise_cli/gates/builtin/types.py +48 -0
  105. raise_cli/gates/models.py +40 -0
  106. raise_cli/gates/protocol.py +41 -0
  107. raise_cli/gates/registry.py +141 -0
  108. raise_cli/governance/__init__.py +11 -0
  109. raise_cli/governance/extractor.py +412 -0
  110. raise_cli/governance/models.py +134 -0
  111. raise_cli/governance/parsers/__init__.py +35 -0
  112. raise_cli/governance/parsers/_convert.py +38 -0
  113. raise_cli/governance/parsers/adr.py +274 -0
  114. raise_cli/governance/parsers/backlog.py +356 -0
  115. raise_cli/governance/parsers/constitution.py +119 -0
  116. raise_cli/governance/parsers/epic.py +323 -0
  117. raise_cli/governance/parsers/glossary.py +316 -0
  118. raise_cli/governance/parsers/guardrails.py +345 -0
  119. raise_cli/governance/parsers/prd.py +112 -0
  120. raise_cli/governance/parsers/roadmap.py +118 -0
  121. raise_cli/governance/parsers/vision.py +116 -0
  122. raise_cli/graph/__init__.py +1 -0
  123. raise_cli/graph/backends/__init__.py +57 -0
  124. raise_cli/graph/backends/api.py +137 -0
  125. raise_cli/graph/backends/dual.py +139 -0
  126. raise_cli/graph/backends/pending.py +84 -0
  127. raise_cli/handlers/__init__.py +3 -0
  128. raise_cli/hooks/__init__.py +54 -0
  129. raise_cli/hooks/builtin/__init__.py +1 -0
  130. raise_cli/hooks/builtin/backlog.py +216 -0
  131. raise_cli/hooks/builtin/gate_bridge.py +83 -0
  132. raise_cli/hooks/builtin/jira_sync.py +127 -0
  133. raise_cli/hooks/builtin/memory.py +117 -0
  134. raise_cli/hooks/builtin/telemetry.py +72 -0
  135. raise_cli/hooks/emitter.py +184 -0
  136. raise_cli/hooks/events.py +262 -0
  137. raise_cli/hooks/protocol.py +38 -0
  138. raise_cli/hooks/registry.py +117 -0
  139. raise_cli/mcp/__init__.py +33 -0
  140. raise_cli/mcp/bridge.py +218 -0
  141. raise_cli/mcp/models.py +43 -0
  142. raise_cli/mcp/registry.py +77 -0
  143. raise_cli/mcp/schema.py +41 -0
  144. raise_cli/memory/__init__.py +58 -0
  145. raise_cli/memory/loader.py +247 -0
  146. raise_cli/memory/migration.py +241 -0
  147. raise_cli/memory/models.py +169 -0
  148. raise_cli/memory/writer.py +598 -0
  149. raise_cli/onboarding/__init__.py +103 -0
  150. raise_cli/onboarding/bootstrap.py +324 -0
  151. raise_cli/onboarding/claudemd.py +17 -0
  152. raise_cli/onboarding/conventions.py +742 -0
  153. raise_cli/onboarding/detection.py +374 -0
  154. raise_cli/onboarding/governance.py +443 -0
  155. raise_cli/onboarding/instructions.py +672 -0
  156. raise_cli/onboarding/manifest.py +201 -0
  157. raise_cli/onboarding/memory_md.py +399 -0
  158. raise_cli/onboarding/migration.py +207 -0
  159. raise_cli/onboarding/profile.py +624 -0
  160. raise_cli/onboarding/skill_conflict.py +100 -0
  161. raise_cli/onboarding/skill_manifest.py +176 -0
  162. raise_cli/onboarding/skills.py +437 -0
  163. raise_cli/onboarding/workflows.py +101 -0
  164. raise_cli/output/__init__.py +28 -0
  165. raise_cli/output/console.py +394 -0
  166. raise_cli/output/formatters/__init__.py +9 -0
  167. raise_cli/output/formatters/adapters.py +135 -0
  168. raise_cli/output/formatters/discover.py +439 -0
  169. raise_cli/output/formatters/skill.py +298 -0
  170. raise_cli/publish/__init__.py +3 -0
  171. raise_cli/publish/changelog.py +80 -0
  172. raise_cli/publish/check.py +179 -0
  173. raise_cli/publish/version.py +172 -0
  174. raise_cli/rai_base/__init__.py +22 -0
  175. raise_cli/rai_base/framework/__init__.py +7 -0
  176. raise_cli/rai_base/framework/methodology.yaml +233 -0
  177. raise_cli/rai_base/governance/__init__.py +1 -0
  178. raise_cli/rai_base/governance/architecture/__init__.py +1 -0
  179. raise_cli/rai_base/governance/architecture/domain-model.md +20 -0
  180. raise_cli/rai_base/governance/architecture/system-context.md +34 -0
  181. raise_cli/rai_base/governance/architecture/system-design.md +24 -0
  182. raise_cli/rai_base/governance/backlog.md +8 -0
  183. raise_cli/rai_base/governance/guardrails.md +17 -0
  184. raise_cli/rai_base/governance/prd.md +25 -0
  185. raise_cli/rai_base/governance/vision.md +16 -0
  186. raise_cli/rai_base/identity/__init__.py +8 -0
  187. raise_cli/rai_base/identity/core.md +119 -0
  188. raise_cli/rai_base/identity/perspective.md +119 -0
  189. raise_cli/rai_base/memory/__init__.py +7 -0
  190. raise_cli/rai_base/memory/patterns-base.jsonl +55 -0
  191. raise_cli/schemas/__init__.py +3 -0
  192. raise_cli/schemas/journal.py +49 -0
  193. raise_cli/schemas/session_state.py +117 -0
  194. raise_cli/session/__init__.py +5 -0
  195. raise_cli/session/bundle.py +820 -0
  196. raise_cli/session/close.py +268 -0
  197. raise_cli/session/journal.py +119 -0
  198. raise_cli/session/resolver.py +126 -0
  199. raise_cli/session/state.py +187 -0
  200. raise_cli/skills/__init__.py +44 -0
  201. raise_cli/skills/locator.py +141 -0
  202. raise_cli/skills/name_checker.py +199 -0
  203. raise_cli/skills/parser.py +145 -0
  204. raise_cli/skills/scaffold.py +212 -0
  205. raise_cli/skills/schema.py +132 -0
  206. raise_cli/skills/skillsets.py +195 -0
  207. raise_cli/skills/validator.py +197 -0
  208. raise_cli/skills_base/__init__.py +80 -0
  209. raise_cli/skills_base/contract-template.md +60 -0
  210. raise_cli/skills_base/preamble.md +37 -0
  211. raise_cli/skills_base/rai-architecture-review/SKILL.md +137 -0
  212. raise_cli/skills_base/rai-debug/SKILL.md +171 -0
  213. raise_cli/skills_base/rai-discover/SKILL.md +167 -0
  214. raise_cli/skills_base/rai-discover-document/SKILL.md +128 -0
  215. raise_cli/skills_base/rai-discover-scan/SKILL.md +147 -0
  216. raise_cli/skills_base/rai-discover-start/SKILL.md +145 -0
  217. raise_cli/skills_base/rai-discover-validate/SKILL.md +142 -0
  218. raise_cli/skills_base/rai-docs-update/SKILL.md +142 -0
  219. raise_cli/skills_base/rai-doctor/SKILL.md +120 -0
  220. raise_cli/skills_base/rai-epic-close/SKILL.md +165 -0
  221. raise_cli/skills_base/rai-epic-close/templates/retrospective.md +68 -0
  222. raise_cli/skills_base/rai-epic-design/SKILL.md +146 -0
  223. raise_cli/skills_base/rai-epic-design/templates/design.md +24 -0
  224. raise_cli/skills_base/rai-epic-design/templates/scope.md +76 -0
  225. raise_cli/skills_base/rai-epic-plan/SKILL.md +153 -0
  226. raise_cli/skills_base/rai-epic-plan/_references/sequencing-strategies.md +67 -0
  227. raise_cli/skills_base/rai-epic-plan/templates/plan-section.md +49 -0
  228. raise_cli/skills_base/rai-epic-run/SKILL.md +208 -0
  229. raise_cli/skills_base/rai-epic-start/SKILL.md +136 -0
  230. raise_cli/skills_base/rai-epic-start/templates/brief.md +34 -0
  231. raise_cli/skills_base/rai-mcp-add/SKILL.md +176 -0
  232. raise_cli/skills_base/rai-mcp-remove/SKILL.md +120 -0
  233. raise_cli/skills_base/rai-mcp-status/SKILL.md +147 -0
  234. raise_cli/skills_base/rai-problem-shape/SKILL.md +138 -0
  235. raise_cli/skills_base/rai-project-create/SKILL.md +144 -0
  236. raise_cli/skills_base/rai-project-onboard/SKILL.md +162 -0
  237. raise_cli/skills_base/rai-quality-review/SKILL.md +189 -0
  238. raise_cli/skills_base/rai-research/SKILL.md +143 -0
  239. raise_cli/skills_base/rai-research/references/research-prompt-template.md +317 -0
  240. raise_cli/skills_base/rai-session-close/SKILL.md +176 -0
  241. raise_cli/skills_base/rai-session-start/SKILL.md +110 -0
  242. raise_cli/skills_base/rai-story-close/SKILL.md +198 -0
  243. raise_cli/skills_base/rai-story-design/SKILL.md +203 -0
  244. raise_cli/skills_base/rai-story-design/references/tech-design-story-v2.md +293 -0
  245. raise_cli/skills_base/rai-story-implement/SKILL.md +115 -0
  246. raise_cli/skills_base/rai-story-plan/SKILL.md +135 -0
  247. raise_cli/skills_base/rai-story-review/SKILL.md +178 -0
  248. raise_cli/skills_base/rai-story-run/SKILL.md +282 -0
  249. raise_cli/skills_base/rai-story-start/SKILL.md +166 -0
  250. raise_cli/skills_base/rai-story-start/templates/story.md +38 -0
  251. raise_cli/skills_base/rai-welcome/SKILL.md +134 -0
  252. raise_cli/telemetry/__init__.py +42 -0
  253. raise_cli/telemetry/schemas.py +285 -0
  254. raise_cli/telemetry/writer.py +217 -0
  255. raise_cli/tier/__init__.py +0 -0
  256. raise_cli/tier/context.py +134 -0
  257. raise_cli/viz/__init__.py +7 -0
  258. raise_cli/viz/generator.py +406 -0
  259. raise_cli-2.2.1.dist-info/METADATA +433 -0
  260. raise_cli-2.2.1.dist-info/RECORD +264 -0
  261. raise_cli-2.2.1.dist-info/WHEEL +4 -0
  262. raise_cli-2.2.1.dist-info/entry_points.txt +40 -0
  263. raise_cli-2.2.1.dist-info/licenses/LICENSE +190 -0
  264. raise_cli-2.2.1.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,820 @@
1
+ """Context bundle assembly for session start.
2
+
3
+ Assembles a token-optimized context bundle (~600 tokens) from multiple sources:
4
+ 1. ~/.rai/developer.yaml → developer model + coaching + deadlines
5
+ 2. .raise/rai/session-state.yaml → current work state
6
+ 3. Memory graph → foundational patterns, governance primes
7
+ 4. .raise/rai/personal/sessions/index.jsonl → recent sessions
8
+
9
+ Note: Identity primes (RAI-VAL-*, RAI-BND-*) are no longer emitted here.
10
+ They live in CLAUDE.md as always-on content (ADR-012).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ from collections.abc import Callable
18
+ from datetime import date
19
+ from pathlib import Path
20
+
21
+ from pydantic import BaseModel
22
+
23
+ from raise_cli.graph.backends import get_active_backend
24
+ from raise_cli.onboarding.profile import DeveloperProfile
25
+ from raise_cli.schemas.session_state import SessionState
26
+ from raise_core.graph.models import GraphNode
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class LiveBacklogStatus(BaseModel):
32
+ """Live status fetched from backlog adapter during session-start."""
33
+
34
+ epic_status: str = ""
35
+ epic_summary: str = ""
36
+ story_status: str = ""
37
+ story_summary: str = ""
38
+ warning: str = ""
39
+
40
+
41
+ def _fetch_live_status(
42
+ state: SessionState | None,
43
+ timeout: float = 5.0,
44
+ ) -> LiveBacklogStatus:
45
+ """Query backlog adapter for live epic/story status.
46
+
47
+ Returns LiveBacklogStatus with warning on any failure.
48
+ Never raises — all errors are caught and surfaced as warnings.
49
+ """
50
+ if state is None:
51
+ return LiveBacklogStatus()
52
+
53
+ epic_key = state.current_work.epic
54
+ story_key = state.current_work.story
55
+
56
+ if not epic_key and not story_key:
57
+ return LiveBacklogStatus()
58
+
59
+ return _query_adapter(epic_key, story_key, timeout)
60
+
61
+
62
+ def _query_adapter(
63
+ epic_key: str,
64
+ story_key: str,
65
+ timeout: float,
66
+ ) -> LiveBacklogStatus:
67
+ """Resolve adapter and run queries with timeout. Never raises.
68
+
69
+ The entire operation (adapter resolution + issue fetches) runs inside
70
+ the ThreadPoolExecutor so that the timeout covers everything, including
71
+ slow adapter startup (e.g., MCP bridge initialization).
72
+ """
73
+ from concurrent.futures import ThreadPoolExecutor
74
+ from concurrent.futures import TimeoutError as FuturesTimeoutError
75
+
76
+ from raise_cli.adapters.models import IssueDetail
77
+ from raise_cli.adapters.protocols import ProjectManagementAdapter
78
+ from raise_cli.cli.commands._resolve import resolve_adapter
79
+
80
+ def _do_fetch() -> LiveBacklogStatus:
81
+ adapter: ProjectManagementAdapter = resolve_adapter(None)
82
+ result = LiveBacklogStatus()
83
+ if epic_key:
84
+ detail: IssueDetail = adapter.get_issue(epic_key)
85
+ result.epic_status = detail.status
86
+ result.epic_summary = detail.summary
87
+ if story_key:
88
+ detail = adapter.get_issue(story_key)
89
+ result.story_status = detail.status
90
+ result.story_summary = detail.summary
91
+ return result
92
+
93
+ try:
94
+ with ThreadPoolExecutor(max_workers=1) as pool:
95
+ future = pool.submit(_do_fetch)
96
+ return future.result(timeout=timeout)
97
+ except FuturesTimeoutError:
98
+ logger.debug("Live status fetch timed out after %.1fs", timeout)
99
+ return LiveBacklogStatus(
100
+ warning=f"Backlog query timeout ({timeout:.0f}s) — showing cached state"
101
+ )
102
+ except SystemExit:
103
+ # resolve_adapter() uses sys.exit() on failure; SystemExit is
104
+ # BaseException, not Exception, so we catch it explicitly.
105
+ logger.debug("Adapter unavailable (SystemExit)")
106
+ return LiveBacklogStatus(
107
+ warning="Backlog adapter unavailable — showing cached state"
108
+ )
109
+ except Exception as exc:
110
+ logger.debug("Live status fetch failed: %s", exc)
111
+ return LiveBacklogStatus(
112
+ warning=f"Backlog query error: {exc} — showing cached state"
113
+ )
114
+
115
+
116
+ # Graph path relative to project root
117
+ GRAPH_REL_PATH = Path(".raise") / "rai" / "memory" / "index.json"
118
+ # Sessions index path relative to project root (personal = developer-specific)
119
+ SESSIONS_INDEX_REL_PATH = (
120
+ Path(".raise") / "rai" / "personal" / "sessions" / "index.jsonl"
121
+ )
122
+
123
+
124
+ def get_foundational_patterns(project_path: Path) -> list[GraphNode]:
125
+ """Query memory graph for foundational patterns.
126
+
127
+ Args:
128
+ project_path: Absolute path to the project root.
129
+
130
+ Returns:
131
+ List of pattern GraphNodes with foundational=true metadata.
132
+ """
133
+ graph_path = project_path / GRAPH_REL_PATH
134
+ if not graph_path.exists():
135
+ logger.debug("Graph not found: %s", graph_path)
136
+ return []
137
+
138
+ try:
139
+ graph = get_active_backend(graph_path).load()
140
+ except Exception:
141
+ logger.warning("Failed to load graph: %s", graph_path)
142
+ return []
143
+
144
+ return [
145
+ node
146
+ for node in graph.iter_concepts()
147
+ if node.type == "pattern" and node.metadata.get("foundational") is True
148
+ ]
149
+
150
+
151
+ def get_always_on_primes(project_path: Path) -> list[GraphNode]:
152
+ """Query memory graph for all always_on nodes (governance + identity).
153
+
154
+ Args:
155
+ project_path: Absolute path to the project root.
156
+
157
+ Returns:
158
+ List of GraphNodes with always_on=true metadata.
159
+ """
160
+ graph_path = project_path / GRAPH_REL_PATH
161
+ if not graph_path.exists():
162
+ logger.debug("Graph not found: %s", graph_path)
163
+ return []
164
+
165
+ try:
166
+ graph = get_active_backend(graph_path).load()
167
+ except Exception:
168
+ logger.warning("Failed to load graph: %s", graph_path)
169
+ return []
170
+
171
+ return [
172
+ node for node in graph.iter_concepts() if node.metadata.get("always_on") is True
173
+ ]
174
+
175
+
176
+ def _format_developer_section(profile: DeveloperProfile) -> str:
177
+ """Format developer identity line with non-default communication prefs."""
178
+ line = f"Developer: {profile.name} ({profile.experience_level.value})"
179
+
180
+ # Surface communication preferences that deviate from defaults
181
+ comm = profile.communication
182
+ prefs: list[str] = []
183
+ if comm.language != "en":
184
+ prefs.append(f"language: {comm.language}")
185
+ if comm.style.value != "balanced":
186
+ prefs.append(f"style: {comm.style.value}")
187
+ if comm.skip_praise:
188
+ prefs.append("skip_praise")
189
+ if comm.redirect_when_dispersing:
190
+ prefs.append("redirect_when_dispersing")
191
+
192
+ if prefs:
193
+ line += f"\nCommunication: {', '.join(prefs)}"
194
+
195
+ return line
196
+
197
+
198
+ def _find_release_for_current_epic(
199
+ project_path: Path, epic_id: str
200
+ ) -> GraphNode | None:
201
+ """Find release node for the current epic from the memory graph.
202
+
203
+ Args:
204
+ project_path: Absolute path to the project root.
205
+ epic_id: Epic identifier (e.g., "E19").
206
+
207
+ Returns:
208
+ The release GraphNode, or None if not found or graph unavailable.
209
+ """
210
+ if not epic_id:
211
+ return None
212
+
213
+ graph_path = project_path / GRAPH_REL_PATH
214
+ if not graph_path.exists():
215
+ return None
216
+
217
+ try:
218
+ from raise_cli.graph.backends import get_active_backend
219
+ from raise_core.graph.query import QueryEngine
220
+
221
+ graph = get_active_backend(graph_path).load()
222
+ engine = QueryEngine(graph)
223
+ return engine.find_release_for(f"epic-{epic_id.lower()}")
224
+ except Exception:
225
+ logger.debug("Failed to query release for epic %s", epic_id)
226
+ return None
227
+
228
+
229
+ def _format_work_section(
230
+ state: SessionState | None,
231
+ release_node: GraphNode | None = None,
232
+ live: LiveBacklogStatus | None = None,
233
+ ) -> str:
234
+ """Format current work state with optional live backlog annotations."""
235
+ if state is None:
236
+ return "Work: (no previous session state)"
237
+
238
+ lines: list[str] = []
239
+
240
+ if release_node:
241
+ release_id = release_node.metadata.get("release_id", release_node.id)
242
+ name = release_node.metadata.get("name", "")
243
+ target = release_node.metadata.get("target", "")
244
+ release_parts = [f"Release: {release_id}"]
245
+ if name:
246
+ release_parts.append(f"({name})")
247
+ if target:
248
+ release_parts.append(f"— Target: {target}")
249
+ lines.append(" ".join(release_parts))
250
+
251
+ # Story line with optional live annotation
252
+ story_line = f"Story: {state.current_work.story} [{state.current_work.phase}]"
253
+ if live and live.story_status:
254
+ story_line += f" — {live.story_status} (live)"
255
+ lines.append(story_line)
256
+
257
+ # Epic line with optional live annotation
258
+ epic_line = f"Epic: {state.current_work.epic}"
259
+ if live and live.epic_status:
260
+ epic_line += f" — {live.epic_status} (live)"
261
+ lines.append(epic_line)
262
+
263
+ lines.append(f"Branch: {state.current_work.branch}")
264
+
265
+ # Warning line for degraded live status
266
+ if live and live.warning:
267
+ lines.append(f"⚠ {live.warning}")
268
+
269
+ return "\n".join(lines)
270
+
271
+
272
+ def _format_last_session(state: SessionState | None) -> str:
273
+ """Format last session summary."""
274
+ if state is None:
275
+ return ""
276
+ s = state.last_session
277
+ return f"Last: {s.id} ({s.date}, {s.developer}) — {s.summary}"
278
+
279
+
280
+ def _format_deadlines(profile: DeveloperProfile) -> str:
281
+ """Format deadlines with days remaining."""
282
+ if not profile.deadlines:
283
+ return ""
284
+
285
+ today = date.today()
286
+ lines = ["# Deadlines"]
287
+ for d in profile.deadlines:
288
+ days = (d.date - today).days
289
+ if days < 0:
290
+ suffix = f"({abs(days)}d overdue)"
291
+ elif days == 0:
292
+ suffix = "(today)"
293
+ elif days == 1:
294
+ suffix = "(1 day)"
295
+ else:
296
+ suffix = f"({days} days)"
297
+ line = f"{d.name}: {d.date.strftime('%b %d')} {suffix}"
298
+ if d.notes:
299
+ line += f" — {d.notes}"
300
+ lines.append(line)
301
+ return "\n".join(lines)
302
+
303
+
304
+ def _format_governance_primes(always_on_nodes: list[GraphNode]) -> str:
305
+ """Format governance primes (guardrails + non-identity principles).
306
+
307
+ Args:
308
+ always_on_nodes: All always_on nodes from the graph.
309
+
310
+ Returns:
311
+ Formatted governance primes section, or empty string if none.
312
+ """
313
+ governance = [
314
+ n
315
+ for n in always_on_nodes
316
+ if not n.id.startswith("RAI-VAL-") and not n.id.startswith("RAI-BND-")
317
+ ]
318
+ if not governance:
319
+ return ""
320
+
321
+ lines = ["# Governance Primes"]
322
+ for n in governance:
323
+ content = n.content
324
+ if len(content) > 80:
325
+ content = content[:77] + "..."
326
+ lines.append(f"- {n.id}: {content}")
327
+ return "\n".join(lines)
328
+
329
+
330
+ def _format_progress(state: SessionState | None) -> str:
331
+ """Format epic progress section.
332
+
333
+ Args:
334
+ state: Session state (may be None).
335
+
336
+ Returns:
337
+ Formatted progress section, or empty string if no progress.
338
+ """
339
+ if state is None or state.progress is None:
340
+ return ""
341
+
342
+ p = state.progress
343
+ pct = round(p.sp_done / p.sp_total * 100) if p.sp_total > 0 else 0
344
+ lines = [
345
+ f"Progress: {p.epic} — {p.stories_done}/{p.stories_total} stories, {p.sp_done}/{p.sp_total} SP ({pct}%)"
346
+ ]
347
+
348
+ if state.completed_epics:
349
+ lines.append(f"Completed: {', '.join(state.completed_epics)}")
350
+
351
+ return "\n".join(lines)
352
+
353
+
354
+ def _format_recent_sessions(project_path: Path, limit: int = 3) -> str:
355
+ """Format recent sessions from sessions/index.jsonl.
356
+
357
+ Args:
358
+ project_path: Absolute path to the project root.
359
+ limit: Number of recent sessions to include.
360
+
361
+ Returns:
362
+ Formatted recent sessions section, or empty string if none.
363
+ """
364
+ index_path = project_path / SESSIONS_INDEX_REL_PATH
365
+ if not index_path.exists():
366
+ return ""
367
+
368
+ try:
369
+ text = index_path.read_text(encoding="utf-8").strip()
370
+ if not text:
371
+ return ""
372
+ sessions = [json.loads(line) for line in text.splitlines() if line.strip()]
373
+ except Exception:
374
+ logger.warning("Failed to read sessions index: %s", index_path)
375
+ return ""
376
+
377
+ if not sessions:
378
+ return ""
379
+
380
+ recent = sessions[-limit:]
381
+ recent.reverse() # Most recent first
382
+
383
+ lines = ["Recent:"]
384
+ for s in recent:
385
+ topic = s.get("topic", "")
386
+ if len(topic) > 80:
387
+ topic = topic[:77] + "..."
388
+ lines.append(f"- {s['id']}: {topic}")
389
+ return "\n".join(lines)
390
+
391
+
392
+ def _format_narrative(state: SessionState | None) -> str:
393
+ """Format session narrative for cross-session continuity.
394
+
395
+ Narrative is loaded verbatim — no truncation. It contains structured
396
+ context (decisions, research, artifacts, branch state) that makes the
397
+ next session immediately resumable.
398
+
399
+ Adds a staleness caveat so the reader knows to verify volatile
400
+ state (git status, branch, uncommitted files) before acting on it.
401
+
402
+ Args:
403
+ state: Session state (may be None).
404
+
405
+ Returns:
406
+ Formatted narrative section, or empty string if no narrative.
407
+ """
408
+ if state is None or not state.narrative:
409
+ return ""
410
+
411
+ captured_date = state.last_session.date
412
+ caveat = (
413
+ f"(Captured at session close on {captured_date}. "
414
+ "Git/branch state may be stale — verify before acting.)"
415
+ )
416
+ return f"# Session Narrative\n{caveat}\n{state.narrative}"
417
+
418
+
419
+ def _format_next_session_prompt(state: SessionState | None) -> str:
420
+ """Format next session prompt for cross-session continuity.
421
+
422
+ This is forward-looking guidance from Rai to her future self,
423
+ written during session-close and presented at session-start.
424
+
425
+ Adds a staleness caveat so the reader knows to verify volatile
426
+ state (git status, branch, uncommitted files) before acting on it.
427
+
428
+ Args:
429
+ state: Session state (may be None).
430
+
431
+ Returns:
432
+ Formatted prompt section, or empty string if no prompt.
433
+ """
434
+ if state is None or not state.next_session_prompt:
435
+ return ""
436
+
437
+ captured_date = state.last_session.date
438
+ caveat = (
439
+ f"(Captured at session close on {captured_date}. "
440
+ "Git/branch state may be stale — verify before acting.)"
441
+ )
442
+ return f"# Next Session Prompt\n{caveat}\n{state.next_session_prompt}"
443
+
444
+
445
+ def _format_primes(patterns: list[GraphNode]) -> str:
446
+ """Format foundational patterns as behavioral primes."""
447
+ if not patterns:
448
+ return ""
449
+
450
+ lines = ["# Behavioral Primes"]
451
+ for p in patterns:
452
+ # Compact: PAT-ID: first sentence
453
+ content = p.content.split("—")[0].strip() if "—" in p.content else p.content
454
+ # Truncate long content
455
+ if len(content) > 80:
456
+ content = content[:77] + "..."
457
+ lines.append(f"- {p.id}: {content}")
458
+ return "\n".join(lines)
459
+
460
+
461
+ def _format_coaching(profile: DeveloperProfile) -> str:
462
+ """Format coaching context."""
463
+ coaching = profile.coaching
464
+ has_content = (
465
+ coaching.strengths
466
+ or coaching.growth_edge
467
+ or coaching.trust_level != "new"
468
+ or coaching.autonomy
469
+ or coaching.relationship.quality != "new"
470
+ )
471
+ if not has_content:
472
+ return ""
473
+
474
+ lines = ["# Coaching"]
475
+ if coaching.trust_level != "new":
476
+ lines.append(f"Trust: {coaching.trust_level}")
477
+ if coaching.strengths:
478
+ lines.append(f"Strengths: {', '.join(coaching.strengths)}")
479
+ if coaching.growth_edge:
480
+ lines.append(f"Growth edge: {coaching.growth_edge}")
481
+ if coaching.autonomy:
482
+ lines.append(f"Autonomy: {coaching.autonomy}")
483
+ if coaching.relationship.quality != "new":
484
+ rel = coaching.relationship
485
+ lines.append(f"Relationship: {rel.quality} ({rel.trajectory})")
486
+ # Corrections suppressed from session context — noise without specific
487
+ # consumption point. Revisit when /rai-story-review integrates them.
488
+ return "\n".join(lines)
489
+
490
+
491
+ def _format_pending(state: SessionState | None) -> str:
492
+ """Format pending items."""
493
+ if state is None:
494
+ return ""
495
+
496
+ pending = state.pending
497
+ if not pending.decisions and not pending.blockers and not pending.next_actions:
498
+ return ""
499
+
500
+ lines = ["# Pending"]
501
+ if pending.decisions:
502
+ lines.append("Decisions:")
503
+ for d in pending.decisions:
504
+ lines.append(f"- {d}")
505
+ if pending.blockers:
506
+ lines.append("Blockers:")
507
+ for b in pending.blockers:
508
+ lines.append(f"- {b}")
509
+ if pending.next_actions:
510
+ lines.append("Next:")
511
+ for n in pending.next_actions:
512
+ lines.append(f"- {n}")
513
+ return "\n".join(lines)
514
+
515
+
516
+ # ---------------------------------------------------------------------------
517
+ # Section registry and manifest
518
+ # ---------------------------------------------------------------------------
519
+
520
+
521
+ class SectionManifest(BaseModel):
522
+ """Manifest entry for a queryable context section."""
523
+
524
+ name: str
525
+ count: int
526
+ token_estimate: int
527
+
528
+
529
+ def _count_governance(project_path: Path) -> int:
530
+ """Count governance items (always_on nodes minus identity)."""
531
+ nodes = get_always_on_primes(project_path)
532
+ return len(
533
+ [
534
+ n
535
+ for n in nodes
536
+ if not n.id.startswith("RAI-VAL-") and not n.id.startswith("RAI-BND-")
537
+ ]
538
+ )
539
+
540
+
541
+ def _count_behavioral(project_path: Path) -> int:
542
+ """Count foundational pattern items."""
543
+ return len(get_foundational_patterns(project_path))
544
+
545
+
546
+ def _count_coaching(profile: DeveloperProfile) -> int:
547
+ """Count coaching items (1 if content exists, 0 otherwise)."""
548
+ coaching = profile.coaching
549
+ has_content = (
550
+ coaching.strengths
551
+ or coaching.growth_edge
552
+ or coaching.trust_level != "new"
553
+ or coaching.autonomy
554
+ or coaching.relationship.quality != "new"
555
+ )
556
+ return 1 if has_content else 0
557
+
558
+
559
+ def _count_deadlines(profile: DeveloperProfile) -> int:
560
+ """Count deadline items."""
561
+ return len(profile.deadlines)
562
+
563
+
564
+ def _count_progress(state: SessionState | None) -> int:
565
+ """Count progress items (1 if exists, 0 otherwise)."""
566
+ if state is None or state.progress is None:
567
+ return 0
568
+ return 1
569
+
570
+
571
+ # Average tokens per item, estimated from real data
572
+ _TOKENS_PER_ITEM: dict[str, int] = {
573
+ "governance": 25,
574
+ "behavioral": 20,
575
+ "coaching": 80,
576
+ "deadlines": 30,
577
+ "progress": 40,
578
+ }
579
+
580
+
581
+ def count_section_items(
582
+ section: str,
583
+ project_path: Path,
584
+ profile: DeveloperProfile,
585
+ state: SessionState | None,
586
+ ) -> int:
587
+ """Count items in a named section.
588
+
589
+ Args:
590
+ section: Section name from SECTION_REGISTRY.
591
+ project_path: Absolute path to the project root.
592
+ profile: Developer profile.
593
+ state: Session state (may be None).
594
+
595
+ Returns:
596
+ Number of items in the section.
597
+
598
+ Raises:
599
+ ValueError: If section name is not in SECTION_REGISTRY.
600
+ """
601
+ if section not in SECTION_REGISTRY:
602
+ raise ValueError(
603
+ f"Unknown section: '{section}'. Valid: {sorted(SECTION_REGISTRY.keys())}"
604
+ )
605
+
606
+ if section == "governance":
607
+ return _count_governance(project_path)
608
+ if section == "behavioral":
609
+ return _count_behavioral(project_path)
610
+ if section == "coaching":
611
+ return _count_coaching(profile)
612
+ if section == "deadlines":
613
+ return _count_deadlines(profile)
614
+ if section == "progress":
615
+ return _count_progress(state)
616
+
617
+ return 0 # unreachable but satisfies pyright
618
+
619
+
620
+ # Registry: section name → format function
621
+ # Format functions have heterogeneous signatures; the registry maps names
622
+ # for validation and dispatch. Actual calling happens in assemble_sections().
623
+ SECTION_REGISTRY: dict[str, Callable[..., str]] = {
624
+ "governance": _format_governance_primes,
625
+ "behavioral": _format_primes,
626
+ "coaching": _format_coaching,
627
+ "deadlines": _format_deadlines,
628
+ "progress": _format_progress,
629
+ }
630
+
631
+
632
+ def assemble_sections(
633
+ sections: list[str],
634
+ project_path: Path,
635
+ profile: DeveloperProfile,
636
+ state: SessionState | None,
637
+ ) -> str:
638
+ """Assemble formatted output for selected priming sections.
639
+
640
+ Each section independently loads its data source (graph, profile, or state)
641
+ and formats the output. Section names are validated against SECTION_REGISTRY.
642
+
643
+ Args:
644
+ sections: List of section names to load (e.g., ["governance", "behavioral"]).
645
+ project_path: Absolute path to the project root.
646
+ profile: Developer profile.
647
+ state: Session state (may be None).
648
+
649
+ Returns:
650
+ Formatted sections joined by blank lines, or empty string if no content.
651
+
652
+ Raises:
653
+ ValueError: If any section name is not in SECTION_REGISTRY.
654
+ """
655
+ if not sections:
656
+ return ""
657
+
658
+ # Validate all section names first
659
+ for name in sections:
660
+ if name not in SECTION_REGISTRY:
661
+ raise ValueError(
662
+ f"Unknown section: '{name}'. Valid: {sorted(SECTION_REGISTRY.keys())}"
663
+ )
664
+
665
+ parts: list[str] = []
666
+ for name in sections:
667
+ if name == "governance":
668
+ always_on = get_always_on_primes(project_path)
669
+ part = _format_governance_primes(always_on)
670
+ elif name == "behavioral":
671
+ patterns = get_foundational_patterns(project_path)
672
+ part = _format_primes(patterns)
673
+ elif name == "coaching":
674
+ part = _format_coaching(profile)
675
+ elif name == "deadlines":
676
+ part = _format_deadlines(profile)
677
+ elif name == "progress":
678
+ part = _format_progress(state)
679
+ else:
680
+ continue # unreachable due to validation above
681
+
682
+ if part:
683
+ parts.append(part)
684
+
685
+ return "\n\n".join(parts)
686
+
687
+
688
+ def _format_manifest(manifests: list[SectionManifest]) -> str:
689
+ """Format manifest of available context sections.
690
+
691
+ Args:
692
+ manifests: List of section manifest entries.
693
+
694
+ Returns:
695
+ Formatted manifest section, or empty string if no manifests.
696
+ """
697
+ if not manifests:
698
+ return ""
699
+
700
+ lines = ["# Available Context"]
701
+ for m in manifests:
702
+ if m.count == 0:
703
+ lines.append(f"- {m.name}: 0 items")
704
+ else:
705
+ lines.append(f"- {m.name}: {m.count} items (~{m.token_estimate} tokens)")
706
+ return "\n".join(lines)
707
+
708
+
709
+ def assemble_orientation(
710
+ profile: DeveloperProfile,
711
+ state: SessionState | None,
712
+ project_path: Path,
713
+ session_id: str | None = None,
714
+ ) -> str:
715
+ """Assemble orientation-only context (always-on sections).
716
+
717
+ Orientation = "where are we?" — work state, continuity, pending.
718
+ Does NOT include priming sections (governance, behavioral, coaching,
719
+ deadlines, progress). Those are loaded separately via assemble_sections().
720
+
721
+ Args:
722
+ profile: Developer profile from ~/.rai/developer.yaml.
723
+ state: Session state from .raise/rai/session-state.yaml (may be None).
724
+ project_path: Absolute path to the project root.
725
+ session_id: Optional session identifier (e.g., "SES-177").
726
+
727
+ Returns:
728
+ Plain text orientation context.
729
+ """
730
+ # Resolve release context for current epic
731
+ release_node: GraphNode | None = None
732
+ if state and state.current_work.epic:
733
+ release_node = _find_release_for_current_epic(
734
+ project_path, state.current_work.epic
735
+ )
736
+
737
+ # Session Context header
738
+ sections: list[str] = [
739
+ "# Session Context",
740
+ _format_developer_section(profile),
741
+ ]
742
+
743
+ # Add session ID if provided
744
+ if session_id:
745
+ sections.append(f"Session: {session_id}")
746
+
747
+ # Fetch live backlog status (never blocks — degrades gracefully)
748
+ live = _fetch_live_status(state)
749
+
750
+ sections.append(_format_work_section(state, release_node=release_node, live=live))
751
+
752
+ # Last session + recent sessions
753
+ sections.append(_format_last_session(state))
754
+ recent = _format_recent_sessions(project_path)
755
+ if recent:
756
+ sections.append(recent)
757
+
758
+ # Session narrative (cross-session continuity — not truncated)
759
+ narrative = _format_narrative(state)
760
+ if narrative:
761
+ sections.append(narrative)
762
+
763
+ # Next session prompt (forward-looking guidance from Rai to future self)
764
+ next_prompt = _format_next_session_prompt(state)
765
+ if next_prompt:
766
+ sections.append(next_prompt)
767
+
768
+ # Pending
769
+ pending = _format_pending(state)
770
+ if pending:
771
+ sections.append(pending)
772
+
773
+ # Filter empty sections, join with blank lines
774
+ return "\n\n".join(s for s in sections if s)
775
+
776
+
777
+ def assemble_context_bundle(
778
+ profile: DeveloperProfile,
779
+ state: SessionState | None,
780
+ project_path: Path,
781
+ session_id: str | None = None,
782
+ ) -> str:
783
+ """Assemble lean context bundle: orientation + manifest.
784
+
785
+ Emits always-on orientation sections plus a manifest of available
786
+ priming sections (with counts and token estimates). Priming sections
787
+ are loaded separately via `rai session context --sections`.
788
+
789
+ Args:
790
+ profile: Developer profile from ~/.rai/developer.yaml.
791
+ state: Session state from .raise/rai/session-state.yaml (may be None).
792
+ project_path: Absolute path to the project root.
793
+ session_id: Optional session identifier (e.g., "SES-177").
794
+
795
+ Returns:
796
+ Plain text context bundle: orientation + manifest.
797
+ """
798
+ # Orientation (always-on sections)
799
+ orientation = assemble_orientation(profile, state, project_path, session_id)
800
+
801
+ # Build manifest for available priming sections
802
+ manifests: list[SectionManifest] = []
803
+ for section_name in SECTION_REGISTRY:
804
+ count = count_section_items(section_name, project_path, profile, state)
805
+ tokens = count * _TOKENS_PER_ITEM.get(section_name, 20)
806
+ manifests.append(
807
+ SectionManifest(
808
+ name=section_name,
809
+ count=count,
810
+ token_estimate=tokens,
811
+ )
812
+ )
813
+
814
+ manifest = _format_manifest(manifests)
815
+
816
+ parts = [orientation]
817
+ if manifest:
818
+ parts.append(manifest)
819
+
820
+ return "\n\n".join(parts)