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,57 @@
1
+ """Graph backend implementations — CLI layer with env-var-based selection.
2
+
3
+ The factory `get_active_backend()` checks RAI_SERVER_URL + RAI_API_KEY
4
+ and returns DualWriteBackend when both are set, else FilesystemGraphBackend.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from raise_core.graph.backends.filesystem import FilesystemGraphBackend
14
+ from raise_core.graph.backends.protocol import KnowledgeGraphBackend
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ __all__ = ["get_active_backend"]
19
+
20
+
21
+ def get_active_backend(path: Path) -> KnowledgeGraphBackend:
22
+ """Resolve the active graph backend based on environment.
23
+
24
+ Returns DualWriteBackend when RAI_SERVER_URL and RAI_API_KEY are both
25
+ set. Falls back to FilesystemGraphBackend otherwise.
26
+
27
+ Args:
28
+ path: Path to the graph JSON file (for local backend).
29
+ """
30
+ server_url = os.environ.get("RAI_SERVER_URL", "").strip()
31
+ api_key = os.environ.get("RAI_API_KEY", "").strip()
32
+
33
+ if server_url and api_key:
34
+ from raise_cli.graph.backends.api import ApiGraphBackend
35
+ from raise_cli.graph.backends.dual import DualWriteBackend
36
+
37
+ project_id = Path.cwd().name
38
+ raise_dir = Path.cwd() / ".raise"
39
+ local = FilesystemGraphBackend(path=path)
40
+ remote = ApiGraphBackend(
41
+ server_url=server_url,
42
+ api_key=api_key,
43
+ project_id=project_id,
44
+ )
45
+ return DualWriteBackend(
46
+ local=local,
47
+ remote=remote,
48
+ raise_dir=raise_dir if raise_dir.exists() else None,
49
+ )
50
+
51
+ if server_url and not api_key:
52
+ logger.warning(
53
+ "RAI_SERVER_URL is set but RAI_API_KEY is missing. "
54
+ "Using local filesystem backend only."
55
+ )
56
+
57
+ return FilesystemGraphBackend(path=path)
@@ -0,0 +1,137 @@
1
+ """API-based graph backend — sends graph data to rai-server via HTTP.
2
+
3
+ PRO backend. Requires RAI_SERVER_URL and RAI_API_KEY.
4
+ Implements KnowledgeGraphBackend protocol from raise-core (ADR-036).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ try:
13
+ import httpx
14
+ except ModuleNotFoundError as exc:
15
+ raise ModuleNotFoundError(
16
+ "httpx is required for the API graph backend. "
17
+ "Install with: pip install 'raise-cli[dev]'"
18
+ ) from exc
19
+
20
+ from raise_core.graph.backends.models import BackendHealth
21
+ from raise_core.graph.engine import Graph
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ __all__ = ["ApiGraphBackend"]
26
+
27
+
28
+ class ApiGraphBackend:
29
+ """HTTP client backend — persists graph to rai-server.
30
+
31
+ Args:
32
+ server_url: Base URL of the rai-server (e.g. "http://localhost:8000").
33
+ api_key: API key for authentication (rsk_ prefix).
34
+ project_id: Project identifier for graph sync.
35
+ """
36
+
37
+ def __init__(self, server_url: str, api_key: str, project_id: str) -> None:
38
+ self.server_url = server_url
39
+ self.api_key = api_key
40
+ self.project_id = project_id
41
+ self._client = httpx.Client(
42
+ base_url=server_url,
43
+ headers={"Authorization": f"Bearer {api_key}"},
44
+ timeout=httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0),
45
+ )
46
+
47
+ def close(self) -> None:
48
+ """Close the underlying HTTP connection pool."""
49
+ self._client.close()
50
+
51
+ def persist(self, graph: Graph) -> None:
52
+ """Send graph to server via POST /api/v1/graph/sync.
53
+
54
+ Serializes all nodes and edges from the Graph into the server's
55
+ expected sync payload format.
56
+
57
+ Args:
58
+ graph: The graph to persist.
59
+ """
60
+ skipped = 0
61
+ nodes: list[dict[str, Any]] = []
62
+ for node in graph.iter_concepts():
63
+ if not node.content:
64
+ skipped += 1
65
+ continue
66
+ nodes.append(
67
+ {
68
+ "node_id": node.id,
69
+ "node_type": node.type,
70
+ "scope": "project",
71
+ "content": node.content,
72
+ "source_file": node.source_file,
73
+ "properties": node.metadata,
74
+ }
75
+ )
76
+ if skipped:
77
+ logger.info("Skipped %d nodes with empty content", skipped)
78
+ edges: list[dict[str, Any]] = [
79
+ {
80
+ "source_node_id": edge.source,
81
+ "target_node_id": edge.target,
82
+ "edge_type": edge.type,
83
+ "weight": edge.weight,
84
+ "properties": edge.metadata,
85
+ }
86
+ for edge in graph.iter_relationships()
87
+ ]
88
+ payload: dict[str, Any] = {
89
+ "project_id": self.project_id,
90
+ "nodes": nodes,
91
+ "edges": edges,
92
+ }
93
+ response = self._client.post(url="/api/v1/graph/sync", json=payload)
94
+ response.raise_for_status()
95
+ logger.info(
96
+ "Synced to remote server (%d nodes, %d edges)",
97
+ len(nodes),
98
+ len(edges),
99
+ )
100
+
101
+ def load(self) -> Graph:
102
+ """Not supported — DualWriteBackend reads from local.
103
+
104
+ Raises:
105
+ NotImplementedError: Always. Use DualWriteBackend for load,
106
+ which delegates to local FilesystemGraphBackend.
107
+ """
108
+ raise NotImplementedError(
109
+ "ApiGraphBackend.load() is not supported. "
110
+ "Use DualWriteBackend, which loads from local filesystem."
111
+ )
112
+
113
+ def health(self) -> BackendHealth:
114
+ """Check server availability via GET /health.
115
+
116
+ Returns:
117
+ BackendHealth with status healthy or unavailable.
118
+ """
119
+ try:
120
+ response = self._client.get(url="/health")
121
+ if response.status_code == 200:
122
+ return BackendHealth(
123
+ status="healthy",
124
+ message="API server operational",
125
+ metadata={"backend": "api", "server_url": self.server_url},
126
+ )
127
+ return BackendHealth(
128
+ status="degraded",
129
+ message=f"Server returned {response.status_code}",
130
+ metadata={"backend": "api", "server_url": self.server_url},
131
+ )
132
+ except httpx.HTTPError as e:
133
+ return BackendHealth(
134
+ status="unavailable",
135
+ message=f"Connection failed: {e}",
136
+ metadata={"backend": "api", "server_url": self.server_url},
137
+ )
@@ -0,0 +1,139 @@
1
+ """Dual-write graph backend — local + remote with best-effort sync.
2
+
3
+ Writes to both FilesystemGraphBackend (local, always succeeds) and
4
+ ApiGraphBackend (remote, best-effort). Loads from local only.
5
+
6
+ Architecture: ADR-036 (KnowledgeGraphBackend)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+
15
+ from raise_core.graph.backends.models import BackendHealth
16
+ from raise_core.graph.backends.protocol import KnowledgeGraphBackend
17
+ from raise_core.graph.engine import Graph
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ __all__ = ["DualWriteBackend"]
22
+
23
+
24
+ class DualWriteBackend:
25
+ """Dual-write backend — local always, remote best-effort.
26
+
27
+ Local is source of truth. Remote failures are logged as warnings,
28
+ never raised as exceptions. When raise_dir is provided, a pending_sync
29
+ marker is created on remote failure and cleared on success.
30
+
31
+ Args:
32
+ local: Local backend (FilesystemGraphBackend).
33
+ remote: Remote backend (ApiGraphBackend).
34
+ raise_dir: Path to .raise/ directory for pending_sync marker.
35
+ If None, marker behavior is skipped.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ local: KnowledgeGraphBackend,
41
+ remote: KnowledgeGraphBackend,
42
+ raise_dir: Path | None = None,
43
+ ) -> None:
44
+ self.local = local
45
+ self.remote = remote
46
+ self._raise_dir = raise_dir
47
+
48
+ def persist(self, graph: Graph) -> None:
49
+ """Persist to local first, then sync to remote (best-effort).
50
+
51
+ Local failure raises (critical). Remote failure logs warning
52
+ and creates a pending_sync marker if raise_dir is configured.
53
+
54
+ Args:
55
+ graph: The graph to persist.
56
+ """
57
+ self.local.persist(graph)
58
+ try:
59
+ self.remote.persist(graph)
60
+ except Exception as e:
61
+ detail = ""
62
+ try:
63
+ import httpx
64
+
65
+ if isinstance(e, httpx.HTTPStatusError):
66
+ detail = f" Response: {e.response.text[:200]}"
67
+ except ImportError:
68
+ pass
69
+ logger.warning(
70
+ "Remote sync failed: %s.%s Graph saved locally only.", e, detail
71
+ )
72
+ self._write_marker(graph, str(e))
73
+ return
74
+
75
+ # Remote succeeded — clear any pending marker
76
+ self._clear_marker()
77
+
78
+ def _write_marker(self, graph: Graph, error: str) -> None:
79
+ """Create pending_sync marker on remote failure."""
80
+ if self._raise_dir is None:
81
+ return
82
+ from raise_cli.graph.backends.pending import (
83
+ PendingSyncMarker,
84
+ write_pending_marker,
85
+ )
86
+
87
+ marker = PendingSyncMarker(
88
+ timestamp=datetime.now(tz=UTC),
89
+ graph_path=str(getattr(self.local, "path", "unknown")),
90
+ node_count=graph.node_count,
91
+ edge_count=graph.edge_count,
92
+ error=error,
93
+ )
94
+ write_pending_marker(self._raise_dir, marker)
95
+
96
+ def _clear_marker(self) -> None:
97
+ """Clear pending_sync marker on remote success."""
98
+ if self._raise_dir is None:
99
+ return
100
+ from raise_cli.graph.backends.pending import clear_pending_marker
101
+
102
+ clear_pending_marker(self._raise_dir)
103
+
104
+ def load(self) -> Graph:
105
+ """Load from local backend (source of truth).
106
+
107
+ Returns:
108
+ Graph loaded from local filesystem.
109
+ """
110
+ return self.local.load()
111
+
112
+ def health(self) -> BackendHealth:
113
+ """Aggregate health from both backends.
114
+
115
+ Returns:
116
+ BackendHealth with combined status:
117
+ - healthy: both healthy
118
+ - degraded: local healthy, remote not
119
+ - unavailable: local not healthy
120
+ """
121
+ local_health = self.local.health()
122
+ remote_health = self.remote.health()
123
+
124
+ if local_health.status != "healthy":
125
+ status = "unavailable"
126
+ elif remote_health.status != "healthy":
127
+ status = "degraded"
128
+ else:
129
+ status = "healthy"
130
+
131
+ return BackendHealth(
132
+ status=status,
133
+ message=f"local: {local_health.status}, remote: {remote_health.status}",
134
+ metadata={
135
+ "backend": "dual",
136
+ "local": local_health.status,
137
+ "remote": remote_health.status,
138
+ },
139
+ )
@@ -0,0 +1,84 @@
1
+ """Pending sync marker — tracks failed remote writes for retry.
2
+
3
+ When DualWriteBackend fails to sync remotely, a marker file is created
4
+ in .raise/pending_sync.json. On next successful sync, the marker is cleared.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ from pydantic import BaseModel
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ __all__ = [
19
+ "PendingSyncMarker",
20
+ "write_pending_marker",
21
+ "read_pending_marker",
22
+ "clear_pending_marker",
23
+ ]
24
+
25
+ MARKER_FILENAME = "pending_sync.json"
26
+
27
+
28
+ class PendingSyncMarker(BaseModel):
29
+ """Marker for a failed remote graph sync."""
30
+
31
+ timestamp: datetime
32
+ graph_path: str
33
+ node_count: int
34
+ edge_count: int
35
+ error: str
36
+
37
+
38
+ def write_pending_marker(raise_dir: Path, marker: PendingSyncMarker) -> None:
39
+ """Write pending_sync.json to .raise/ directory.
40
+
41
+ Args:
42
+ raise_dir: Path to .raise/ directory.
43
+ marker: The marker data to write.
44
+ """
45
+ path = raise_dir / MARKER_FILENAME
46
+ path.write_text(marker.model_dump_json(indent=2))
47
+ logger.warning("Remote sync failed, marked as pending: %s", path)
48
+
49
+
50
+ def read_pending_marker(raise_dir: Path) -> PendingSyncMarker | None:
51
+ """Read pending_sync.json, return None if not present or corrupt.
52
+
53
+ Args:
54
+ raise_dir: Path to .raise/ directory.
55
+
56
+ Returns:
57
+ PendingSyncMarker if file exists and is valid, None otherwise.
58
+ """
59
+ path = raise_dir / MARKER_FILENAME
60
+ if not path.exists():
61
+ return None
62
+ try:
63
+ data = json.loads(path.read_text())
64
+ return PendingSyncMarker.model_validate(data)
65
+ except (json.JSONDecodeError, ValueError):
66
+ logger.warning("Corrupt pending_sync marker at %s, ignoring", path)
67
+ return None
68
+
69
+
70
+ def clear_pending_marker(raise_dir: Path) -> bool:
71
+ """Delete pending_sync.json.
72
+
73
+ Args:
74
+ raise_dir: Path to .raise/ directory.
75
+
76
+ Returns:
77
+ True if file existed and was deleted, False otherwise.
78
+ """
79
+ path = raise_dir / MARKER_FILENAME
80
+ if path.exists():
81
+ path.unlink()
82
+ logger.info("Cleared pending sync marker — remote sync successful")
83
+ return True
84
+ return False
@@ -0,0 +1,3 @@
1
+ """Handlers."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,54 @@
1
+ """Lifecycle hooks infrastructure for raise-cli.
2
+
3
+ Provides typed event infrastructure for cross-cutting concerns (telemetry,
4
+ notifications, compliance) to react to CLI operations without coupling
5
+ to skill content.
6
+
7
+ Architecture: ADR-039 (Lifecycle Hooks & Workflow Gates)
8
+ """
9
+
10
+ from raise_cli.hooks.emitter import EventEmitter, create_emitter
11
+ from raise_cli.hooks.events import (
12
+ AdapterFailedEvent,
13
+ AdapterLoadedEvent,
14
+ BeforeReleasePublishEvent,
15
+ BeforeSessionCloseEvent,
16
+ DiscoverScanEvent,
17
+ EmitResult,
18
+ GraphBuildEvent,
19
+ HookEvent,
20
+ HookResult,
21
+ InitCompleteEvent,
22
+ PatternAddedEvent,
23
+ ReleasePublishEvent,
24
+ SessionCloseEvent,
25
+ SessionStartEvent,
26
+ )
27
+ from raise_cli.hooks.protocol import LifecycleHook
28
+ from raise_cli.hooks.registry import HookRegistry
29
+
30
+ __all__ = [
31
+ # Base types
32
+ "HookEvent",
33
+ "HookResult",
34
+ "EmitResult",
35
+ # Protocol + Registry
36
+ "LifecycleHook",
37
+ "HookRegistry",
38
+ # Emitter
39
+ "EventEmitter",
40
+ "create_emitter",
41
+ # After-events (9)
42
+ "SessionStartEvent",
43
+ "SessionCloseEvent",
44
+ "GraphBuildEvent",
45
+ "PatternAddedEvent",
46
+ "DiscoverScanEvent",
47
+ "InitCompleteEvent",
48
+ "AdapterLoadedEvent",
49
+ "AdapterFailedEvent",
50
+ "ReleasePublishEvent",
51
+ # Before-events (2)
52
+ "BeforeSessionCloseEvent",
53
+ "BeforeReleasePublishEvent",
54
+ ]
@@ -0,0 +1 @@
1
+ """Built-in lifecycle hooks for raise-cli (COMMUNITY tier)."""
@@ -0,0 +1,216 @@
1
+ """Built-in BacklogHook — syncs backlog state on work lifecycle events.
2
+
3
+ Listens to ``work:lifecycle`` events (fired by ``rai signal emit-work``)
4
+ and calls the resolved ProjectManagementAdapter to create/transition issues.
5
+
6
+ Error isolation: ``handle()`` never raises. Adapter or config failures are
7
+ logged and returned as ``HookResult(status="error")``.
8
+
9
+ Architecture: ADR-039 §5 (Built-in hooks), S325.4, S347.4
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any, ClassVar
17
+
18
+ if TYPE_CHECKING:
19
+ from raise_cli.adapters.protocols import ProjectManagementAdapter
20
+
21
+ import yaml
22
+
23
+ from raise_cli.adapters.models import IssueSpec
24
+ from raise_cli.hooks.events import HookEvent, HookResult, WorkLifecycleEvent
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Events that trigger backlog actions
29
+ _ACTIONABLE_EVENTS = frozenset({"start", "complete"})
30
+
31
+ # Map (work_type, event) → lifecycle_mapping key
32
+ _LIFECYCLE_KEY_MAP: dict[tuple[str, str], str] = {
33
+ ("story", "start"): "story_start",
34
+ ("story", "complete"): "story_close",
35
+ ("epic", "start"): "epic_start",
36
+ ("epic", "complete"): "epic_close",
37
+ }
38
+
39
+ # Map lifecycle_mapping key → target status for rai backlog transition
40
+ _STATUS_MAP: dict[str, str] = {
41
+ "story_start": "in-progress",
42
+ "story_close": "done",
43
+ "epic_start": "in-progress",
44
+ "epic_close": "done",
45
+ }
46
+
47
+
48
+ class _JiraConfig:
49
+ """Parsed jira.yaml config relevant to BacklogHook."""
50
+
51
+ __slots__ = ("project_key", "lifecycle_mapping")
52
+
53
+ def __init__(self, project_key: str, lifecycle_mapping: dict[str, int]) -> None:
54
+ self.project_key = project_key
55
+ self.lifecycle_mapping = lifecycle_mapping
56
+
57
+
58
+ def _load_jira_config(project_root: Path) -> _JiraConfig | None:
59
+ """Load project key and lifecycle_mapping from .raise/jira.yaml.
60
+
61
+ Returns None if file missing, unparseable, or missing required sections.
62
+ """
63
+ config_path = project_root / ".raise" / "jira.yaml"
64
+ if not config_path.exists():
65
+ return None
66
+ try:
67
+ with open(config_path) as f:
68
+ data: dict[str, Any] = yaml.safe_load(f)
69
+ workflow = data.get("workflow", {})
70
+ mapping: dict[str, int] | None = workflow.get("lifecycle_mapping")
71
+ if mapping is None:
72
+ return None
73
+ projects = data.get("projects", {})
74
+ project_key = next(iter(projects), None) if projects else None
75
+ if project_key is None:
76
+ return None
77
+ return _JiraConfig(project_key=project_key, lifecycle_mapping=mapping)
78
+ except Exception: # noqa: BLE001
79
+ logger.warning("Failed to parse .raise/jira.yaml")
80
+ return None
81
+
82
+
83
+ def _resolve_issue_key(
84
+ adapter: ProjectManagementAdapter, work_id: str, project_key: str
85
+ ) -> str | None:
86
+ """Search for a backlog issue by work_id.
87
+
88
+ Strategy depends on adapter type:
89
+ - FilesystemPMAdapter: direct key lookup (no JQL)
90
+ - Jira/MCP: label-first (``rai:{work_id}``), then summary fallback
91
+
92
+ Returns the issue key if found, None otherwise.
93
+ """
94
+ # Import inside function to avoid circular imports
95
+ from raise_cli.adapters.filesystem import FilesystemPMAdapter
96
+
97
+ if isinstance(adapter, FilesystemPMAdapter):
98
+ # FileAdapter: direct key lookup, no JQL
99
+ results = adapter.search(work_id, limit=1)
100
+ return results[0].key if results else None
101
+
102
+ # Jira/MCP: label-first search
103
+ label_query = f'project = "{project_key}" AND labels = "rai:{work_id}"'
104
+ results = adapter.search(label_query, limit=1)
105
+ if results:
106
+ return results[0].key
107
+
108
+ # Summary fallback with warning
109
+ logger.warning("No label match for %s — falling back to summary search", work_id)
110
+ summary_query = f'project = "{project_key}" AND summary ~ "{work_id}"'
111
+ results = adapter.search(summary_query, limit=1)
112
+ if results:
113
+ return results[0].key
114
+ return None
115
+
116
+
117
+ def resolve_adapter() -> ProjectManagementAdapter:
118
+ """Resolve ProjectManagementAdapter via manifest default. Separated for testability."""
119
+ from raise_cli.cli.commands._resolve import (
120
+ resolve_adapter as _resolve,
121
+ )
122
+
123
+ return _resolve(None)
124
+
125
+
126
+ class BacklogHook:
127
+ """Syncs backlog state on work lifecycle events.
128
+
129
+ Subscribes to ``work:lifecycle`` events and maps them to
130
+ ``rai backlog`` actions using ``.raise/jira.yaml`` lifecycle_mapping.
131
+
132
+ Registered via ``rai.hooks`` entry point in pyproject.toml.
133
+ """
134
+
135
+ events: ClassVar[list[str]] = ["work:lifecycle"]
136
+ priority: ClassVar[int] = 0
137
+ timeout: ClassVar[float] = 30.0 # MCP bridge cold start needs >5s
138
+
139
+ def __init__(self, project_root: Path | None = None) -> None:
140
+ self._project_root = project_root or Path(".")
141
+
142
+ def handle(self, event: HookEvent) -> HookResult:
143
+ """Handle a work lifecycle event by syncing Jira.
144
+
145
+ Never raises — returns HookResult with status and message.
146
+ """
147
+ if not isinstance(event, WorkLifecycleEvent):
148
+ return HookResult(status="ok")
149
+
150
+ # Only act on start/complete
151
+ if event.event not in _ACTIONABLE_EVENTS:
152
+ return HookResult(status="ok")
153
+
154
+ # Load jira config
155
+ config = _load_jira_config(self._project_root)
156
+ if config is None:
157
+ return HookResult(
158
+ status="error", message="no jira.yaml or lifecycle_mapping"
159
+ )
160
+
161
+ # Determine lifecycle key
162
+ lifecycle_key = _LIFECYCLE_KEY_MAP.get((event.work_type, event.event))
163
+ if lifecycle_key is None:
164
+ return HookResult(status="ok")
165
+
166
+ # Resolve adapter
167
+ try:
168
+ adapter = resolve_adapter()
169
+ except Exception as exc: # noqa: BLE001
170
+ return HookResult(status="error", message=f"adapter unavailable: {exc}")
171
+
172
+ # Resolve Jira key
173
+ try:
174
+ jira_key = _resolve_issue_key(adapter, event.work_id, config.project_key)
175
+ except Exception as exc: # noqa: BLE001
176
+ return HookResult(status="error", message=f"search failed: {exc}")
177
+
178
+ target_status = _STATUS_MAP[lifecycle_key]
179
+
180
+ # Handle start: create if missing, then transition
181
+ if event.event == "start":
182
+ if jira_key is None:
183
+ try:
184
+ issue_type = "Epic" if event.work_type == "epic" else "Story"
185
+ spec = IssueSpec(
186
+ summary=f"{event.work_id}",
187
+ issue_type=issue_type,
188
+ labels=[f"rai:{event.work_id}"],
189
+ )
190
+ ref = adapter.create_issue(config.project_key, spec)
191
+ jira_key = ref.key
192
+ except Exception as exc: # noqa: BLE001
193
+ return HookResult(status="error", message=f"create failed: {exc}")
194
+
195
+ try:
196
+ adapter.transition_issue(jira_key, target_status)
197
+ except Exception as exc: # noqa: BLE001
198
+ return HookResult(status="error", message=f"transition failed: {exc}")
199
+
200
+ return HookResult(status="ok")
201
+
202
+ # Handle complete: transition existing issue
203
+ if event.event == "complete":
204
+ if jira_key is None:
205
+ return HookResult(
206
+ status="error", message=f"no Jira issue found for {event.work_id}"
207
+ )
208
+
209
+ try:
210
+ adapter.transition_issue(jira_key, target_status)
211
+ except Exception as exc: # noqa: BLE001
212
+ return HookResult(status="error", message=f"transition failed: {exc}")
213
+
214
+ return HookResult(status="ok")
215
+
216
+ return HookResult(status="ok")