codeframe-ai 0.9.0__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 (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,380 @@
1
+ """Webhook notification service.
2
+
3
+ Originally built for SYNC blocker alerts (049-human-in-loop, Phase 7), now also
4
+ powers outbound event webhooks for batch completion / blocker creation / PR
5
+ merge (issue #560).
6
+
7
+ Delivery is fire-and-forget with a configurable timeout — failures are logged
8
+ but never break the triggering operation.
9
+ """
10
+
11
+ import asyncio
12
+ import logging
13
+ import threading
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+ from typing import Optional
17
+
18
+ import aiohttp
19
+
20
+ from codeframe.core.models import BlockerType
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class WebhookSendResult:
27
+ """Result of a single ``send_event`` call.
28
+
29
+ ``ok`` mirrors HTTP 2xx semantics. ``status_code`` is ``None`` when the
30
+ request never completed (timeout, DNS failure, connection refused, …) —
31
+ the ``error`` field carries a short human-readable reason in that case.
32
+ """
33
+
34
+ ok: bool
35
+ status_code: Optional[int]
36
+ error: Optional[str] = None
37
+
38
+
39
+ def _utc_iso_now() -> str:
40
+ """ISO-8601 UTC timestamp for webhook payloads (``Z`` suffix).
41
+
42
+ Slack/Discord/Zapier-style consumers expect ``Z``, not ``+00:00``.
43
+ Drops sub-second precision for the same reason — consumers vary widely
44
+ in fractional-second handling.
45
+ """
46
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
47
+
48
+
49
+ def format_batch_payload(batch_id: str, task_count: int) -> dict:
50
+ # ``status`` is always "completed" today (the dispatcher gates on
51
+ # BATCH_COMPLETED only) but is included so consumers can self-document
52
+ # and so a future PARTIAL/FAILED extension is forward-compatible.
53
+ return {
54
+ "event": "batch.completed",
55
+ "batch_id": batch_id,
56
+ "task_count": task_count,
57
+ "status": "completed",
58
+ "timestamp": _utc_iso_now(),
59
+ }
60
+
61
+
62
+ def format_blocker_payload(blocker_id: str, task_id: Optional[str]) -> dict:
63
+ return {
64
+ "event": "blocker.created",
65
+ "blocker_id": blocker_id,
66
+ "task_id": task_id,
67
+ "timestamp": _utc_iso_now(),
68
+ }
69
+
70
+
71
+ def format_pr_payload(pr_number: int, pr_url: Optional[str] = None) -> dict:
72
+ # ``pr_number`` is always present so consumers can branch on it.
73
+ # ``pr_url`` is the canonical github.com URL when GITHUB_REPO is set,
74
+ # ``None`` otherwise (an unparseable sentinel like ``"pr#42"`` was
75
+ # actively confusing for downstream handlers).
76
+ return {
77
+ "event": "pr.merged",
78
+ "pr_number": pr_number,
79
+ "pr_url": pr_url,
80
+ "timestamp": _utc_iso_now(),
81
+ }
82
+
83
+
84
+ def format_test_payload() -> dict:
85
+ return {"event": "test", "timestamp": _utc_iso_now()}
86
+
87
+
88
+ class WebhookNotificationService:
89
+ """Async outbound webhook service.
90
+
91
+ Originally built for SYNC blocker alerts (``send_blocker_notification``);
92
+ now also powers the issue #560 outbound event webhooks (batch / blocker /
93
+ PR / test) via ``send_event``. Delivery is fire-and-forget with a
94
+ configurable timeout — failures are logged but never break the triggering
95
+ operation.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ webhook_url: Optional[str] = None,
101
+ timeout: int = 5,
102
+ dashboard_base_url: str = "http://localhost:3000",
103
+ ):
104
+ """Initialize webhook notification service.
105
+
106
+ Args:
107
+ webhook_url: Target webhook endpoint URL (e.g., Zapier, webhook.site)
108
+ timeout: HTTP request timeout in seconds (default: 5)
109
+ dashboard_base_url: Base URL for dashboard links (default: http://localhost:3000)
110
+ """
111
+ self.webhook_url = webhook_url
112
+ self.timeout = timeout
113
+ self.dashboard_base_url = dashboard_base_url
114
+
115
+ def is_enabled(self) -> bool:
116
+ """Check if webhook notifications are enabled.
117
+
118
+ Returns:
119
+ True if webhook_url is configured, False otherwise
120
+ """
121
+ return self.webhook_url is not None and self.webhook_url.strip() != ""
122
+
123
+ def format_payload(
124
+ self,
125
+ blocker_id: int,
126
+ question: str,
127
+ agent_id: str,
128
+ task_id: int,
129
+ blocker_type: BlockerType,
130
+ created_at: datetime,
131
+ ) -> dict:
132
+ """Format webhook payload with blocker details.
133
+
134
+ Args:
135
+ blocker_id: Blocker database ID
136
+ question: Blocker question text
137
+ agent_id: Agent that created the blocker
138
+ task_id: Associated task ID
139
+ blocker_type: SYNC or ASYNC
140
+ created_at: Blocker creation timestamp
141
+
142
+ Returns:
143
+ Dictionary payload ready for JSON serialization
144
+ """
145
+ dashboard_url = f"{self.dashboard_base_url}/#blocker-{blocker_id}"
146
+
147
+ return {
148
+ "blocker_id": blocker_id,
149
+ "question": question,
150
+ "agent_id": agent_id,
151
+ "task_id": task_id,
152
+ "type": blocker_type.value,
153
+ "created_at": created_at.isoformat(),
154
+ "dashboard_url": dashboard_url,
155
+ }
156
+
157
+ async def send_blocker_notification(
158
+ self,
159
+ blocker_id: int,
160
+ question: str,
161
+ agent_id: str,
162
+ task_id: int,
163
+ blocker_type: BlockerType,
164
+ created_at: datetime,
165
+ ) -> bool:
166
+ """Send async webhook notification for a blocker.
167
+
168
+ Fire-and-forget delivery with timeout. Logs errors but doesn't block execution.
169
+ Only sends notifications for SYNC blockers.
170
+
171
+ Args:
172
+ blocker_id: Blocker database ID
173
+ question: Blocker question text
174
+ agent_id: Agent that created the blocker
175
+ task_id: Associated task ID
176
+ blocker_type: SYNC or ASYNC
177
+ created_at: Blocker creation timestamp
178
+
179
+ Returns:
180
+ True if notification sent successfully, False on failure
181
+ """
182
+ # Only send notifications for SYNC blockers
183
+ if blocker_type != BlockerType.SYNC:
184
+ logger.debug(f"Skipping webhook notification for ASYNC blocker {blocker_id}")
185
+ return False
186
+
187
+ # Check if webhooks are enabled
188
+ if not self.is_enabled():
189
+ logger.debug(f"Webhook notifications disabled, skipping blocker {blocker_id}")
190
+ return False
191
+
192
+ # Format payload
193
+ payload = self.format_payload(
194
+ blocker_id=blocker_id,
195
+ question=question,
196
+ agent_id=agent_id,
197
+ task_id=task_id,
198
+ blocker_type=blocker_type,
199
+ created_at=created_at,
200
+ )
201
+
202
+ try:
203
+ # Send HTTP POST with timeout
204
+ async with aiohttp.ClientSession() as session:
205
+ async with session.post(
206
+ self.webhook_url,
207
+ json=payload,
208
+ timeout=aiohttp.ClientTimeout(total=self.timeout),
209
+ ) as response:
210
+ response.raise_for_status()
211
+
212
+ logger.info(
213
+ f"Webhook notification sent for blocker {blocker_id} "
214
+ f"(status: {response.status})"
215
+ )
216
+ return True
217
+
218
+ except asyncio.TimeoutError:
219
+ logger.error(
220
+ f"Webhook notification timeout for blocker {blocker_id} "
221
+ f"(exceeded {self.timeout}s)"
222
+ )
223
+ return False
224
+
225
+ except aiohttp.ClientError as e:
226
+ logger.error(f"Webhook notification failed for blocker {blocker_id}: {e}")
227
+ return False
228
+
229
+ except Exception as e:
230
+ logger.error(
231
+ f"Unexpected error sending webhook for blocker {blocker_id}: {e}", exc_info=True
232
+ )
233
+ return False
234
+
235
+ async def send_event(
236
+ self, payload: dict, url: Optional[str] = None
237
+ ) -> WebhookSendResult:
238
+ """Generic webhook POST for outbound event notifications (issue #560).
239
+
240
+ Unlike ``send_blocker_notification``, this method:
241
+
242
+ * Accepts an arbitrary JSON payload (the caller composes the event).
243
+ * Returns rich status information so the Settings ``Test`` endpoint
244
+ can surface the HTTP status code or error to the user.
245
+ * Accepts an optional ``url`` override so the same service instance
246
+ can dispatch to a freshly-configured URL without rebuilding state.
247
+
248
+ Failures (timeout, network, non-2xx) are logged but never raised —
249
+ the caller can react via the returned ``WebhookSendResult``.
250
+ """
251
+ target_url = url or self.webhook_url
252
+ if not target_url or not target_url.strip():
253
+ return WebhookSendResult(
254
+ ok=False, status_code=None, error="No webhook URL configured"
255
+ )
256
+
257
+ try:
258
+ async with aiohttp.ClientSession() as session:
259
+ async with session.post(
260
+ target_url,
261
+ json=payload,
262
+ timeout=aiohttp.ClientTimeout(total=self.timeout),
263
+ ) as response:
264
+ ok = 200 <= response.status < 300
265
+ if not ok:
266
+ logger.warning(
267
+ "Webhook returned non-2xx status %s for event %s",
268
+ response.status,
269
+ payload.get("event"),
270
+ )
271
+ return WebhookSendResult(ok=ok, status_code=response.status)
272
+ except asyncio.TimeoutError:
273
+ logger.error(
274
+ "Webhook timeout for event %s (exceeded %ss)",
275
+ payload.get("event"),
276
+ self.timeout,
277
+ )
278
+ return WebhookSendResult(
279
+ ok=False, status_code=None, error=f"Timeout after {self.timeout}s"
280
+ )
281
+ except aiohttp.ClientError as e:
282
+ logger.error("Webhook ClientError for event %s: %s", payload.get("event"), e)
283
+ return WebhookSendResult(ok=False, status_code=None, error=str(e))
284
+ except Exception as e:
285
+ logger.error(
286
+ "Unexpected webhook error for event %s: %s",
287
+ payload.get("event"),
288
+ e,
289
+ exc_info=True,
290
+ )
291
+ return WebhookSendResult(ok=False, status_code=None, error=str(e))
292
+
293
+ def send_event_background(self, payload: dict, url: Optional[str] = None) -> None:
294
+ """Fire-and-forget wrapper for ``send_event``.
295
+
296
+ Works in both contexts:
297
+
298
+ * **Async** (FastAPI request handler): schedules the send on the
299
+ current event loop via ``loop.create_task`` and returns
300
+ immediately.
301
+ * **Sync** (CLI batch run, sync test): spawns a daemon thread that
302
+ runs the send in a fresh event loop. The thread is daemon so it
303
+ never blocks process exit; ``timeout`` still applies inside the
304
+ loop, so the thread lives at most ``self.timeout`` seconds.
305
+
306
+ Either way, the triggering operation never blocks on webhook
307
+ delivery and never sees an exception from this method.
308
+ """
309
+ try:
310
+ loop = asyncio.get_running_loop()
311
+ except RuntimeError:
312
+ # No running loop — we're in sync context (CLI). Run the send
313
+ # in a daemon thread so we don't block the caller and so the
314
+ # process can exit cleanly even if the webhook hangs.
315
+ thread = threading.Thread(
316
+ target=self._run_send_event_sync,
317
+ args=(payload, url),
318
+ daemon=True,
319
+ name="webhook-send-event",
320
+ )
321
+ thread.start()
322
+ return
323
+ task = loop.create_task(self.send_event(payload, url=url))
324
+ # ``send_event`` already swallows all exceptions, but Python 3.11+
325
+ # warns ``Task exception was never retrieved`` if a task ends with
326
+ # an unhandled exception and nobody awaited / called .exception().
327
+ # Add a no-op callback so the result is always consumed.
328
+ task.add_done_callback(
329
+ lambda t: t.exception() if not t.cancelled() else None
330
+ )
331
+
332
+ def _run_send_event_sync(self, payload: dict, url: Optional[str]) -> None:
333
+ """Run ``send_event`` to completion in a fresh event loop.
334
+
335
+ Used only by the sync branch of ``send_event_background`` — never
336
+ raises into the calling thread (the daemon thread is meant to die
337
+ quietly).
338
+ """
339
+ try:
340
+ asyncio.run(self.send_event(payload, url=url))
341
+ except Exception:
342
+ logger.warning(
343
+ "Sync webhook dispatch failed for event %s",
344
+ payload.get("event"),
345
+ exc_info=True,
346
+ )
347
+
348
+ def send_blocker_notification_background(
349
+ self,
350
+ blocker_id: int,
351
+ question: str,
352
+ agent_id: str,
353
+ task_id: int,
354
+ blocker_type: BlockerType,
355
+ created_at: datetime,
356
+ ) -> None:
357
+ """Fire-and-forget wrapper for send_blocker_notification.
358
+
359
+ Launches notification task in background without awaiting result.
360
+ Use this method to avoid blocking blocker creation.
361
+
362
+ Args:
363
+ blocker_id: Blocker database ID
364
+ question: Blocker question text
365
+ agent_id: Agent that created the blocker
366
+ task_id: Associated task ID
367
+ blocker_type: SYNC or ASYNC
368
+ created_at: Blocker creation timestamp
369
+ """
370
+ # Create background task
371
+ asyncio.create_task(
372
+ self.send_blocker_notification(
373
+ blocker_id=blocker_id,
374
+ question=question,
375
+ agent_id=agent_id,
376
+ task_id=task_id,
377
+ blocker_type=blocker_type,
378
+ created_at=created_at,
379
+ )
380
+ )
@@ -0,0 +1,30 @@
1
+ """Planning module for CodeFRAME.
2
+
3
+ This module handles PRD-to-Issue-to-Task decomposition:
4
+ - Issue generation from PRD features
5
+ - Task decomposition from issues
6
+ - Work breakdown planning
7
+ - PRD template system for customizable output formats
8
+ """
9
+
10
+ from codeframe.planning.issue_generator import (
11
+ IssueGenerator,
12
+ parse_prd_features,
13
+ assign_priority,
14
+ )
15
+ from codeframe.planning.prd_templates import (
16
+ PrdTemplate,
17
+ PrdTemplateSection,
18
+ PrdTemplateManager,
19
+ BUILTIN_TEMPLATES,
20
+ )
21
+
22
+ __all__ = [
23
+ "IssueGenerator",
24
+ "parse_prd_features",
25
+ "assign_priority",
26
+ "PrdTemplate",
27
+ "PrdTemplateSection",
28
+ "PrdTemplateManager",
29
+ "BUILTIN_TEMPLATES",
30
+ ]
@@ -0,0 +1,219 @@
1
+ """Issue Generator - Extract high-level features from PRD and create Issues.
2
+
3
+ This module implements the hierarchical work breakdown for CodeFRAME:
4
+ PRD → Issues → Tasks
5
+
6
+ Issues are high-level work items (e.g., "2.1", "2.2") that can parallelize.
7
+ Each issue will later be decomposed into sequential tasks (e.g., "2.1.1", "2.1.2").
8
+
9
+ Algorithm:
10
+ 1. Parse PRD markdown for "Features & Requirements" section
11
+ 2. Each major feature (### header) becomes an Issue
12
+ 3. Number issues sequentially: {sprint}.1, {sprint}.2, etc.
13
+ 4. Extract title and description from feature text
14
+ 5. Assign priority based on feature importance keywords
15
+ 6. Set status='pending', workflow_step=0
16
+ """
17
+
18
+ import re
19
+ import logging
20
+ from typing import List, Dict, Any
21
+ from codeframe.core.models import Issue, TaskStatus
22
+
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def parse_prd_features(prd_content: str) -> List[Dict[str, Any]]:
28
+ """Parse PRD markdown and extract features from Features & Requirements section.
29
+
30
+ Args:
31
+ prd_content: Full PRD content as markdown string
32
+
33
+ Returns:
34
+ List of feature dictionaries with 'title', 'description', and 'raw_text'
35
+ """
36
+ if not prd_content or not prd_content.strip():
37
+ logger.warning("Empty PRD content provided")
38
+ return []
39
+
40
+ features = []
41
+
42
+ # Find the Features & Requirements section
43
+ # Look for ## Features & Requirements, then capture everything until next ## or end
44
+ features_section_pattern = r"##\s+Features\s*&\s*Requirements(.*?)(?=\n##[^#]|\Z)"
45
+ features_match = re.search(features_section_pattern, prd_content, re.IGNORECASE | re.DOTALL)
46
+
47
+ if not features_match:
48
+ logger.warning("No 'Features & Requirements' section found in PRD")
49
+ return []
50
+
51
+ features_section = features_match.group(1).strip()
52
+
53
+ # Extract individual features using ### headers (but not #### or deeper)
54
+ # Match ### followed by title, then capture everything until next ### or end of section
55
+ # Use negative lookahead to ensure we don't match #### or more
56
+ feature_pattern = r"###(?!#)\s+([^\n]+)\n(.*?)(?=\n###(?!#)|\Z)"
57
+ feature_matches = re.finditer(feature_pattern, features_section, re.DOTALL)
58
+
59
+ for match in feature_matches:
60
+ raw_title = match.group(1).strip()
61
+ description = match.group(2).strip()
62
+
63
+ # Remove priority keywords from title (Critical:, High:, etc.)
64
+ title = re.sub(r"^(Critical|High|Medium|Low)\s*:\s*", "", raw_title, flags=re.IGNORECASE)
65
+
66
+ features.append(
67
+ {
68
+ "title": title.strip(),
69
+ "description": description.strip(),
70
+ "raw_text": raw_title + "\n" + description, # Keep original for priority detection
71
+ }
72
+ )
73
+
74
+ logger.info(f"Extracted {len(features)} features from PRD")
75
+ return features
76
+
77
+
78
+ def assign_priority(text: str) -> int:
79
+ """Assign priority based on keywords in text.
80
+
81
+ Priority levels:
82
+ - 0: Critical
83
+ - 1: High
84
+ - 2: Medium (default)
85
+ - 3: Low
86
+ - 4: Nice-to-have
87
+
88
+ Args:
89
+ text: Feature text to analyze for priority keywords
90
+
91
+ Returns:
92
+ Priority level (0-4)
93
+ """
94
+ text_lower = text.lower()
95
+
96
+ # Check for priority keywords (case-insensitive, first match wins)
97
+ if "critical" in text_lower or "urgent" in text_lower or "must have" in text_lower:
98
+ return 0
99
+ elif "high" in text_lower or "important" in text_lower:
100
+ return 1
101
+ elif "low" in text_lower or "optional" in text_lower:
102
+ return 3
103
+ elif "nice to have" in text_lower or "nice-to-have" in text_lower:
104
+ return 4
105
+ else:
106
+ # Default to medium priority
107
+ return 2
108
+
109
+
110
+ class IssueGenerator:
111
+ """Generator for creating Issues from PRD features."""
112
+
113
+ def __init__(self):
114
+ """Initialize the issue generator."""
115
+ self.logger = logging.getLogger(__name__)
116
+
117
+ def generate_issues_from_prd(self, prd_content: str, sprint_number: int) -> List[Issue]:
118
+ """Generate issues from PRD content.
119
+
120
+ Main entry point for issue generation. Parses PRD, creates issues,
121
+ validates them, and returns the list.
122
+
123
+ Args:
124
+ prd_content: Full PRD markdown content
125
+ sprint_number: Sprint number for issue numbering (e.g., 2 for Sprint 2)
126
+
127
+ Returns:
128
+ List of Issue objects with sequential numbering
129
+ """
130
+ self.logger.info(f"Generating issues from PRD for sprint {sprint_number}")
131
+
132
+ # Parse features from PRD
133
+ features = parse_prd_features(prd_content)
134
+
135
+ if not features:
136
+ self.logger.warning("No features found in PRD, returning empty list")
137
+ return []
138
+
139
+ # Create issues from features
140
+ issues = self._create_issues(features, sprint_number)
141
+
142
+ # Validate all issues
143
+ for issue in issues:
144
+ self._validate_issue(issue)
145
+
146
+ self.logger.info(f"Generated {len(issues)} issues successfully")
147
+ return issues
148
+
149
+ def _create_issues(self, features: List[Dict[str, Any]], sprint_number: int) -> List[Issue]:
150
+ """Create Issue objects from parsed features.
151
+
152
+ Args:
153
+ features: List of feature dictionaries from parse_prd_features()
154
+ sprint_number: Sprint number for issue numbering
155
+
156
+ Returns:
157
+ List of Issue objects
158
+ """
159
+ issues = []
160
+
161
+ for idx, feature in enumerate(features, start=1):
162
+ # Generate issue number: {sprint}.{idx}
163
+ issue_number = f"{sprint_number}.{idx}"
164
+
165
+ # Assign priority based on raw text (includes keywords)
166
+ priority = assign_priority(feature["raw_text"])
167
+
168
+ # Create issue
169
+ issue = Issue(
170
+ issue_number=issue_number,
171
+ title=feature["title"],
172
+ description=feature["description"],
173
+ priority=priority,
174
+ status=TaskStatus.PENDING,
175
+ workflow_step=0,
176
+ )
177
+
178
+ issues.append(issue)
179
+ self.logger.debug(
180
+ f"Created issue {issue_number}: {feature['title']} (priority={priority})"
181
+ )
182
+
183
+ return issues
184
+
185
+ def _validate_issue(self, issue: Issue) -> None:
186
+ """Validate issue meets requirements.
187
+
188
+ Args:
189
+ issue: Issue to validate
190
+
191
+ Raises:
192
+ ValueError: If issue is invalid
193
+ """
194
+ # Validate title
195
+ if not issue.title or not issue.title.strip():
196
+ raise ValueError(f"Issue {issue.issue_number} must have a title")
197
+
198
+ # Validate issue number format (X.Y)
199
+ issue_number_pattern = r"^\d+\.\d+$"
200
+ if not re.match(issue_number_pattern, issue.issue_number):
201
+ raise ValueError(
202
+ f"Issue number '{issue.issue_number}' must match format X.Y "
203
+ f"(e.g., '2.1', '3.5')"
204
+ )
205
+
206
+ # Validate priority range
207
+ if not (0 <= issue.priority <= 4):
208
+ raise ValueError(
209
+ f"Issue {issue.issue_number} priority must be 0-4, got {issue.priority}"
210
+ )
211
+
212
+ # Validate status
213
+ if not isinstance(issue.status, TaskStatus):
214
+ raise ValueError(
215
+ f"Issue {issue.issue_number} status must be TaskStatus enum, "
216
+ f"got {type(issue.status)}"
217
+ )
218
+
219
+ self.logger.debug(f"Validated issue {issue.issue_number}")