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,262 @@
1
+ """Typed lifecycle event definitions.
2
+
3
+ Each event is a frozen dataclass with a specific payload type.
4
+ Events use frozen dataclasses (not Pydantic) because they are internal
5
+ infrastructure, not boundary objects.
6
+
7
+ Architecture: ADR-039 §2 (Typed events as frozen dataclasses)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+
18
+ def _now_utc() -> datetime:
19
+ """Return current UTC datetime."""
20
+ return datetime.now(UTC)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class HookEvent:
25
+ """Base class for all lifecycle events.
26
+
27
+ Subclasses set ``event_name`` via a class-level default with ``init=False``.
28
+ ``timestamp`` defaults to now(UTC) but can be overridden for testing.
29
+ """
30
+
31
+ event_name: str = field(init=False, default="")
32
+ timestamp: datetime = field(default_factory=_now_utc)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class HookResult:
37
+ """Result returned by a hook handler.
38
+
39
+ Attributes:
40
+ status: ``ok`` (success), ``abort`` (request operation abort,
41
+ only valid for ``before:`` events), or ``error`` (handler failed).
42
+ message: Human-readable detail (required for abort/error).
43
+ """
44
+
45
+ status: Literal["ok", "abort", "error"] = "ok"
46
+ message: str = ""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class EmitResult:
51
+ """Aggregate result from dispatching an event to all handlers.
52
+
53
+ Attributes:
54
+ aborted: True if a handler requested abort on a ``before:`` event.
55
+ abort_message: Reason for abort.
56
+ handler_errors: Error messages from handlers that raised exceptions.
57
+ """
58
+
59
+ aborted: bool = False
60
+ abort_message: str = ""
61
+ handler_errors: tuple[str, ...] = ()
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Concrete events
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class SessionStartEvent(HookEvent):
71
+ """Emitted after a session starts."""
72
+
73
+ event_name: Literal["session:start"] = field( # type: ignore[assignment]
74
+ default="session:start", init=False
75
+ )
76
+ session_id: str = ""
77
+ developer: str = ""
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class SessionCloseEvent(HookEvent):
82
+ """Emitted after a session closes."""
83
+
84
+ event_name: Literal["session:close"] = field( # type: ignore[assignment]
85
+ default="session:close", init=False
86
+ )
87
+ session_id: str = ""
88
+ outcome: str = ""
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class GraphBuildEvent(HookEvent):
93
+ """Emitted after a graph build completes."""
94
+
95
+ event_name: Literal["graph:build"] = field( # type: ignore[assignment]
96
+ default="graph:build", init=False
97
+ )
98
+ project_path: Path = field(default_factory=lambda: Path("."))
99
+ node_count: int = 0
100
+ edge_count: int = 0
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class PatternAddedEvent(HookEvent):
105
+ """Emitted after a pattern is added to memory."""
106
+
107
+ event_name: Literal["pattern:added"] = field( # type: ignore[assignment]
108
+ default="pattern:added", init=False
109
+ )
110
+ pattern_id: str = ""
111
+ content: str = ""
112
+ context: str = ""
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class DiscoverScanEvent(HookEvent):
117
+ """Emitted after a discovery scan completes."""
118
+
119
+ event_name: Literal["discover:scan"] = field( # type: ignore[assignment]
120
+ default="discover:scan", init=False
121
+ )
122
+ project_path: Path = field(default_factory=lambda: Path("."))
123
+ language: str = ""
124
+ component_count: int = 0
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class InitCompleteEvent(HookEvent):
129
+ """Emitted after project initialization completes."""
130
+
131
+ event_name: Literal["init:complete"] = field( # type: ignore[assignment]
132
+ default="init:complete", init=False
133
+ )
134
+ project_path: Path = field(default_factory=lambda: Path("."))
135
+ project_name: str = ""
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class AdapterLoadedEvent(HookEvent):
140
+ """Emitted after an adapter is successfully loaded from entry points."""
141
+
142
+ event_name: Literal["adapter:loaded"] = field( # type: ignore[assignment]
143
+ default="adapter:loaded", init=False
144
+ )
145
+ adapter_name: str = ""
146
+ group: str = ""
147
+ adapter_type: str = ""
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class AdapterFailedEvent(HookEvent):
152
+ """Emitted when an adapter fails to load from entry points."""
153
+
154
+ event_name: Literal["adapter:failed"] = field( # type: ignore[assignment]
155
+ default="adapter:failed", init=False
156
+ )
157
+ adapter_name: str = ""
158
+ group: str = ""
159
+ error: str = ""
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class ReleasePublishEvent(HookEvent):
164
+ """Emitted after a release is published."""
165
+
166
+ event_name: Literal["release:publish"] = field( # type: ignore[assignment]
167
+ default="release:publish", init=False
168
+ )
169
+ version: str = ""
170
+ project_path: Path = field(default_factory=lambda: Path("."))
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class WorkLifecycleEvent(HookEvent):
175
+ """Emitted when a work lifecycle signal is recorded.
176
+
177
+ Bridges ``rai signal emit-work`` to the hook system so hooks like
178
+ BacklogHook can react to story/epic lifecycle transitions.
179
+ """
180
+
181
+ event_name: Literal["work:lifecycle"] = field( # type: ignore[assignment]
182
+ default="work:lifecycle", init=False
183
+ )
184
+ work_type: str = "" # "story" or "epic"
185
+ work_id: str = "" # e.g. "S325.4", "E325"
186
+ event: str = "" # "start", "complete", "blocked", etc.
187
+ phase: str = "" # "design", "plan", "implement", "review", "close"
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Work lifecycle events (S301.6: auto-sync hooks)
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class WorkStartEvent(HookEvent):
197
+ """Emitted when a work item (story/epic) starts."""
198
+
199
+ event_name: Literal["work:start"] = field( # type: ignore[assignment]
200
+ default="work:start", init=False
201
+ )
202
+ work_type: str = ""
203
+ work_id: str = ""
204
+ issue_key: str = ""
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class WorkCloseEvent(HookEvent):
209
+ """Emitted when a work item (story/epic) closes."""
210
+
211
+ event_name: Literal["work:close"] = field( # type: ignore[assignment]
212
+ default="work:close", init=False
213
+ )
214
+ work_type: str = ""
215
+ work_id: str = ""
216
+ issue_key: str = ""
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Before-variant events (AD-6: only release:publish and session:close)
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ @dataclass(frozen=True)
225
+ class BeforeSessionCloseEvent(HookEvent):
226
+ """Emitted before a session closes. Handlers can abort."""
227
+
228
+ event_name: Literal["before:session:close"] = field( # type: ignore[assignment]
229
+ default="before:session:close", init=False
230
+ )
231
+ session_id: str = ""
232
+ outcome: str = ""
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class BeforeReleasePublishEvent(HookEvent):
237
+ """Emitted before a release is published. Handlers can abort."""
238
+
239
+ event_name: Literal["before:release:publish"] = field( # type: ignore[assignment]
240
+ default="before:release:publish", init=False
241
+ )
242
+ version: str = ""
243
+ project_path: Path = field(default_factory=lambda: Path("."))
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # MCP events (E338: MCP Platform)
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ @dataclass(frozen=True)
252
+ class McpCallEvent(HookEvent):
253
+ """Emitted after an MCP tool call completes (success or failure)."""
254
+
255
+ event_name: Literal["mcp:call"] = field( # type: ignore[assignment]
256
+ default="mcp:call", init=False
257
+ )
258
+ server: str = ""
259
+ tool: str = ""
260
+ success: bool = True
261
+ latency_ms: int = 0
262
+ error: str = ""
@@ -0,0 +1,38 @@
1
+ """LifecycleHook Protocol — contract for hook implementations.
2
+
3
+ Hooks react to CLI lifecycle events (telemetry, notifications, compliance).
4
+ A hook failure is logged and skipped — hooks never crash the CLI.
5
+
6
+ Architecture: ADR-039 §1 (LifecycleHook Protocol)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import ClassVar, Protocol, runtime_checkable
12
+
13
+ from raise_cli.hooks.events import HookEvent, HookResult
14
+
15
+
16
+ @runtime_checkable
17
+ class LifecycleHook(Protocol):
18
+ """Contract for lifecycle hook implementations.
19
+
20
+ Attributes:
21
+ events: Event names this hook subscribes to (e.g. ``["session:start"]``).
22
+ priority: Dispatch order — higher runs first. Default ``0``.
23
+
24
+ Example::
25
+
26
+ class TelemetryHook:
27
+ events = ["session:start", "graph:build"]
28
+ priority = 0
29
+
30
+ def handle(self, event: HookEvent) -> HookResult:
31
+ # write to signals.jsonl...
32
+ return HookResult(status="ok")
33
+ """
34
+
35
+ events: ClassVar[list[str]]
36
+ priority: ClassVar[int]
37
+
38
+ def handle(self, event: HookEvent) -> HookResult: ...
@@ -0,0 +1,117 @@
1
+ """Hook registry with entry point discovery.
2
+
3
+ Discovers LifecycleHook implementations registered via Python entry points
4
+ (``[project.entry-points."rai.hooks"]`` in pyproject.toml). Validates
5
+ Protocol conformance before accepting hooks.
6
+
7
+ Architecture: ADR-039 §3 (Entry point discovery, same as RAISE-211)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ import logging
14
+ from importlib.metadata import entry_points
15
+ from typing import Any
16
+
17
+ from raise_cli.hooks.protocol import LifecycleHook
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ EP_HOOKS: str = "rai.hooks"
22
+
23
+
24
+ def _dist_name(ep: Any) -> str:
25
+ """Best-effort extraction of the distribution name for an entry point."""
26
+ try:
27
+ return ep.dist.name # type: ignore[union-attr]
28
+ except AttributeError:
29
+ return "unknown"
30
+
31
+
32
+ class HookRegistry:
33
+ """Discovers and manages LifecycleHook implementations.
34
+
35
+ Example::
36
+
37
+ registry = HookRegistry()
38
+ registry.discover() # loads from rai.hooks entry points
39
+
40
+ for hook in registry.hooks:
41
+ print(f"{hook.__class__.__name__}: priority={hook.priority}")
42
+
43
+ handlers = registry.get_hooks_for_event("session:start")
44
+ """
45
+
46
+ def __init__(self) -> None:
47
+ self._hooks: list[LifecycleHook] = []
48
+
49
+ @property
50
+ def hooks(self) -> list[LifecycleHook]:
51
+ """Return a copy of registered hooks."""
52
+ return list(self._hooks)
53
+
54
+ def discover(self) -> None:
55
+ """Load hooks from ``rai.hooks`` entry points.
56
+
57
+ Skips entry points that:
58
+ - Fail to load (ImportError, etc.)
59
+ - Are not classes
60
+ - Don't conform to the LifecycleHook Protocol
61
+ """
62
+ for ep in entry_points(group=EP_HOOKS):
63
+ try:
64
+ loaded: Any = ep.load()
65
+ except Exception as exc: # noqa: BLE001
66
+ logger.warning(
67
+ "Skipping hook entry point '%s' from '%s': %s",
68
+ ep.name,
69
+ _dist_name(ep),
70
+ exc,
71
+ )
72
+ continue
73
+
74
+ if not inspect.isclass(loaded):
75
+ logger.warning(
76
+ "Skipping hook entry point '%s' from '%s': expected a class, got %s",
77
+ ep.name,
78
+ _dist_name(ep),
79
+ type(loaded).__name__,
80
+ )
81
+ continue
82
+
83
+ instance = loaded()
84
+ if not isinstance(instance, LifecycleHook):
85
+ logger.warning(
86
+ "Skipping hook entry point '%s' from '%s': "
87
+ "does not conform to LifecycleHook Protocol",
88
+ ep.name,
89
+ _dist_name(ep),
90
+ )
91
+ continue
92
+
93
+ self._hooks.append(instance)
94
+ logger.debug(
95
+ "Loaded hook '%s' (priority=%d, events=%s)",
96
+ ep.name,
97
+ instance.priority,
98
+ instance.events,
99
+ )
100
+
101
+ def register(self, hook: LifecycleHook | Any) -> None:
102
+ """Manually register a hook instance (useful for testing).
103
+
104
+ Silently skips non-compliant objects.
105
+ """
106
+ if not isinstance(hook, LifecycleHook):
107
+ logger.warning(
108
+ "Skipping manual hook registration: %s does not conform to LifecycleHook Protocol",
109
+ type(hook).__name__,
110
+ )
111
+ return
112
+ self._hooks.append(hook)
113
+
114
+ def get_hooks_for_event(self, event_name: str) -> list[LifecycleHook]:
115
+ """Return hooks subscribed to ``event_name``, sorted by priority (highest first)."""
116
+ matching = [h for h in self._hooks if event_name in h.events]
117
+ return sorted(matching, key=lambda h: h.priority, reverse=True)
@@ -0,0 +1,33 @@
1
+ """MCP infrastructure layer — independent of domain adapters.
2
+
3
+ Provides generic MCP server management: bridge, models, schema, registry.
4
+ Domain adapters (PM, Docs) live in ``raise_cli.adapters`` and consume this layer.
5
+
6
+ Architecture: ADR-042, E338
7
+
8
+ Note: Bridge imports are lazy because the ``mcp`` SDK and ``logfire-api``
9
+ are optional dependencies. Eager import would crash CLI startup when
10
+ these packages are not installed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ from raise_cli.mcp.models import McpHealthResult, McpToolInfo, McpToolResult
18
+ from raise_cli.mcp.registry import discover_mcp_servers
19
+ from raise_cli.mcp.schema import McpServerConfig, ServerConnection
20
+
21
+ if TYPE_CHECKING:
22
+ from raise_cli.mcp.bridge import McpBridge, McpBridgeError
23
+
24
+ __all__ = [
25
+ "McpBridge",
26
+ "McpBridgeError",
27
+ "McpHealthResult",
28
+ "McpServerConfig",
29
+ "McpToolInfo",
30
+ "McpToolResult",
31
+ "ServerConnection",
32
+ "discover_mcp_servers",
33
+ ]
@@ -0,0 +1,218 @@
1
+ """Generic MCP server bridge using the official MCP Python SDK.
2
+
3
+ Manages server process lifecycle, async session, and RaiSE telemetry.
4
+ Works with any MCP server via stdio transport.
5
+
6
+ Architecture: E301 epic design (D1, D3, D4), moved to raise_cli.mcp (ADR-042, E338)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import json
13
+ import logging
14
+ import subprocess
15
+ import time
16
+ from contextlib import AsyncExitStack
17
+ from typing import Any
18
+
19
+ try:
20
+ import logfire_api as logfire
21
+ except ModuleNotFoundError: # pragma: no cover
22
+ logfire = None # type: ignore[assignment]
23
+ from mcp import ClientSession, StdioServerParameters
24
+ from mcp.client.stdio import stdio_client
25
+ from mcp.types import TextContent
26
+
27
+ from raise_cli.mcp.models import McpHealthResult, McpToolInfo, McpToolResult
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class McpBridgeError(Exception):
33
+ """Raised when MCP bridge operations fail."""
34
+
35
+
36
+ class McpBridge:
37
+ """Generic async bridge to any MCP server via official Python SDK.
38
+
39
+ Manages server process lifecycle, session, and RaiSE telemetry.
40
+ Session is lazy — created on first call, reused if alive,
41
+ reconnected if dead.
42
+
43
+ Args:
44
+ server_command: Command to start MCP server (e.g. "mcp-atlassian").
45
+ server_args: Optional args for the server command.
46
+ env: Optional environment variables. None = inherit parent env.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ server_command: str,
52
+ server_args: list[str] | None = None,
53
+ env: dict[str, str] | None = None,
54
+ ) -> None:
55
+ self._server_command = server_command
56
+ self._server_args = server_args or []
57
+ self._env = env # None = subprocess inherits os.environ
58
+ self._session: ClientSession | None = None
59
+ self._cm_stack: AsyncExitStack | None = None
60
+
61
+ async def call(self, tool_name: str, arguments: dict[str, Any]) -> McpToolResult:
62
+ """Call a tool on the MCP server.
63
+
64
+ Wraps ClientSession.call_tool() with telemetry and error handling.
65
+
66
+ Args:
67
+ tool_name: MCP tool name (e.g. "jira_get_issue").
68
+ arguments: Tool arguments dict.
69
+
70
+ Returns:
71
+ Parsed McpToolResult.
72
+
73
+ Raises:
74
+ McpBridgeError: On connection or tool call failure.
75
+ """
76
+ span_cm = (
77
+ logfire.span(
78
+ "mcp.tool_call",
79
+ mcp_server=self._server_command,
80
+ mcp_tool=tool_name,
81
+ )
82
+ if logfire is not None
83
+ else contextlib.nullcontext()
84
+ )
85
+ with span_cm as span:
86
+ start = time.monotonic()
87
+ session = await self._ensure_session()
88
+ try:
89
+ result = await session.call_tool(tool_name, arguments)
90
+ elapsed_ms = int((time.monotonic() - start) * 1000)
91
+ if span is not None:
92
+ span.set_attribute("duration_ms", elapsed_ms)
93
+ span.set_attribute("success", not result.isError)
94
+ return self._parse_result(result)
95
+ except Exception as exc:
96
+ elapsed_ms = int((time.monotonic() - start) * 1000)
97
+ if span is not None:
98
+ span.set_attribute("duration_ms", elapsed_ms)
99
+ span.set_attribute("success", False)
100
+ raise McpBridgeError(f"Tool call '{tool_name}' failed: {exc}") from exc
101
+
102
+ async def list_tools(self) -> list[McpToolInfo]:
103
+ """List available tools on the server."""
104
+ session = await self._ensure_session()
105
+ result = await session.list_tools()
106
+ return [
107
+ McpToolInfo(name=t.name, description=t.description or "")
108
+ for t in result.tools
109
+ ]
110
+
111
+ async def health(self) -> McpHealthResult:
112
+ """Check server connectivity and tool availability."""
113
+ start = time.monotonic()
114
+ try:
115
+ tools = await self.list_tools()
116
+ elapsed_ms = int((time.monotonic() - start) * 1000)
117
+ return McpHealthResult(
118
+ server_name=self._server_command,
119
+ healthy=True,
120
+ message=f"OK, {len(tools)} tools",
121
+ latency_ms=elapsed_ms,
122
+ tool_count=len(tools),
123
+ )
124
+ except Exception as exc:
125
+ elapsed_ms = int((time.monotonic() - start) * 1000)
126
+ return McpHealthResult(
127
+ server_name=self._server_command,
128
+ healthy=False,
129
+ message=str(exc),
130
+ latency_ms=elapsed_ms,
131
+ )
132
+
133
+ async def _ensure_session(self) -> ClientSession:
134
+ """Lazy connect. Reconnects if session is closed/dead."""
135
+ if self._session is not None:
136
+ try:
137
+ await self._session.list_tools()
138
+ return self._session
139
+ except Exception:
140
+ await self._cleanup()
141
+
142
+ params = StdioServerParameters(
143
+ command=self._server_command,
144
+ args=self._server_args,
145
+ env=self._env,
146
+ )
147
+ stack = AsyncExitStack()
148
+ try:
149
+ # Redirect MCP server stderr to devnull to suppress banner/warning noise.
150
+ # Server errors are captured via MCP protocol (isError), not stderr.
151
+ # subprocess.DEVNULL is an int constant (-3) accepted by anyio.open_process;
152
+ # avoids sync open() in async context (RAISE-436).
153
+ read, write = await stack.enter_async_context(
154
+ stdio_client(params, errlog=subprocess.DEVNULL) # type: ignore[arg-type]
155
+ )
156
+ session = await stack.enter_async_context(ClientSession(read, write))
157
+ await session.initialize()
158
+ except FileNotFoundError as exc:
159
+ await stack.aclose()
160
+ raise McpBridgeError(
161
+ f"MCP server '{self._server_command}' not found. "
162
+ f"Install the server and ensure it's on PATH."
163
+ ) from exc
164
+ except Exception as exc:
165
+ await stack.aclose()
166
+ raise McpBridgeError(
167
+ f"Failed to connect to MCP server '{self._server_command}': {exc}"
168
+ ) from exc
169
+
170
+ self._session = session
171
+ self._cm_stack = stack
172
+ return session
173
+
174
+ async def aclose(self) -> None:
175
+ """Close session and exit stack.
176
+
177
+ Must be called within the same event loop that created the session.
178
+ Prevents asyncgen finalizer tracebacks from stdio_client (RAISE-324).
179
+ """
180
+ if self._cm_stack:
181
+ with contextlib.suppress(Exception):
182
+ await self._cm_stack.aclose()
183
+ self._session = None
184
+ self._cm_stack = None
185
+
186
+ async def _cleanup(self) -> None:
187
+ """Alias for aclose() — used internally by _ensure_session."""
188
+ await self.aclose()
189
+
190
+ @staticmethod
191
+ def _parse_result(result: Any) -> McpToolResult:
192
+ """Parse CallToolResult into McpToolResult.
193
+
194
+ Handles: empty content, multiple text items, non-text content,
195
+ isError flag, and JSON auto-parsing.
196
+ """
197
+ if result.isError:
198
+ texts = [c.text for c in result.content if isinstance(c, TextContent)]
199
+ error_text = "\n".join(texts) if texts else "Unknown error"
200
+ return McpToolResult(is_error=True, error_message=error_text)
201
+
202
+ # Collect all text content, ignore non-text
203
+ texts = [c.text for c in result.content if isinstance(c, TextContent)]
204
+ text = "\n".join(texts) if texts else ""
205
+
206
+ # Try parse as JSON for structured access
207
+ data: dict[str, Any] = {}
208
+ if text:
209
+ try:
210
+ parsed: Any = json.loads(text)
211
+ if isinstance(parsed, dict):
212
+ data = parsed # type: ignore[assignment]
213
+ elif isinstance(parsed, list):
214
+ data = {"items": parsed}
215
+ except json.JSONDecodeError:
216
+ pass
217
+
218
+ return McpToolResult(text=text, data=data)