draft-board 0.1.0-beta.0

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 (250) hide show
  1. package/app/backend/.env.example +9 -0
  2. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
  3. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
  4. package/app/backend/CURL_EXAMPLES.md +335 -0
  5. package/app/backend/ENV_SETUP.md +65 -0
  6. package/app/backend/alembic/env.py +71 -0
  7. package/app/backend/alembic/script.py.mako +28 -0
  8. package/app/backend/alembic/versions/001_initial_schema.py +104 -0
  9. package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
  10. package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
  11. package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
  12. package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
  13. package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
  14. package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
  15. package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
  16. package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
  17. package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
  18. package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
  19. package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
  20. package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
  21. package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
  22. package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
  23. package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
  24. package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
  25. package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
  26. package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
  27. package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
  28. package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
  29. package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
  30. package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
  31. package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
  32. package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
  33. package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
  34. package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
  35. package/app/backend/alembic/versions/add_job_variant.py +34 -0
  36. package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
  37. package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
  38. package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
  39. package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
  40. package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
  41. package/app/backend/alembic.ini +89 -0
  42. package/app/backend/app/__init__.py +3 -0
  43. package/app/backend/app/data_dir.py +85 -0
  44. package/app/backend/app/database.py +70 -0
  45. package/app/backend/app/database_sync.py +64 -0
  46. package/app/backend/app/dependencies/__init__.py +5 -0
  47. package/app/backend/app/dependencies/auth.py +80 -0
  48. package/app/backend/app/dependencies.py +43 -0
  49. package/app/backend/app/exceptions.py +178 -0
  50. package/app/backend/app/executors/__init__.py +1 -0
  51. package/app/backend/app/executors/adapters/__init__.py +1 -0
  52. package/app/backend/app/executors/adapters/aider.py +152 -0
  53. package/app/backend/app/executors/adapters/amazon_q.py +103 -0
  54. package/app/backend/app/executors/adapters/amp.py +123 -0
  55. package/app/backend/app/executors/adapters/claude.py +177 -0
  56. package/app/backend/app/executors/adapters/cline.py +127 -0
  57. package/app/backend/app/executors/adapters/codex.py +167 -0
  58. package/app/backend/app/executors/adapters/copilot.py +202 -0
  59. package/app/backend/app/executors/adapters/cursor.py +87 -0
  60. package/app/backend/app/executors/adapters/droid.py +123 -0
  61. package/app/backend/app/executors/adapters/gemini.py +132 -0
  62. package/app/backend/app/executors/adapters/goose.py +131 -0
  63. package/app/backend/app/executors/adapters/opencode.py +123 -0
  64. package/app/backend/app/executors/adapters/qwen.py +123 -0
  65. package/app/backend/app/executors/plugins/__init__.py +1 -0
  66. package/app/backend/app/executors/registry.py +202 -0
  67. package/app/backend/app/executors/spec.py +226 -0
  68. package/app/backend/app/main.py +486 -0
  69. package/app/backend/app/middleware/__init__.py +13 -0
  70. package/app/backend/app/middleware/idempotency.py +426 -0
  71. package/app/backend/app/middleware/rate_limit.py +312 -0
  72. package/app/backend/app/middleware/security_headers.py +43 -0
  73. package/app/backend/app/middleware/timeout.py +37 -0
  74. package/app/backend/app/models/__init__.py +56 -0
  75. package/app/backend/app/models/agent_conversation_history.py +56 -0
  76. package/app/backend/app/models/agent_session.py +127 -0
  77. package/app/backend/app/models/analysis_cache.py +49 -0
  78. package/app/backend/app/models/base.py +9 -0
  79. package/app/backend/app/models/board.py +79 -0
  80. package/app/backend/app/models/board_repo.py +68 -0
  81. package/app/backend/app/models/cost_budget.py +42 -0
  82. package/app/backend/app/models/enums.py +40 -0
  83. package/app/backend/app/models/evidence.py +132 -0
  84. package/app/backend/app/models/goal.py +102 -0
  85. package/app/backend/app/models/idempotency_entry.py +30 -0
  86. package/app/backend/app/models/job.py +163 -0
  87. package/app/backend/app/models/job_queue.py +39 -0
  88. package/app/backend/app/models/kv_store.py +28 -0
  89. package/app/backend/app/models/merge_checklist.py +87 -0
  90. package/app/backend/app/models/normalized_log.py +100 -0
  91. package/app/backend/app/models/planner_lock.py +43 -0
  92. package/app/backend/app/models/rate_limit_entry.py +25 -0
  93. package/app/backend/app/models/repo.py +66 -0
  94. package/app/backend/app/models/review_comment.py +91 -0
  95. package/app/backend/app/models/review_summary.py +69 -0
  96. package/app/backend/app/models/revision.py +130 -0
  97. package/app/backend/app/models/ticket.py +223 -0
  98. package/app/backend/app/models/ticket_event.py +83 -0
  99. package/app/backend/app/models/user.py +47 -0
  100. package/app/backend/app/models/workspace.py +71 -0
  101. package/app/backend/app/redis_client.py +119 -0
  102. package/app/backend/app/routers/__init__.py +29 -0
  103. package/app/backend/app/routers/agents.py +296 -0
  104. package/app/backend/app/routers/auth.py +94 -0
  105. package/app/backend/app/routers/board.py +885 -0
  106. package/app/backend/app/routers/dashboard.py +351 -0
  107. package/app/backend/app/routers/debug.py +528 -0
  108. package/app/backend/app/routers/evidence.py +96 -0
  109. package/app/backend/app/routers/executors.py +324 -0
  110. package/app/backend/app/routers/goals.py +574 -0
  111. package/app/backend/app/routers/jobs.py +448 -0
  112. package/app/backend/app/routers/maintenance.py +172 -0
  113. package/app/backend/app/routers/merge.py +360 -0
  114. package/app/backend/app/routers/planner.py +537 -0
  115. package/app/backend/app/routers/pull_requests.py +382 -0
  116. package/app/backend/app/routers/repos.py +263 -0
  117. package/app/backend/app/routers/revisions.py +939 -0
  118. package/app/backend/app/routers/settings.py +267 -0
  119. package/app/backend/app/routers/tickets.py +2003 -0
  120. package/app/backend/app/routers/webhooks.py +143 -0
  121. package/app/backend/app/routers/websocket.py +249 -0
  122. package/app/backend/app/schemas/__init__.py +109 -0
  123. package/app/backend/app/schemas/board.py +87 -0
  124. package/app/backend/app/schemas/common.py +33 -0
  125. package/app/backend/app/schemas/evidence.py +87 -0
  126. package/app/backend/app/schemas/goal.py +90 -0
  127. package/app/backend/app/schemas/job.py +97 -0
  128. package/app/backend/app/schemas/merge.py +139 -0
  129. package/app/backend/app/schemas/planner.py +500 -0
  130. package/app/backend/app/schemas/repo.py +187 -0
  131. package/app/backend/app/schemas/review.py +137 -0
  132. package/app/backend/app/schemas/revision.py +114 -0
  133. package/app/backend/app/schemas/ticket.py +238 -0
  134. package/app/backend/app/schemas/ticket_event.py +72 -0
  135. package/app/backend/app/schemas/workspace.py +19 -0
  136. package/app/backend/app/services/__init__.py +31 -0
  137. package/app/backend/app/services/agent_memory_service.py +223 -0
  138. package/app/backend/app/services/agent_registry.py +346 -0
  139. package/app/backend/app/services/agent_session_manager.py +318 -0
  140. package/app/backend/app/services/agent_session_service.py +219 -0
  141. package/app/backend/app/services/agent_tools.py +379 -0
  142. package/app/backend/app/services/auth_service.py +98 -0
  143. package/app/backend/app/services/autonomy_service.py +380 -0
  144. package/app/backend/app/services/board_repo_service.py +201 -0
  145. package/app/backend/app/services/board_service.py +326 -0
  146. package/app/backend/app/services/cleanup_service.py +1085 -0
  147. package/app/backend/app/services/config_service.py +908 -0
  148. package/app/backend/app/services/context_gatherer.py +557 -0
  149. package/app/backend/app/services/cost_tracking_service.py +293 -0
  150. package/app/backend/app/services/cursor_log_normalizer.py +536 -0
  151. package/app/backend/app/services/delivery_pipeline.py +440 -0
  152. package/app/backend/app/services/executor_service.py +634 -0
  153. package/app/backend/app/services/git_host/__init__.py +11 -0
  154. package/app/backend/app/services/git_host/factory.py +87 -0
  155. package/app/backend/app/services/git_host/github.py +270 -0
  156. package/app/backend/app/services/git_host/gitlab.py +194 -0
  157. package/app/backend/app/services/git_host/protocol.py +75 -0
  158. package/app/backend/app/services/git_merge_simple.py +346 -0
  159. package/app/backend/app/services/git_ops.py +384 -0
  160. package/app/backend/app/services/github_service.py +233 -0
  161. package/app/backend/app/services/goal_service.py +113 -0
  162. package/app/backend/app/services/job_service.py +423 -0
  163. package/app/backend/app/services/job_watchdog_service.py +424 -0
  164. package/app/backend/app/services/langchain_adapter.py +122 -0
  165. package/app/backend/app/services/llm_provider_clients.py +351 -0
  166. package/app/backend/app/services/llm_service.py +285 -0
  167. package/app/backend/app/services/log_normalizer.py +342 -0
  168. package/app/backend/app/services/log_stream_service.py +276 -0
  169. package/app/backend/app/services/merge_checklist_service.py +264 -0
  170. package/app/backend/app/services/merge_service.py +784 -0
  171. package/app/backend/app/services/orchestrator_log.py +84 -0
  172. package/app/backend/app/services/planner_service.py +1662 -0
  173. package/app/backend/app/services/planner_tick_sync.py +1040 -0
  174. package/app/backend/app/services/queued_message_service.py +156 -0
  175. package/app/backend/app/services/reliability_wrapper.py +389 -0
  176. package/app/backend/app/services/repo_discovery_service.py +318 -0
  177. package/app/backend/app/services/review_service.py +334 -0
  178. package/app/backend/app/services/revision_service.py +389 -0
  179. package/app/backend/app/services/safe_autopilot.py +510 -0
  180. package/app/backend/app/services/sqlite_worker.py +372 -0
  181. package/app/backend/app/services/task_dispatch.py +135 -0
  182. package/app/backend/app/services/ticket_generation_service.py +1781 -0
  183. package/app/backend/app/services/ticket_service.py +486 -0
  184. package/app/backend/app/services/udar_planner_service.py +1007 -0
  185. package/app/backend/app/services/webhook_service.py +126 -0
  186. package/app/backend/app/services/workspace_service.py +465 -0
  187. package/app/backend/app/services/worktree_file_service.py +92 -0
  188. package/app/backend/app/services/worktree_validator.py +213 -0
  189. package/app/backend/app/sqlite_kv.py +278 -0
  190. package/app/backend/app/state_machine.py +128 -0
  191. package/app/backend/app/templates/__init__.py +5 -0
  192. package/app/backend/app/templates/registry.py +243 -0
  193. package/app/backend/app/utils/__init__.py +5 -0
  194. package/app/backend/app/utils/artifact_reader.py +87 -0
  195. package/app/backend/app/utils/circuit_breaker.py +229 -0
  196. package/app/backend/app/utils/db_retry.py +136 -0
  197. package/app/backend/app/utils/ignored_fields.py +123 -0
  198. package/app/backend/app/utils/validators.py +54 -0
  199. package/app/backend/app/websocket/__init__.py +5 -0
  200. package/app/backend/app/websocket/manager.py +179 -0
  201. package/app/backend/app/websocket/state_tracker.py +113 -0
  202. package/app/backend/app/worker.py +3190 -0
  203. package/app/backend/calculator_tickets.json +40 -0
  204. package/app/backend/canary_tests.sh +591 -0
  205. package/app/backend/celerybeat-schedule +0 -0
  206. package/app/backend/celerybeat-schedule-shm +0 -0
  207. package/app/backend/celerybeat-schedule-wal +0 -0
  208. package/app/backend/logs/.gitkeep +3 -0
  209. package/app/backend/multiplication_division_implementation_tickets.json +55 -0
  210. package/app/backend/multiplication_division_tickets.json +42 -0
  211. package/app/backend/pyproject.toml +45 -0
  212. package/app/backend/requirements-dev.txt +8 -0
  213. package/app/backend/requirements.txt +20 -0
  214. package/app/backend/run.sh +30 -0
  215. package/app/backend/run_with_logs.sh +10 -0
  216. package/app/backend/scientific_calculator_tickets.json +40 -0
  217. package/app/backend/scripts/extract_openapi.py +21 -0
  218. package/app/backend/scripts/seed_demo.py +187 -0
  219. package/app/backend/setup_demo_review.py +302 -0
  220. package/app/backend/test_actual_parse.py +41 -0
  221. package/app/backend/test_agent_streaming.py +61 -0
  222. package/app/backend/test_parse.py +51 -0
  223. package/app/backend/test_streaming.py +51 -0
  224. package/app/backend/test_subprocess_streaming.py +50 -0
  225. package/app/backend/tests/__init__.py +1 -0
  226. package/app/backend/tests/conftest.py +46 -0
  227. package/app/backend/tests/test_auth.py +341 -0
  228. package/app/backend/tests/test_autonomy_service.py +391 -0
  229. package/app/backend/tests/test_cleanup_service_safety.py +417 -0
  230. package/app/backend/tests/test_middleware.py +279 -0
  231. package/app/backend/tests/test_planner_providers.py +290 -0
  232. package/app/backend/tests/test_planner_unblock.py +183 -0
  233. package/app/backend/tests/test_revision_invariants.py +618 -0
  234. package/app/backend/tests/test_sqlite_kv.py +290 -0
  235. package/app/backend/tests/test_sqlite_worker.py +353 -0
  236. package/app/backend/tests/test_task_dispatch.py +100 -0
  237. package/app/backend/tests/test_ticket_validation.py +304 -0
  238. package/app/backend/tests/test_udar_agent.py +693 -0
  239. package/app/backend/tests/test_webhook_service.py +184 -0
  240. package/app/backend/tickets_output.json +59 -0
  241. package/app/backend/user_management_tickets.json +50 -0
  242. package/app/backend/uvicorn.log +0 -0
  243. package/app/draft.yaml +313 -0
  244. package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
  245. package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
  246. package/app/frontend/dist/index.html +14 -0
  247. package/app/frontend/dist/vite.svg +1 -0
  248. package/app/frontend/package.json +101 -0
  249. package/bin/cli.js +527 -0
  250. package/package.json +37 -0
@@ -0,0 +1,223 @@
1
+ """Agent memory service for UDAR conversation history.
2
+
3
+ This service manages compressed conversation checkpoints for the UDAR agent.
4
+ Instead of storing full LLM conversation history (expensive), it stores
5
+ only summaries and metadata (lean storage optimization).
6
+ """
7
+
8
+ import json
9
+ from datetime import datetime, timedelta
10
+ from typing import Any
11
+
12
+ from sqlalchemy import delete, select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from app.models.agent_conversation_history import AgentConversationHistory
16
+
17
+
18
+ class AgentMemoryService:
19
+ """Manages conversation history and checkpoints for UDAR agent.
20
+
21
+ COST OPTIMIZATION: Stores summaries, not full messages.
22
+ """
23
+
24
+ def __init__(self, db: AsyncSession):
25
+ """Initialize memory service.
26
+
27
+ Args:
28
+ db: Async database session
29
+ """
30
+ self.db = db
31
+
32
+ async def save_checkpoint(
33
+ self,
34
+ goal_id: str,
35
+ checkpoint_id: str,
36
+ state: dict[str, Any],
37
+ ) -> None:
38
+ """Save agent state to database (COMPRESSED).
39
+
40
+ Only stores summary + metadata, not full LLM responses.
41
+ This keeps storage lean while preserving essential context.
42
+
43
+ Args:
44
+ goal_id: Goal this checkpoint belongs to
45
+ checkpoint_id: Unique checkpoint identifier
46
+ state: UDAR state dict to checkpoint
47
+ """
48
+ # Extract only essential data (not full messages)
49
+ summary = {
50
+ "tickets_proposed": len(state.get("proposed_tickets", [])),
51
+ "tickets_validated": len(state.get("validated_tickets", [])),
52
+ "reasoning_summary": state.get("reasoning", "")[:500], # Cap at 500 chars
53
+ "phase": state.get("phase", "unknown"),
54
+ "iteration": state.get("iteration", 0),
55
+ "llm_calls_made": state.get("llm_calls_made", 0),
56
+ "trigger": state.get("trigger", "unknown"),
57
+ }
58
+
59
+ # Check if checkpoint already exists
60
+ existing = await self.db.execute(
61
+ select(AgentConversationHistory).where(
62
+ AgentConversationHistory.goal_id == goal_id,
63
+ AgentConversationHistory.checkpoint_id == checkpoint_id,
64
+ )
65
+ )
66
+ existing_checkpoint = existing.scalar_one_or_none()
67
+
68
+ if existing_checkpoint:
69
+ # Update existing checkpoint
70
+ existing_checkpoint.metadata_json = json.dumps(summary)
71
+ existing_checkpoint.updated_at = datetime.utcnow()
72
+ else:
73
+ # Create new checkpoint
74
+ history = AgentConversationHistory(
75
+ goal_id=goal_id,
76
+ checkpoint_id=checkpoint_id,
77
+ messages_json=json.dumps([]), # Empty, don't store full messages
78
+ metadata_json=json.dumps(summary),
79
+ )
80
+ self.db.add(history)
81
+
82
+ await self.db.commit()
83
+
84
+ async def load_checkpoint(self, goal_id: str) -> dict[str, Any] | None:
85
+ """Load most recent checkpoint summary (not full history).
86
+
87
+ Args:
88
+ goal_id: Goal to load checkpoint for
89
+
90
+ Returns:
91
+ Checkpoint summary dict, or None if no checkpoint exists
92
+ """
93
+ result = await self.db.execute(
94
+ select(AgentConversationHistory)
95
+ .where(AgentConversationHistory.goal_id == goal_id)
96
+ .order_by(AgentConversationHistory.created_at.desc())
97
+ .limit(1)
98
+ )
99
+ history = result.scalar_one_or_none()
100
+
101
+ if history:
102
+ # Return summary only, agent doesn't need full history
103
+ return json.loads(history.metadata_json)
104
+
105
+ return None
106
+
107
+ async def list_checkpoints(
108
+ self,
109
+ goal_id: str,
110
+ limit: int = 10,
111
+ ) -> list[dict[str, Any]]:
112
+ """List recent checkpoints for a goal.
113
+
114
+ Args:
115
+ goal_id: Goal to list checkpoints for
116
+ limit: Maximum number of checkpoints to return
117
+
118
+ Returns:
119
+ List of checkpoint dicts with metadata
120
+ """
121
+ result = await self.db.execute(
122
+ select(AgentConversationHistory)
123
+ .where(AgentConversationHistory.goal_id == goal_id)
124
+ .order_by(AgentConversationHistory.created_at.desc())
125
+ .limit(limit)
126
+ )
127
+ checkpoints = result.scalars().all()
128
+
129
+ return [
130
+ {
131
+ "id": checkpoint.id,
132
+ "checkpoint_id": checkpoint.checkpoint_id,
133
+ "created_at": checkpoint.created_at.isoformat(),
134
+ "metadata": json.loads(checkpoint.metadata_json),
135
+ }
136
+ for checkpoint in checkpoints
137
+ ]
138
+
139
+ async def cleanup_old_checkpoints(self, days: int = 30) -> int:
140
+ """Delete checkpoints older than N days to save storage.
141
+
142
+ Args:
143
+ days: Age threshold in days
144
+
145
+ Returns:
146
+ Number of checkpoints deleted
147
+ """
148
+ cutoff = datetime.utcnow() - timedelta(days=days)
149
+
150
+ result = await self.db.execute(
151
+ delete(AgentConversationHistory).where(
152
+ AgentConversationHistory.created_at < cutoff
153
+ )
154
+ )
155
+ deleted_count = result.rowcount
156
+
157
+ await self.db.commit()
158
+
159
+ return deleted_count
160
+
161
+ async def delete_checkpoints_for_goal(self, goal_id: str) -> int:
162
+ """Delete all checkpoints for a goal.
163
+
164
+ Args:
165
+ goal_id: Goal to delete checkpoints for
166
+
167
+ Returns:
168
+ Number of checkpoints deleted
169
+ """
170
+ result = await self.db.execute(
171
+ delete(AgentConversationHistory).where(
172
+ AgentConversationHistory.goal_id == goal_id
173
+ )
174
+ )
175
+ deleted_count = result.rowcount
176
+
177
+ await self.db.commit()
178
+
179
+ return deleted_count
180
+
181
+ async def get_goal_summary(self, goal_id: str) -> dict[str, Any]:
182
+ """Get summary of agent activity for a goal.
183
+
184
+ Args:
185
+ goal_id: Goal to summarize
186
+
187
+ Returns:
188
+ Summary dict with aggregated statistics
189
+ """
190
+ result = await self.db.execute(
191
+ select(AgentConversationHistory).where(
192
+ AgentConversationHistory.goal_id == goal_id
193
+ )
194
+ )
195
+ checkpoints = result.scalars().all()
196
+
197
+ if not checkpoints:
198
+ return {
199
+ "goal_id": goal_id,
200
+ "checkpoint_count": 0,
201
+ "total_llm_calls": 0,
202
+ "total_tickets_proposed": 0,
203
+ "first_checkpoint": None,
204
+ "last_checkpoint": None,
205
+ }
206
+
207
+ # Aggregate statistics
208
+ total_llm_calls = 0
209
+ total_tickets_proposed = 0
210
+
211
+ for checkpoint in checkpoints:
212
+ metadata = json.loads(checkpoint.metadata_json)
213
+ total_llm_calls += metadata.get("llm_calls_made", 0)
214
+ total_tickets_proposed += metadata.get("tickets_proposed", 0)
215
+
216
+ return {
217
+ "goal_id": goal_id,
218
+ "checkpoint_count": len(checkpoints),
219
+ "total_llm_calls": total_llm_calls,
220
+ "total_tickets_proposed": total_tickets_proposed,
221
+ "first_checkpoint": checkpoints[-1].created_at.isoformat(),
222
+ "last_checkpoint": checkpoints[0].created_at.isoformat(),
223
+ }
@@ -0,0 +1,346 @@
1
+ """Agent registry for supporting multiple AI coding agents.
2
+
3
+ This module provides a pluggable architecture for supporting multiple
4
+ AI coding agents (Claude, Amp, Codex, Gemini, etc.) with a unified interface.
5
+ """
6
+
7
+ import logging
8
+ import shutil
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass, field
11
+ from enum import StrEnum
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class AgentType(StrEnum):
19
+ """Supported AI coding agents."""
20
+
21
+ CLAUDE = "claude"
22
+ CURSOR = "cursor"
23
+ AMP = "amp"
24
+ CODEX = "codex"
25
+ GEMINI = "gemini"
26
+ AIDER = "aider"
27
+ CONTINUE = "continue"
28
+
29
+
30
+ @dataclass
31
+ class AgentConfig:
32
+ """Configuration for an AI agent."""
33
+
34
+ agent_type: AgentType
35
+ command: str # Base command to run
36
+ args: list[str] = field(default_factory=list)
37
+ env_vars: dict[str, str] = field(default_factory=dict)
38
+ timeout: int = 600 # seconds
39
+ supports_yolo: bool = False
40
+ supports_session_resume: bool = False
41
+ supports_mcp: bool = False
42
+ cost_per_1k_input: float | None = None
43
+ cost_per_1k_output: float | None = None
44
+
45
+
46
+ class AgentExecutor(ABC):
47
+ """Abstract base class for agent executors."""
48
+
49
+ def __init__(self, config: AgentConfig):
50
+ self.config = config
51
+
52
+ @abstractmethod
53
+ def is_available(self) -> bool:
54
+ """Check if this agent is available on the system."""
55
+ pass
56
+
57
+ @abstractmethod
58
+ def build_command(
59
+ self,
60
+ prompt: str,
61
+ working_dir: Path,
62
+ yolo_mode: bool = False,
63
+ session_id: str | None = None,
64
+ **kwargs,
65
+ ) -> list[str]:
66
+ """Build the command to execute the agent."""
67
+ pass
68
+
69
+ @abstractmethod
70
+ def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
71
+ """Parse agent output into structured format."""
72
+ pass
73
+
74
+
75
+ class ClaudeExecutor(AgentExecutor):
76
+ """Executor for Claude Code CLI."""
77
+
78
+ def is_available(self) -> bool:
79
+ return shutil.which("claude") is not None
80
+
81
+ def build_command(
82
+ self,
83
+ prompt: str,
84
+ working_dir: Path,
85
+ yolo_mode: bool = False,
86
+ session_id: str | None = None,
87
+ **kwargs,
88
+ ) -> list[str]:
89
+ cmd = [self.config.command, "--print", "--output-format", "json"]
90
+
91
+ if yolo_mode and self.config.supports_yolo:
92
+ cmd.append("--dangerously-skip-permissions")
93
+
94
+ if session_id and self.config.supports_session_resume:
95
+ cmd.extend(["--resume", session_id])
96
+
97
+ cmd.extend(["--prompt", prompt])
98
+ return cmd
99
+
100
+ def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
101
+ # Parse Claude's JSON output format
102
+ import json
103
+
104
+ try:
105
+ return {"success": True, "data": json.loads(stdout)}
106
+ except json.JSONDecodeError:
107
+ return {"success": False, "raw_output": stdout, "error": stderr}
108
+
109
+
110
+ class AmpExecutor(AgentExecutor):
111
+ """Executor for Amp CLI."""
112
+
113
+ def is_available(self) -> bool:
114
+ return shutil.which("amp") is not None
115
+
116
+ def build_command(
117
+ self,
118
+ prompt: str,
119
+ working_dir: Path,
120
+ yolo_mode: bool = False,
121
+ session_id: str | None = None,
122
+ **kwargs,
123
+ ) -> list[str]:
124
+ cmd = [self.config.command, "run"]
125
+
126
+ if session_id:
127
+ cmd.extend(["--thread", session_id])
128
+
129
+ cmd.extend(["--message", prompt])
130
+ return cmd
131
+
132
+ def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
133
+ return {"success": True, "raw_output": stdout}
134
+
135
+
136
+ class CursorExecutor(AgentExecutor):
137
+ """Executor for Cursor Agent CLI."""
138
+
139
+ def is_available(self) -> bool:
140
+ # Check common paths for cursor-agent
141
+ paths = [
142
+ shutil.which("cursor-agent"),
143
+ Path.home() / ".local/bin/cursor-agent",
144
+ Path("/usr/local/bin/cursor-agent"),
145
+ ]
146
+ return any(p and (isinstance(p, str) or p.exists()) for p in paths)
147
+
148
+ def build_command(
149
+ self,
150
+ prompt: str,
151
+ working_dir: Path,
152
+ yolo_mode: bool = False,
153
+ session_id: str | None = None,
154
+ **kwargs,
155
+ ) -> list[str]:
156
+ cmd = [self.config.command]
157
+ cmd.extend(["--prompt", prompt])
158
+ return cmd
159
+
160
+ def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
161
+ return {"success": True, "raw_output": stdout, "interactive": True}
162
+
163
+
164
+ class AiderExecutor(AgentExecutor):
165
+ """Executor for Aider CLI (open-source coding assistant)."""
166
+
167
+ def is_available(self) -> bool:
168
+ return shutil.which("aider") is not None
169
+
170
+ def build_command(
171
+ self,
172
+ prompt: str,
173
+ working_dir: Path,
174
+ yolo_mode: bool = False,
175
+ session_id: str | None = None,
176
+ **kwargs,
177
+ ) -> list[str]:
178
+ cmd = [self.config.command, "--yes", "--no-auto-commits"]
179
+
180
+ if yolo_mode:
181
+ cmd.append("--auto-commits")
182
+
183
+ cmd.extend(["--message", prompt])
184
+ return cmd
185
+
186
+ def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
187
+ return {"success": True, "raw_output": stdout}
188
+
189
+
190
+ class GeminiExecutor(AgentExecutor):
191
+ """Executor for Gemini CLI."""
192
+
193
+ def is_available(self) -> bool:
194
+ return shutil.which("gemini") is not None
195
+
196
+ def build_command(
197
+ self,
198
+ prompt: str,
199
+ working_dir: Path,
200
+ yolo_mode: bool = False,
201
+ session_id: str | None = None,
202
+ **kwargs,
203
+ ) -> list[str]:
204
+ cmd = [self.config.command]
205
+
206
+ if yolo_mode:
207
+ cmd.extend(["--sandbox=false"])
208
+
209
+ cmd.extend(["--prompt", prompt])
210
+ return cmd
211
+
212
+ def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
213
+ return {"success": True, "raw_output": stdout}
214
+
215
+
216
+ class CodexExecutor(AgentExecutor):
217
+ """Executor for OpenAI Codex CLI."""
218
+
219
+ def is_available(self) -> bool:
220
+ return shutil.which("codex") is not None
221
+
222
+ def build_command(
223
+ self,
224
+ prompt: str,
225
+ working_dir: Path,
226
+ yolo_mode: bool = False,
227
+ session_id: str | None = None,
228
+ **kwargs,
229
+ ) -> list[str]:
230
+ cmd = [self.config.command]
231
+
232
+ if yolo_mode:
233
+ cmd.extend(["--approval-mode", "full-auto"])
234
+
235
+ cmd.extend([prompt])
236
+ return cmd
237
+
238
+ def parse_output(self, stdout: str, stderr: str) -> dict[str, Any]:
239
+ return {"success": True, "raw_output": stdout}
240
+
241
+
242
+ # Agent registry with default configurations
243
+ AGENT_REGISTRY: dict[AgentType, AgentConfig] = {
244
+ AgentType.CLAUDE: AgentConfig(
245
+ agent_type=AgentType.CLAUDE,
246
+ command="claude",
247
+ supports_yolo=True,
248
+ supports_session_resume=True,
249
+ supports_mcp=True,
250
+ cost_per_1k_input=0.003,
251
+ cost_per_1k_output=0.015,
252
+ ),
253
+ AgentType.CURSOR: AgentConfig(
254
+ agent_type=AgentType.CURSOR,
255
+ command="cursor-agent",
256
+ supports_yolo=False,
257
+ supports_session_resume=False,
258
+ ),
259
+ AgentType.AMP: AgentConfig(
260
+ agent_type=AgentType.AMP,
261
+ command="amp",
262
+ supports_yolo=False,
263
+ supports_session_resume=True,
264
+ ),
265
+ AgentType.AIDER: AgentConfig(
266
+ agent_type=AgentType.AIDER,
267
+ command="aider",
268
+ supports_yolo=True,
269
+ supports_session_resume=False,
270
+ cost_per_1k_input=0.003, # Depends on model used
271
+ cost_per_1k_output=0.015,
272
+ ),
273
+ AgentType.GEMINI: AgentConfig(
274
+ agent_type=AgentType.GEMINI,
275
+ command="gemini",
276
+ supports_yolo=True,
277
+ supports_session_resume=False,
278
+ supports_mcp=False,
279
+ cost_per_1k_input=0.001,
280
+ cost_per_1k_output=0.002,
281
+ ),
282
+ AgentType.CODEX: AgentConfig(
283
+ agent_type=AgentType.CODEX,
284
+ command="codex",
285
+ supports_yolo=True,
286
+ supports_session_resume=False,
287
+ cost_per_1k_input=0.01,
288
+ cost_per_1k_output=0.03,
289
+ ),
290
+ }
291
+
292
+ EXECUTOR_CLASSES: dict[AgentType, type] = {
293
+ AgentType.CLAUDE: ClaudeExecutor,
294
+ AgentType.CURSOR: CursorExecutor,
295
+ AgentType.AMP: AmpExecutor,
296
+ AgentType.AIDER: AiderExecutor,
297
+ AgentType.GEMINI: GeminiExecutor,
298
+ AgentType.CODEX: CodexExecutor,
299
+ }
300
+
301
+
302
+ class AgentRegistry:
303
+ """Registry for managing multiple AI coding agents."""
304
+
305
+ def __init__(self):
306
+ self._executors: dict[AgentType, AgentExecutor] = {}
307
+
308
+ def get_executor(self, agent_type: AgentType) -> AgentExecutor | None:
309
+ """Get an executor for the specified agent type."""
310
+ if agent_type not in self._executors:
311
+ config = AGENT_REGISTRY.get(agent_type)
312
+ executor_class = EXECUTOR_CLASSES.get(agent_type)
313
+ if config and executor_class:
314
+ self._executors[agent_type] = executor_class(config)
315
+
316
+ return self._executors.get(agent_type)
317
+
318
+ def get_available_agents(self) -> list[AgentType]:
319
+ """Get list of agents available on this system."""
320
+ available = []
321
+ for agent_type in AgentType:
322
+ executor = self.get_executor(agent_type)
323
+ if executor and executor.is_available():
324
+ available.append(agent_type)
325
+ return available
326
+
327
+ def get_agent_info(self, agent_type: AgentType) -> dict[str, Any] | None:
328
+ """Get information about an agent."""
329
+ config = AGENT_REGISTRY.get(agent_type)
330
+ if not config:
331
+ return None
332
+
333
+ executor = self.get_executor(agent_type)
334
+ return {
335
+ "type": agent_type.value,
336
+ "available": executor.is_available() if executor else False,
337
+ "supports_yolo": config.supports_yolo,
338
+ "supports_session_resume": config.supports_session_resume,
339
+ "supports_mcp": config.supports_mcp,
340
+ "cost_per_1k_input": config.cost_per_1k_input,
341
+ "cost_per_1k_output": config.cost_per_1k_output,
342
+ }
343
+
344
+
345
+ # Global registry instance
346
+ agent_registry = AgentRegistry()