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,634 @@
1
+ """Service for executing code changes using CLI tools (Claude, Codex, Gemini, Cursor)."""
2
+
3
+ import os
4
+ import shutil
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from textwrap import dedent
9
+
10
+ from app.exceptions import ExecutorNotFoundError
11
+
12
+
13
+ class ExecutorType(StrEnum):
14
+ """Supported executor CLI types."""
15
+
16
+ CLAUDE = "claude" # Headless executor - can run automatically
17
+ CODEX = "codex" # Headless executor - OpenAI Codex CLI
18
+ GEMINI = "gemini" # Headless executor - Google Gemini CLI
19
+ DROID = "droid" # Headless executor - Droid CLI
20
+ QWEN = "qwen" # Headless executor - Qwen Code CLI
21
+ OPENCODE = "opencode" # Headless executor - OpenCode CLI
22
+ AMP = "amp" # Headless executor - Amp (Sourcegraph) CLI
23
+ CURSOR_AGENT = "cursor-agent" # Headless executor - Cursor Agent CLI
24
+ CURSOR = "cursor" # Interactive executor - requires human completion
25
+
26
+
27
+ class ExecutorMode(StrEnum):
28
+ """Execution mode for the executor."""
29
+
30
+ HEADLESS = "headless" # Fully automated, no human intervention
31
+ INTERACTIVE = "interactive" # Requires human to complete the work
32
+
33
+
34
+ @dataclass
35
+ class ExecutorInfo:
36
+ """Information about an available executor CLI."""
37
+
38
+ executor_type: ExecutorType
39
+ command: str
40
+ path: str
41
+
42
+ @property
43
+ def mode(self) -> ExecutorMode:
44
+ """Get the execution mode for this executor.
45
+
46
+ Claude CLI and Cursor Agent CLI support headless operation.
47
+ Cursor CLI is interactive - it opens the editor for human completion.
48
+ """
49
+ if self.executor_type in (
50
+ ExecutorType.CLAUDE,
51
+ ExecutorType.CODEX,
52
+ ExecutorType.GEMINI,
53
+ ExecutorType.DROID,
54
+ ExecutorType.QWEN,
55
+ ExecutorType.OPENCODE,
56
+ ExecutorType.AMP,
57
+ ExecutorType.CURSOR_AGENT,
58
+ ):
59
+ return ExecutorMode.HEADLESS
60
+ return ExecutorMode.INTERACTIVE
61
+
62
+ def is_headless(self) -> bool:
63
+ """Check if this executor supports headless (non-interactive) operation."""
64
+ return self.mode == ExecutorMode.HEADLESS
65
+
66
+ def is_interactive(self) -> bool:
67
+ """Check if this executor requires human interaction."""
68
+ return self.mode == ExecutorMode.INTERACTIVE
69
+
70
+ def get_apply_command(
71
+ self,
72
+ prompt_file: Path,
73
+ worktree_path: Path,
74
+ yolo_mode: bool = False,
75
+ **kwargs,
76
+ ) -> tuple[list[str], str | None]:
77
+ """
78
+ Get the command to run for applying changes.
79
+
80
+ Returns a tuple of (command_args, stdin_input). The prompt content is
81
+ passed via stdin instead of as a CLI argument to avoid exceeding
82
+ ARG_MAX (~130KB on most systems) with large prompts.
83
+
84
+ Args:
85
+ prompt_file: Path to the prompt bundle file.
86
+ worktree_path: Path to the worktree directory.
87
+ yolo_mode: If True, use --dangerously-skip-permissions (DANGEROUS).
88
+ Only use when execution is isolated and you accept the risk.
89
+
90
+ Returns:
91
+ Tuple of (command args list, stdin content or None).
92
+ """
93
+ if self.executor_type == ExecutorType.CLAUDE:
94
+ # Claude Code CLI with non-interactive mode:
95
+ # - --print: Non-interactive mode that prints response and exits
96
+ # - --dangerously-skip-permissions: ONLY if yolo_mode is enabled
97
+ # Prompt is piped via stdin to avoid ARG_MAX limits
98
+ prompt_content = prompt_file.read_text()
99
+ cmd = [self.command, "--print"]
100
+ if yolo_mode:
101
+ cmd.append("--dangerously-skip-permissions")
102
+ return cmd, prompt_content
103
+ elif self.executor_type == ExecutorType.CURSOR_AGENT:
104
+ # Cursor Agent CLI with non-interactive mode:
105
+ # - --print: Non-interactive mode that prints response and exits
106
+ # - --output-format=stream-json: Stream JSON output line-by-line for real-time logs
107
+ # - --trust: Trust the workspace directory (required for Cursor Agent to execute)
108
+ # - --force: Allow all commands without prompting (like YOLO mode)
109
+ # - --workspace: Set the working directory
110
+ # Prompt is piped via stdin to avoid ARG_MAX limits
111
+ prompt_content = prompt_file.read_text()
112
+ cmd = [
113
+ self.command,
114
+ "--print",
115
+ "--output-format=stream-json",
116
+ "--trust",
117
+ "--workspace",
118
+ str(worktree_path),
119
+ ]
120
+ if yolo_mode:
121
+ cmd.append("--force")
122
+ return cmd, prompt_content
123
+ elif self.executor_type == ExecutorType.CODEX:
124
+ # OpenAI Codex CLI with non-interactive mode:
125
+ # - --print: Non-interactive mode that prints response and exits
126
+ # - --auto-edit: Automatically apply edits to files
127
+ # - --full-auto: ONLY if yolo_mode is enabled (skip all confirmations)
128
+ # Prompt is piped via stdin to avoid ARG_MAX limits
129
+ prompt_content = prompt_file.read_text()
130
+ cmd = [self.command, "--print", "--auto-edit"]
131
+ if yolo_mode:
132
+ cmd.append("--full-auto")
133
+ return cmd, prompt_content
134
+ elif self.executor_type == ExecutorType.GEMINI:
135
+ # Google Gemini CLI with non-interactive mode:
136
+ # - --print: Non-interactive mode that prints response and exits
137
+ # - --yolo: ONLY if yolo_mode is enabled (skip all confirmations)
138
+ # Prompt is piped via stdin to avoid ARG_MAX limits
139
+ prompt_content = prompt_file.read_text()
140
+ cmd = [self.command, "--print"]
141
+ if yolo_mode:
142
+ cmd.append("--yolo")
143
+ return cmd, prompt_content
144
+ elif self.executor_type == ExecutorType.DROID:
145
+ prompt_content = prompt_file.read_text()
146
+ cmd = [self.command, "--print"]
147
+ if yolo_mode:
148
+ cmd.append("--dangerously-skip-permissions")
149
+ return cmd, prompt_content
150
+ elif self.executor_type == ExecutorType.QWEN:
151
+ prompt_content = prompt_file.read_text()
152
+ cmd = [self.command, "--print"]
153
+ if yolo_mode:
154
+ cmd.append("--yolo")
155
+ return cmd, prompt_content
156
+ elif self.executor_type == ExecutorType.OPENCODE:
157
+ prompt_content = prompt_file.read_text()
158
+ cmd = [self.command, "--print"]
159
+ if yolo_mode:
160
+ cmd.append("--yolo")
161
+ return cmd, prompt_content
162
+ elif self.executor_type == ExecutorType.AMP:
163
+ prompt_content = prompt_file.read_text()
164
+ cmd = [self.command, "--print"]
165
+ if yolo_mode:
166
+ cmd.append("--yolo")
167
+ return cmd, prompt_content
168
+ elif self.executor_type == ExecutorType.CURSOR:
169
+ # Cursor CLI is INTERACTIVE ONLY
170
+ # It opens the editor with the worktree. User must complete changes manually.
171
+ # The worker will immediately transition to needs_human.
172
+ return [self.command, str(worktree_path)], None
173
+ else:
174
+ raise ValueError(f"Unknown executor type: {self.executor_type}")
175
+
176
+
177
+ class ExecutorService:
178
+ """Service for detecting and using code executor CLIs.
179
+
180
+ Executor Types:
181
+ - Claude CLI (headless): Can run fully automated. Preferred for CI/automation.
182
+ - Codex CLI (headless): OpenAI's Codex CLI for automated code changes.
183
+ - Gemini CLI (headless): Google's Gemini CLI for automated code changes.
184
+ - Cursor Agent CLI (headless): Can run fully automated via cursor-agent.
185
+ - Cursor CLI (interactive): Opens editor for human completion. Use as handoff.
186
+
187
+ Design Decisions:
188
+ - Claude CLI is preferred for headless operation
189
+ - Codex and Gemini are alternative headless executors
190
+ - Cursor Agent CLI is another headless executor
191
+ - Cursor CLI is a fallback that prepares workspace + prompt, then hands off to user
192
+ - If only Cursor is available, caller should transition to needs_human
193
+ """
194
+
195
+ # CLI names to check in order of preference
196
+ # Claude, Codex, Gemini, and cursor-agent are preferred because they support headless operation
197
+ CLI_PREFERENCES = [
198
+ (ExecutorType.CLAUDE, "claude"),
199
+ (ExecutorType.CODEX, "codex"),
200
+ (ExecutorType.GEMINI, "gemini"),
201
+ (ExecutorType.DROID, "droid"),
202
+ (ExecutorType.QWEN, "qwen"),
203
+ (ExecutorType.OPENCODE, "opencode"),
204
+ (ExecutorType.AMP, "amp"),
205
+ (ExecutorType.CURSOR_AGENT, "cursor-agent"),
206
+ (ExecutorType.CURSOR, "cursor"),
207
+ ]
208
+
209
+ # Common paths to check for cursor-agent (not always in PATH)
210
+ CURSOR_AGENT_PATHS = [
211
+ "~/.local/bin/cursor-agent",
212
+ "/usr/local/bin/cursor-agent",
213
+ "/opt/homebrew/bin/cursor-agent",
214
+ ]
215
+
216
+ @classmethod
217
+ def _find_cursor_agent(cls, config_path: str | None = None) -> str | None:
218
+ """Find cursor-agent CLI, checking config path and common locations.
219
+
220
+ Args:
221
+ config_path: Optional custom path from config.
222
+
223
+ Returns:
224
+ Full path to cursor-agent if found, None otherwise.
225
+ """
226
+ # Check config path first
227
+ if config_path:
228
+ expanded = os.path.expanduser(config_path)
229
+ if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
230
+ return expanded
231
+
232
+ # Check common installation paths
233
+ for path in cls.CURSOR_AGENT_PATHS:
234
+ expanded = os.path.expanduser(path)
235
+ if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
236
+ return expanded
237
+
238
+ # Fall back to PATH
239
+ return shutil.which("cursor-agent")
240
+
241
+ @classmethod
242
+ def detect_executor(
243
+ cls, preferred: str | None = None, agent_path: str | None = None
244
+ ) -> ExecutorInfo:
245
+ """
246
+ Detect an available executor CLI.
247
+
248
+ Args:
249
+ preferred: Preferred executor type ("cursor" or "claude").
250
+ If specified and available, it will be used.
251
+ agent_path: Custom path for cursor-agent (from config).
252
+
253
+ Returns:
254
+ ExecutorInfo with details about the detected CLI.
255
+ IMPORTANT: Check executor_info.is_interactive() - if True, you should
256
+ transition to needs_human instead of expecting automated completion.
257
+
258
+ Raises:
259
+ ExecutorNotFoundError: If no supported CLI is found.
260
+ """
261
+ # Build ordered list of executors to check
262
+ cli_order = list(cls.CLI_PREFERENCES)
263
+
264
+ # If a preferred executor is specified, move it to the front
265
+ if preferred:
266
+ preferred_lower = preferred.lower()
267
+ for i, (exec_type, _cmd) in enumerate(cli_order):
268
+ if exec_type.value == preferred_lower:
269
+ cli_order.insert(0, cli_order.pop(i))
270
+ break
271
+
272
+ # Check each CLI in order
273
+ for exec_type, cmd in cli_order:
274
+ if exec_type == ExecutorType.CURSOR_AGENT:
275
+ # Use custom detection for cursor-agent
276
+ path = cls._find_cursor_agent(agent_path)
277
+ else:
278
+ path = shutil.which(cmd)
279
+
280
+ if path:
281
+ return ExecutorInfo(
282
+ executor_type=exec_type,
283
+ command=path, # Use full path for cursor-agent
284
+ path=path,
285
+ )
286
+
287
+ # No CLI found - raise descriptive error
288
+ raise ExecutorNotFoundError(
289
+ "No supported code executor CLI found. "
290
+ "Please install one of the following:\n"
291
+ " - Claude Code CLI (recommended): "
292
+ "https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview\n"
293
+ " - Codex CLI (OpenAI): https://github.com/openai/codex\n"
294
+ " - Gemini CLI (Google): https://github.com/google/gemini-cli\n"
295
+ " - Droid CLI: https://github.com/anthropics/droid\n"
296
+ " - Qwen Code CLI: https://github.com/QwenLM/qwen-agent\n"
297
+ " - OpenCode CLI: https://github.com/opencode-ai/opencode\n"
298
+ " - Amp CLI (Sourcegraph): https://github.com/sourcegraph/amp\n"
299
+ " - Cursor Agent CLI: Set agent_path in draft.yaml\n"
300
+ " - Cursor CLI (interactive, opens editor): https://docs.cursor.com/cli"
301
+ )
302
+
303
+ @classmethod
304
+ def detect_headless_executor(
305
+ cls, preferred: str | None = None, agent_path: str | None = None
306
+ ) -> ExecutorInfo | None:
307
+ """
308
+ Detect a headless executor CLI only.
309
+
310
+ Unlike detect_executor(), this returns None if only interactive
311
+ executors are available (instead of returning them).
312
+
313
+ Args:
314
+ preferred: Preferred executor type.
315
+ agent_path: Custom path for cursor-agent (from config).
316
+
317
+ Returns:
318
+ ExecutorInfo if a headless executor is found, None otherwise.
319
+ """
320
+ try:
321
+ executor = cls.detect_executor(preferred=preferred, agent_path=agent_path)
322
+ if executor.is_headless():
323
+ return executor
324
+ return None
325
+ except ExecutorNotFoundError:
326
+ return None
327
+
328
+ @classmethod
329
+ def is_available(cls) -> bool:
330
+ """
331
+ Check if any executor CLI is available.
332
+
333
+ Returns:
334
+ True if at least one supported CLI is available.
335
+ """
336
+ try:
337
+ cls.detect_executor()
338
+ return True
339
+ except ExecutorNotFoundError:
340
+ return False
341
+
342
+ @classmethod
343
+ def is_headless_available(cls) -> bool:
344
+ """
345
+ Check if a headless executor CLI is available.
346
+
347
+ Returns:
348
+ True if at least one headless executor is available.
349
+ """
350
+ return cls.detect_headless_executor() is not None
351
+
352
+
353
+ class PromptBundleBuilder:
354
+ """Builder for creating prompt bundles for code execution."""
355
+
356
+ PROMPT_FILENAME = "prompt.txt"
357
+
358
+ def __init__(self, worktree_path: Path, job_id: str, repo_root: Path | None = None):
359
+ """
360
+ Initialize the prompt bundle builder.
361
+
362
+ Args:
363
+ worktree_path: Path to the worktree directory.
364
+ job_id: UUID of the job.
365
+ repo_root: Path to the main repo root (for persistent evidence storage).
366
+ """
367
+ from app.data_dir import get_jobs_dir
368
+
369
+ self.worktree_path = worktree_path
370
+ self.job_id = job_id
371
+ self.repo_root = repo_root
372
+ self.job_dir = get_jobs_dir(job_id)
373
+
374
+ @property
375
+ def prompt_file(self) -> Path:
376
+ """Get the path to the prompt file."""
377
+ return self.job_dir / self.PROMPT_FILENAME
378
+
379
+ def build_prompt(
380
+ self,
381
+ ticket_title: str,
382
+ ticket_description: str | None,
383
+ additional_context: str | None = None,
384
+ feedback_bundle: dict | None = None,
385
+ related_tickets_context: dict | None = None,
386
+ verify_commands: list[str] | None = None,
387
+ ) -> Path:
388
+ """
389
+ Build a prompt bundle file for the executor CLI.
390
+
391
+ Args:
392
+ ticket_title: Title of the ticket.
393
+ ticket_description: Description of the ticket (may be None).
394
+ additional_context: Optional additional context to include.
395
+ feedback_bundle: Optional feedback from previous revision review.
396
+ related_tickets_context: Optional context about related tickets and dependencies.
397
+ Expected format: {
398
+ "dependencies": [{"title": str, "state": str}], # tickets this depends on
399
+ "completed_tickets": [{"title": str, "description": str}], # already done
400
+ "goal_title": str # optional goal title
401
+ }
402
+ verify_commands: Current verification commands from draft.yaml.
403
+
404
+ Returns:
405
+ Path to the created prompt file.
406
+ """
407
+ # Ensure the job directory exists
408
+ self.job_dir.mkdir(parents=True, exist_ok=True)
409
+
410
+ # Build the prompt content
411
+ prompt_content = self._generate_prompt_content(
412
+ ticket_title=ticket_title,
413
+ ticket_description=ticket_description,
414
+ additional_context=additional_context,
415
+ feedback_bundle=feedback_bundle,
416
+ related_tickets_context=related_tickets_context,
417
+ verify_commands=verify_commands,
418
+ )
419
+
420
+ # Write the prompt file
421
+ self.prompt_file.write_text(prompt_content)
422
+
423
+ return self.prompt_file
424
+
425
+ def _generate_prompt_content(
426
+ self,
427
+ ticket_title: str,
428
+ ticket_description: str | None,
429
+ additional_context: str | None = None,
430
+ feedback_bundle: dict | None = None,
431
+ related_tickets_context: dict | None = None,
432
+ verify_commands: list[str] | None = None,
433
+ ) -> str:
434
+ """
435
+ Generate the content for the prompt bundle.
436
+
437
+ Args:
438
+ ticket_title: Title of the ticket.
439
+ ticket_description: Description of the ticket.
440
+ additional_context: Optional additional context.
441
+ feedback_bundle: Optional feedback from previous revision review.
442
+ related_tickets_context: Optional context about related tickets.
443
+ verify_commands: Current verification commands from draft.yaml.
444
+
445
+ Returns:
446
+ Formatted prompt string.
447
+ """
448
+ description_text = ticket_description or "No additional description provided."
449
+
450
+ prompt = dedent(f"""\
451
+ # Task: {ticket_title}
452
+
453
+ ## Description
454
+
455
+ {description_text}
456
+ """)
457
+
458
+ # Add related tickets context if provided
459
+ if related_tickets_context:
460
+ prompt += self._format_related_tickets_section(related_tickets_context)
461
+
462
+ prompt += dedent("""\
463
+ ## Constraints
464
+
465
+ - **CRITICAL**: Analyze the codebase structure FIRST before making changes
466
+ - If the ticket mentions specific file paths (e.g., `app/utils/file.py`), check if that structure exists
467
+ - Adapt paths to match the ACTUAL project structure (don't blindly create new directories)
468
+ - If paths don't match reality, use the existing structure instead
469
+ - Make minimal, focused changes to accomplish the task
470
+ - Do NOT modify files that are unrelated to this task
471
+ - Preserve existing code style and conventions
472
+ - Do NOT introduce unnecessary dependencies
473
+ - Keep changes atomic and reviewable
474
+ """)
475
+
476
+ # Add revision feedback if present
477
+ if feedback_bundle:
478
+ prompt += self._format_feedback_section(feedback_bundle)
479
+
480
+ prompt += dedent("""\
481
+ ## Completion Criteria
482
+
483
+ - Code compiles without errors
484
+ - Tests pass (if applicable)
485
+ - Changes are minimal and focused on the task
486
+ - No unrelated modifications
487
+ """)
488
+
489
+ if feedback_bundle:
490
+ prompt += " - All review feedback has been addressed\n"
491
+
492
+ prompt += dedent("""\
493
+ ## Instructions
494
+
495
+ 1. **First, explore the codebase** to understand:
496
+ - The current directory structure
497
+ - Naming conventions and patterns
498
+ - Where similar functionality already exists
499
+ - Dependencies and existing modules
500
+
501
+ 2. **Validate the approach**:
502
+ - If the ticket mentions specific paths, verify they match the actual structure
503
+ - If paths don't exist, decide: create them OR adapt to existing structure
504
+ - Choose the approach that's most consistent with the codebase
505
+
506
+ 3. **Implement the changes** described in the task
507
+
508
+ 4. **Provide a summary** explaining:
509
+ - What files were modified or created
510
+ - What changes were made
511
+ - Why each change was necessary
512
+ - Any path adaptations you made from the ticket description
513
+ """)
514
+
515
+ # Add verification scoping instructions
516
+ if verify_commands:
517
+ commands_str = "\n".join(f" - `{cmd}`" for cmd in verify_commands)
518
+ prompt += dedent(f"""\
519
+ ## Verification Setup
520
+
521
+ After implementing your changes, the following verification commands will run:
522
+ {commands_str}
523
+
524
+ **IMPORTANT**: If the verification commands above run a broad test suite (e.g., the
525
+ entire test file), you MUST update `draft.yaml` in this worktree to scope the
526
+ `verify_config.commands` to ONLY the tests relevant to your changes. This prevents
527
+ unrelated test failures from blocking your ticket.
528
+
529
+ For example, if you fixed `fibonacci` and `is_prime`, update the verify config to:
530
+ ```yaml
531
+ verify_config:
532
+ commands:
533
+ - "python -m pytest -q test_calculator.py::TestFibonacci test_calculator.py::TestIsPrime"
534
+ ```
535
+
536
+ Scope the verify commands to the test classes/functions that cover your changes.
537
+ """)
538
+
539
+ if additional_context:
540
+ prompt += f"\n## Additional Context\n\n{additional_context}\n"
541
+
542
+ return prompt
543
+
544
+ def _format_related_tickets_section(self, related_tickets_context: dict) -> str:
545
+ """
546
+ Format related tickets context as a prompt section.
547
+
548
+ Args:
549
+ related_tickets_context: Dictionary with dependencies and completed tickets.
550
+
551
+ Returns:
552
+ Formatted related tickets section for the prompt.
553
+ """
554
+ section = "\n## Related Tickets Context\n\n"
555
+
556
+ goal_title = related_tickets_context.get("goal_title")
557
+ if goal_title:
558
+ section += f"**Goal**: {goal_title}\n\n"
559
+
560
+ # Add completed tickets for context
561
+ completed_tickets = related_tickets_context.get("completed_tickets", [])
562
+ if completed_tickets:
563
+ section += "### Previously Completed Tickets\n\n"
564
+ section += "These tickets in the same goal have already been completed:\n\n"
565
+ for ticket in completed_tickets:
566
+ section += f"- **{ticket['title']}**"
567
+ if ticket.get("description"):
568
+ # Truncate long descriptions
569
+ desc = ticket["description"]
570
+ if len(desc) > 150:
571
+ desc = desc[:150] + "..."
572
+ section += f": {desc}"
573
+ section += "\n"
574
+ section += "\n**Important**: Build upon this existing work. Don't recreate what's already done.\n\n"
575
+
576
+ # Add dependency information
577
+ dependencies = related_tickets_context.get("dependencies", [])
578
+ if dependencies:
579
+ section += "### Dependencies\n\n"
580
+ section += (
581
+ "This ticket depends on the following tickets being completed:\n\n"
582
+ )
583
+ for dep in dependencies:
584
+ section += f"- **{dep['title']}** (Status: {dep['state']})\n"
585
+ section += "\n**Note**: You can assume dependencies are complete and build upon their work.\n\n"
586
+
587
+ return section
588
+
589
+ def _format_feedback_section(self, feedback_bundle: dict) -> str:
590
+ """
591
+ Format the feedback bundle as a prompt section.
592
+
593
+ Args:
594
+ feedback_bundle: The feedback bundle dict.
595
+
596
+ Returns:
597
+ Formatted feedback section for the prompt.
598
+ """
599
+ section = "\n## Previous Revision Feedback\n\n"
600
+ section += f"**Revision #{feedback_bundle.get('revision_number', '?')} was reviewed and changes were requested.**\n\n"
601
+
602
+ # Add overall summary
603
+ summary = feedback_bundle.get("summary", "")
604
+ if summary:
605
+ section += f"### Reviewer Summary\n\n{summary}\n\n"
606
+
607
+ # Add inline comments
608
+ comments = feedback_bundle.get("comments", [])
609
+ if comments:
610
+ section += "### Inline Comments to Address\n\n"
611
+ for comment in comments:
612
+ file_path = comment.get("file_path", "unknown")
613
+ line_number = comment.get("line_number", "?")
614
+ body = comment.get("body", "")
615
+ line_content = comment.get("line_content", "")
616
+ section += f"- **{file_path}:{line_number}**: {body}\n"
617
+ if line_content:
618
+ section += f" - Line content: `{line_content}`\n"
619
+ section += "\n"
620
+
621
+ section += "**Important**: Address ALL feedback above while preserving correct changes from the previous revision.\n\n"
622
+
623
+ return section
624
+
625
+ def get_evidence_dir(self) -> Path:
626
+ """
627
+ Get the evidence directory for this job (central location).
628
+
629
+ Returns:
630
+ Path to the evidence directory.
631
+ """
632
+ from app.data_dir import get_evidence_dir as _get_evidence_dir
633
+
634
+ return _get_evidence_dir(self.job_id)
@@ -0,0 +1,11 @@
1
+ """Git host abstraction layer for GitHub and GitLab."""
2
+
3
+ from app.services.git_host.factory import detect_git_host, get_git_host_provider
4
+ from app.services.git_host.protocol import GitHostProvider, PullRequest
5
+
6
+ __all__ = [
7
+ "GitHostProvider",
8
+ "PullRequest",
9
+ "get_git_host_provider",
10
+ "detect_git_host",
11
+ ]