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,1022 @@
1
+ """Task management for CodeFRAME v2.
2
+
3
+ Handles task CRUD operations, status transitions, and task generation from PRD.
4
+
5
+ This module is headless - no FastAPI or HTTP dependencies.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import re
12
+ import threading
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from typing import Optional
17
+ from urllib.parse import urlparse
18
+
19
+ from codeframe.core.state_machine import TaskStatus, validate_transition
20
+ from codeframe.core.workspace import Workspace, get_db_connection
21
+ from codeframe.core.prd import PrdRecord
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def _utc_now() -> datetime:
27
+ """Get current UTC time as timezone-aware datetime."""
28
+ return datetime.now(timezone.utc)
29
+
30
+
31
+ @dataclass
32
+ class Task:
33
+ """Represents a task in the workspace.
34
+
35
+ Attributes:
36
+ id: Unique task identifier (UUID)
37
+ workspace_id: Workspace this task belongs to
38
+ prd_id: Optional PRD this task was generated from
39
+ title: Task title/summary
40
+ description: Detailed task description
41
+ status: Current task status (from state machine)
42
+ priority: Task priority (0 = highest)
43
+ created_at: When the task was created
44
+ updated_at: When the task was last modified
45
+ depends_on: List of task IDs this task depends on (default: empty)
46
+ estimated_hours: Estimated hours to complete the task (optional)
47
+ complexity_score: Complexity rating 1-5 (optional)
48
+ uncertainty_level: Uncertainty level: 'low', 'medium', 'high' (optional)
49
+ """
50
+
51
+ id: str
52
+ workspace_id: str
53
+ prd_id: Optional[str]
54
+ title: str
55
+ description: str
56
+ status: TaskStatus
57
+ priority: int
58
+ created_at: datetime
59
+ updated_at: datetime
60
+ depends_on: list[str] = field(default_factory=list)
61
+ estimated_hours: Optional[float] = None
62
+ complexity_score: Optional[int] = None
63
+ uncertainty_level: Optional[str] = None
64
+ github_issue_number: Optional[int] = None
65
+ parent_id: Optional[str] = None
66
+ lineage: list[str] = field(default_factory=list)
67
+ is_leaf: bool = True
68
+ hierarchical_id: Optional[str] = None
69
+ requirement_ids: list[str] = field(default_factory=list)
70
+ external_url: Optional[str] = None
71
+ auto_close_github_issue: bool = False
72
+
73
+
74
+ def create(
75
+ workspace: Workspace,
76
+ title: str,
77
+ description: str = "",
78
+ status: TaskStatus = TaskStatus.BACKLOG,
79
+ priority: int = 0,
80
+ prd_id: Optional[str] = None,
81
+ depends_on: Optional[list[str]] = None,
82
+ estimated_hours: Optional[float] = None,
83
+ complexity_score: Optional[int] = None,
84
+ uncertainty_level: Optional[str] = None,
85
+ parent_id: Optional[str] = None,
86
+ lineage: Optional[list[str]] = None,
87
+ is_leaf: bool = True,
88
+ hierarchical_id: Optional[str] = None,
89
+ requirement_ids: Optional[list[str]] = None,
90
+ github_issue_number: Optional[int] = None,
91
+ external_url: Optional[str] = None,
92
+ auto_close_github_issue: bool = False,
93
+ ) -> Task:
94
+ """Create a new task.
95
+
96
+ Args:
97
+ workspace: Target workspace
98
+ title: Task title
99
+ description: Task description
100
+ status: Initial status (default BACKLOG)
101
+ priority: Task priority (default 0)
102
+ prd_id: Optional source PRD ID
103
+ depends_on: Optional list of task IDs this task depends on
104
+ estimated_hours: Optional time estimate in hours
105
+ complexity_score: Optional complexity rating 1-5
106
+ uncertainty_level: Optional uncertainty level ('low', 'medium', 'high')
107
+ parent_id: Optional parent task ID for tree structure
108
+ lineage: Optional list of ancestor descriptions
109
+ is_leaf: Whether this is a leaf/executable task (default True)
110
+ hierarchical_id: Optional display ID like "1.2.3"
111
+ requirement_ids: Optional list of PROOF9 requirement IDs this task implements
112
+
113
+ Returns:
114
+ Created Task
115
+ """
116
+ task_id = str(uuid.uuid4())
117
+ now = _utc_now().isoformat()
118
+ depends_on_list = depends_on or []
119
+ lineage_list = lineage or []
120
+ requirement_ids_list = requirement_ids or []
121
+
122
+ conn = get_db_connection(workspace)
123
+ try:
124
+ cursor = conn.cursor()
125
+ cursor.execute(
126
+ """
127
+ INSERT INTO tasks (id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, parent_id, lineage, is_leaf, hierarchical_id, created_at, updated_at, requirement_ids, github_issue_number, external_url, auto_close_github_issue)
128
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
129
+ """,
130
+ (task_id, workspace.id, prd_id, title, description, status.value, priority, json.dumps(depends_on_list), estimated_hours, complexity_score, uncertainty_level, parent_id, json.dumps(lineage_list), 1 if is_leaf else 0, hierarchical_id, now, now, json.dumps(requirement_ids_list), github_issue_number, external_url, 1 if auto_close_github_issue else 0),
131
+ )
132
+ conn.commit()
133
+ finally:
134
+ conn.close()
135
+
136
+ return Task(
137
+ id=task_id,
138
+ workspace_id=workspace.id,
139
+ prd_id=prd_id,
140
+ title=title,
141
+ description=description,
142
+ status=status,
143
+ priority=priority,
144
+ depends_on=depends_on_list,
145
+ estimated_hours=estimated_hours,
146
+ complexity_score=complexity_score,
147
+ uncertainty_level=uncertainty_level,
148
+ parent_id=parent_id,
149
+ lineage=lineage_list,
150
+ is_leaf=is_leaf,
151
+ hierarchical_id=hierarchical_id,
152
+ requirement_ids=requirement_ids_list,
153
+ github_issue_number=github_issue_number,
154
+ external_url=external_url,
155
+ auto_close_github_issue=auto_close_github_issue,
156
+ created_at=datetime.fromisoformat(now),
157
+ updated_at=datetime.fromisoformat(now),
158
+ )
159
+
160
+
161
+ def get(workspace: Workspace, task_id: str) -> Optional[Task]:
162
+ """Get a task by ID.
163
+
164
+ Args:
165
+ workspace: Workspace to query
166
+ task_id: Task identifier
167
+
168
+ Returns:
169
+ Task if found, None otherwise
170
+ """
171
+ conn = get_db_connection(workspace)
172
+ cursor = conn.cursor()
173
+
174
+ cursor.execute(
175
+ """
176
+ SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids, external_url, auto_close_github_issue
177
+ FROM tasks
178
+ WHERE workspace_id = ? AND id = ?
179
+ """,
180
+ (workspace.id, task_id),
181
+ )
182
+ row = cursor.fetchone()
183
+ conn.close()
184
+
185
+ if not row:
186
+ return None
187
+
188
+ return _row_to_task(row)
189
+
190
+
191
+ def get_by_external_url(
192
+ workspace: Workspace, external_url: str
193
+ ) -> Optional[Task]:
194
+ """Get a task previously imported from a given external (issue) URL.
195
+
196
+ Used for duplicate-import protection (issue #565). Keying on the full issue
197
+ URL — not just the issue number — keeps de-duplication correct when a
198
+ workspace is reconnected to a different repository (where the same issue
199
+ number refers to a different issue).
200
+
201
+ Args:
202
+ workspace: Workspace to query
203
+ external_url: The issue's ``html_url`` to look up
204
+
205
+ Returns:
206
+ The matching Task if one exists, otherwise None
207
+ """
208
+ conn = get_db_connection(workspace)
209
+ try:
210
+ cursor = conn.cursor()
211
+ cursor.execute(
212
+ """
213
+ SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids, external_url, auto_close_github_issue
214
+ FROM tasks
215
+ WHERE workspace_id = ? AND external_url = ?
216
+ LIMIT 1
217
+ """,
218
+ (workspace.id, external_url),
219
+ )
220
+ row = cursor.fetchone()
221
+ finally:
222
+ conn.close()
223
+
224
+ if not row:
225
+ return None
226
+
227
+ return _row_to_task(row)
228
+
229
+
230
+ def update_auto_close(
231
+ workspace: Workspace,
232
+ task_id: str,
233
+ auto_close: bool,
234
+ ) -> Task:
235
+ """Update whether the linked GitHub issue should close when the task is DONE.
236
+
237
+ Args:
238
+ workspace: Target workspace
239
+ task_id: Task to update
240
+ auto_close: New auto-close setting
241
+
242
+ Returns:
243
+ Updated Task
244
+
245
+ Raises:
246
+ ValueError: If task not found
247
+ """
248
+ task = get(workspace, task_id)
249
+ if not task:
250
+ raise ValueError(f"Task not found: {task_id}")
251
+
252
+ now = _utc_now().isoformat()
253
+
254
+ conn = get_db_connection(workspace)
255
+ try:
256
+ cursor = conn.cursor()
257
+ cursor.execute(
258
+ """
259
+ UPDATE tasks
260
+ SET auto_close_github_issue = ?, updated_at = ?
261
+ WHERE workspace_id = ? AND id = ?
262
+ """,
263
+ (1 if auto_close else 0, now, workspace.id, task_id),
264
+ )
265
+ conn.commit()
266
+ finally:
267
+ conn.close()
268
+
269
+ task.auto_close_github_issue = auto_close
270
+ task.updated_at = datetime.fromisoformat(now)
271
+
272
+ return task
273
+
274
+
275
+ def list_tasks(
276
+ workspace: Workspace,
277
+ status: Optional[TaskStatus] = None,
278
+ limit: int = 100,
279
+ ) -> list[Task]:
280
+ """List tasks in a workspace.
281
+
282
+ Args:
283
+ workspace: Workspace to query
284
+ status: Optional status filter
285
+ limit: Maximum tasks to return
286
+
287
+ Returns:
288
+ List of Tasks
289
+ """
290
+ conn = get_db_connection(workspace)
291
+ cursor = conn.cursor()
292
+
293
+ if status:
294
+ cursor.execute(
295
+ """
296
+ SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids, external_url, auto_close_github_issue
297
+ FROM tasks
298
+ WHERE workspace_id = ? AND status = ?
299
+ ORDER BY priority ASC, created_at ASC
300
+ LIMIT ?
301
+ """,
302
+ (workspace.id, status.value, limit),
303
+ )
304
+ else:
305
+ cursor.execute(
306
+ """
307
+ SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids, external_url, auto_close_github_issue
308
+ FROM tasks
309
+ WHERE workspace_id = ?
310
+ ORDER BY priority ASC, created_at ASC
311
+ LIMIT ?
312
+ """,
313
+ (workspace.id, limit),
314
+ )
315
+
316
+ rows = cursor.fetchall()
317
+ conn.close()
318
+
319
+ return [_row_to_task(row) for row in rows]
320
+
321
+
322
+ def list_by_status(workspace: Workspace) -> dict[TaskStatus, list[Task]]:
323
+ """List tasks grouped by status.
324
+
325
+ Args:
326
+ workspace: Workspace to query
327
+
328
+ Returns:
329
+ Dict mapping status to list of tasks
330
+ """
331
+ all_tasks = list_tasks(workspace)
332
+ result: dict[TaskStatus, list[Task]] = {status: [] for status in TaskStatus}
333
+
334
+ for task in all_tasks:
335
+ result[task.status].append(task)
336
+
337
+ return result
338
+
339
+
340
+ def update_status(
341
+ workspace: Workspace,
342
+ task_id: str,
343
+ new_status: TaskStatus,
344
+ ) -> Task:
345
+ """Update a task's status.
346
+
347
+ Validates the transition against the state machine.
348
+
349
+ Args:
350
+ workspace: Target workspace
351
+ task_id: Task to update
352
+ new_status: New status
353
+
354
+ Returns:
355
+ Updated Task
356
+
357
+ Raises:
358
+ ValueError: If task not found
359
+ InvalidTransitionError: If transition not allowed
360
+ """
361
+ task = get(workspace, task_id)
362
+ if not task:
363
+ raise ValueError(f"Task not found: {task_id}")
364
+
365
+ # Validate transition
366
+ validate_transition(task.status, new_status)
367
+
368
+ now = _utc_now().isoformat()
369
+
370
+ conn = get_db_connection(workspace)
371
+ try:
372
+ cursor = conn.cursor()
373
+ cursor.execute(
374
+ """
375
+ UPDATE tasks
376
+ SET status = ?, updated_at = ?
377
+ WHERE workspace_id = ? AND id = ?
378
+ """,
379
+ (new_status.value, now, workspace.id, task_id),
380
+ )
381
+ conn.commit()
382
+ finally:
383
+ conn.close()
384
+
385
+ task.status = new_status
386
+ task.updated_at = datetime.fromisoformat(now)
387
+
388
+ # On completion, best-effort close the linked GitHub issue when opted in
389
+ # (issue #565). Placed here — the single chokepoint every DONE transition
390
+ # flows through (HTTP, CLI, agent/batch via runtime.complete_run) — so the
391
+ # behavior is consistent regardless of how the task was completed.
392
+ if new_status == TaskStatus.DONE:
393
+ _dispatch_github_autoclose(workspace, task)
394
+
395
+ return task
396
+
397
+
398
+ def _repo_from_issue_url(url: Optional[str]) -> Optional[str]:
399
+ """Extract ``owner/repo`` from a GitHub issue ``html_url``.
400
+
401
+ e.g. ``https://github.com/acme/app/issues/12`` -> ``"acme/app"``. Returns
402
+ ``None`` when the URL is missing or not in the expected issue-URL shape.
403
+ """
404
+ if not url:
405
+ return None
406
+ try:
407
+ parts = urlparse(url).path.strip("/").split("/")
408
+ except (ValueError, AttributeError):
409
+ return None
410
+ # .../{owner}/{repo}/issues/{number}
411
+ if len(parts) >= 4 and parts[-2] == "issues":
412
+ return f"{parts[-4]}/{parts[-3]}"
413
+ return None
414
+
415
+
416
+ def autoclose_if_done(workspace: Workspace, task: Task) -> None:
417
+ """Fire the GitHub auto-close when opting in on an already-DONE task (#565).
418
+
419
+ The normal path closes the issue on the DONE *transition*. But a user can
420
+ enable the auto-close toggle on a task that is already DONE — in which case
421
+ no transition occurs, so this is called explicitly to honor the intent.
422
+ Best-effort and fully guarded (no-op unless DONE + opted in + linked).
423
+ """
424
+ if task.status == TaskStatus.DONE:
425
+ _dispatch_github_autoclose(workspace, task)
426
+
427
+
428
+ def _dispatch_github_autoclose(workspace: Workspace, task: Task) -> None:
429
+ """Best-effort close of the linked GitHub issue when a task is DONE (#565).
430
+
431
+ Mirrors the outbound-webhook dispatch pattern (``blockers._dispatch_*``):
432
+ fully guarded so a missing connection or any GitHub error never affects the
433
+ task transition. The repo is taken from the task's own ``external_url`` (its
434
+ source repo) — NOT the workspace's current connection — so completing an
435
+ older imported task always closes the right issue even after the workspace
436
+ is reconnected to a different repository. The PAT comes from the machine-wide
437
+ credential store.
438
+ """
439
+ if not task.auto_close_github_issue or task.github_issue_number is None:
440
+ return
441
+ repo = _repo_from_issue_url(task.external_url)
442
+ if repo is None:
443
+ logger.info(
444
+ "Skipping GitHub auto-close for issue #%s: no source repo on task.",
445
+ task.github_issue_number,
446
+ )
447
+ return
448
+ try:
449
+ from codeframe.core.credentials import CredentialManager, CredentialProvider
450
+
451
+ pat = CredentialManager().get_credential(CredentialProvider.GIT_GITHUB)
452
+ if not pat:
453
+ logger.info(
454
+ "Skipping GitHub auto-close for issue #%s: no stored PAT.",
455
+ task.github_issue_number,
456
+ )
457
+ return
458
+ _close_issue_background(pat, repo, task.github_issue_number)
459
+ except Exception: # noqa: BLE001 - must never break the task transition
460
+ logger.warning(
461
+ "Failed to dispatch GitHub auto-close for issue #%s",
462
+ task.github_issue_number,
463
+ exc_info=True,
464
+ )
465
+
466
+
467
+ # Bounded timeout for the auto-close call so a hung GitHub request never stalls
468
+ # a short-lived CLI process for long at exit.
469
+ _AUTOCLOSE_TIMEOUT = 10.0
470
+
471
+
472
+ async def _safe_close_issue(pat: str, repo: str, issue_number: int) -> None:
473
+ """Close the issue, swallowing every error (best-effort, off the hot path)."""
474
+ try:
475
+ from codeframe.core.github_issues_service import close_issue
476
+
477
+ await close_issue(
478
+ pat,
479
+ repo,
480
+ issue_number,
481
+ comment="Completed via CodeFRAME",
482
+ timeout=_AUTOCLOSE_TIMEOUT,
483
+ )
484
+ except Exception: # noqa: BLE001 - background best-effort
485
+ logger.warning(
486
+ "GitHub auto-close of issue #%s failed", issue_number, exc_info=True
487
+ )
488
+
489
+
490
+ def _close_issue_background(pat: str, repo: str, issue_number: int) -> None:
491
+ """Run the auto-close off the caller's path (#565).
492
+
493
+ Context-aware, mirroring ``WebhookNotificationService.send_event_background``:
494
+
495
+ * **Async (server)**: there is a running event loop (the FastAPI handler),
496
+ so schedule the close on it and return immediately. No thread is created,
497
+ so nothing is left to join at process shutdown.
498
+ * **Sync (CLI / agent worker thread)**: no running loop, so run the close to
499
+ completion on a **non-daemon** thread. Unlike a fire-and-forget
500
+ notification, leaving the issue open is a real failure, so a short-lived
501
+ CLI process waits for the close at interpreter exit (bounded by
502
+ ``_AUTOCLOSE_TIMEOUT``) instead of abandoning it.
503
+ """
504
+ try:
505
+ loop = asyncio.get_running_loop()
506
+ except RuntimeError:
507
+ threading.Thread(
508
+ target=lambda: asyncio.run(
509
+ _safe_close_issue(pat, repo, issue_number)
510
+ ),
511
+ daemon=False,
512
+ name=f"gh-autoclose-{issue_number}",
513
+ ).start()
514
+ else:
515
+ loop.create_task(_safe_close_issue(pat, repo, issue_number))
516
+
517
+
518
+ def update(
519
+ workspace: Workspace,
520
+ task_id: str,
521
+ title: Optional[str] = None,
522
+ description: Optional[str] = None,
523
+ priority: Optional[int] = None,
524
+ ) -> Task:
525
+ """Update a task's title, description, or priority.
526
+
527
+ Only provided fields are updated; others are left unchanged.
528
+
529
+ Args:
530
+ workspace: Target workspace
531
+ task_id: Task to update
532
+ title: New title (optional)
533
+ description: New description (optional)
534
+ priority: New priority (optional)
535
+
536
+ Returns:
537
+ Updated Task
538
+
539
+ Raises:
540
+ ValueError: If task not found
541
+ """
542
+ task = get(workspace, task_id)
543
+ if not task:
544
+ raise ValueError(f"Task not found: {task_id}")
545
+
546
+ # Build update query dynamically
547
+ updates = []
548
+ params = []
549
+
550
+ if title is not None:
551
+ updates.append("title = ?")
552
+ params.append(title)
553
+ task.title = title
554
+
555
+ if description is not None:
556
+ updates.append("description = ?")
557
+ params.append(description)
558
+ task.description = description
559
+
560
+ if priority is not None:
561
+ updates.append("priority = ?")
562
+ params.append(priority)
563
+ task.priority = priority
564
+
565
+ if not updates:
566
+ # Nothing to update
567
+ return task
568
+
569
+ now = _utc_now().isoformat()
570
+ updates.append("updated_at = ?")
571
+ params.append(now)
572
+
573
+ # Add WHERE clause params
574
+ params.extend([workspace.id, task_id])
575
+
576
+ conn = get_db_connection(workspace)
577
+ try:
578
+ cursor = conn.cursor()
579
+ cursor.execute(
580
+ f"""
581
+ UPDATE tasks
582
+ SET {', '.join(updates)}
583
+ WHERE workspace_id = ? AND id = ?
584
+ """,
585
+ params,
586
+ )
587
+ conn.commit()
588
+ finally:
589
+ conn.close()
590
+
591
+ task.updated_at = datetime.fromisoformat(now)
592
+
593
+ return task
594
+
595
+
596
+ def update_depends_on(
597
+ workspace: Workspace,
598
+ task_id: str,
599
+ depends_on: list[str],
600
+ ) -> Task:
601
+ """Update a task's dependencies.
602
+
603
+ Args:
604
+ workspace: Target workspace
605
+ task_id: Task to update
606
+ depends_on: List of task IDs this task depends on
607
+
608
+ Returns:
609
+ Updated Task
610
+
611
+ Raises:
612
+ ValueError: If task not found or circular dependency detected
613
+ """
614
+ task = get(workspace, task_id)
615
+ if not task:
616
+ raise ValueError(f"Task not found: {task_id}")
617
+
618
+ # Validate dependencies exist and check for self-reference
619
+ for dep_id in depends_on:
620
+ if dep_id == task_id:
621
+ raise ValueError(f"Task cannot depend on itself: {task_id}")
622
+ dep_task = get(workspace, dep_id)
623
+ if not dep_task:
624
+ raise ValueError(f"Dependency task not found: {dep_id}")
625
+
626
+ now = _utc_now().isoformat()
627
+
628
+ conn = get_db_connection(workspace)
629
+ cursor = conn.cursor()
630
+
631
+ cursor.execute(
632
+ """
633
+ UPDATE tasks
634
+ SET depends_on = ?, updated_at = ?
635
+ WHERE workspace_id = ? AND id = ?
636
+ """,
637
+ (json.dumps(depends_on), now, workspace.id, task_id),
638
+ )
639
+ conn.commit()
640
+ conn.close()
641
+
642
+ task.depends_on = depends_on
643
+ task.updated_at = datetime.fromisoformat(now)
644
+
645
+ return task
646
+
647
+
648
+ def update_requirement_ids(
649
+ workspace: Workspace,
650
+ task_id: str,
651
+ requirement_ids: list[str],
652
+ ) -> Task:
653
+ """Update a task's linked PROOF9 requirement IDs.
654
+
655
+ Args:
656
+ workspace: Target workspace
657
+ task_id: Task to update
658
+ requirement_ids: List of PROOF9 requirement IDs this task implements
659
+
660
+ Returns:
661
+ Updated Task
662
+
663
+ Raises:
664
+ ValueError: If task not found
665
+ """
666
+ task = get(workspace, task_id)
667
+ if not task:
668
+ raise ValueError(f"Task not found: {task_id}")
669
+
670
+ now = _utc_now().isoformat()
671
+
672
+ conn = get_db_connection(workspace)
673
+ cursor = conn.cursor()
674
+ cursor.execute(
675
+ """
676
+ UPDATE tasks
677
+ SET requirement_ids = ?, updated_at = ?
678
+ WHERE workspace_id = ? AND id = ?
679
+ """,
680
+ (json.dumps(requirement_ids), now, workspace.id, task_id),
681
+ )
682
+ conn.commit()
683
+ conn.close()
684
+
685
+ task.requirement_ids = requirement_ids
686
+ task.updated_at = datetime.fromisoformat(now)
687
+
688
+ return task
689
+
690
+
691
+ def get_dependents(workspace: Workspace, task_id: str) -> list[Task]:
692
+ """Get all tasks that depend on the given task.
693
+
694
+ Args:
695
+ workspace: Workspace to query
696
+ task_id: Task ID to find dependents for
697
+
698
+ Returns:
699
+ List of Tasks that have task_id in their depends_on list
700
+ """
701
+ all_tasks = list_tasks(workspace)
702
+ return [t for t in all_tasks if task_id in t.depends_on]
703
+
704
+
705
+ def delete(workspace: Workspace, task_id: str) -> bool:
706
+ """Delete a task by ID.
707
+
708
+ Args:
709
+ workspace: Target workspace
710
+ task_id: Task ID to delete
711
+
712
+ Returns:
713
+ True if task was deleted, False if not found
714
+
715
+ Note:
716
+ This does NOT remove the task from other tasks' depends_on lists.
717
+ Use delete_cascade() if you need to clean up dependencies.
718
+ """
719
+ conn = get_db_connection(workspace)
720
+ cursor = conn.cursor()
721
+
722
+ cursor.execute(
723
+ """
724
+ DELETE FROM tasks
725
+ WHERE workspace_id = ? AND id = ?
726
+ """,
727
+ (workspace.id, task_id),
728
+ )
729
+ deleted = cursor.rowcount > 0
730
+ conn.commit()
731
+ conn.close()
732
+
733
+ return deleted
734
+
735
+
736
+ def delete_all(workspace: Workspace) -> int:
737
+ """Delete all tasks in a workspace.
738
+
739
+ Args:
740
+ workspace: Target workspace
741
+
742
+ Returns:
743
+ Number of tasks deleted
744
+ """
745
+ conn = get_db_connection(workspace)
746
+ cursor = conn.cursor()
747
+
748
+ cursor.execute(
749
+ """
750
+ DELETE FROM tasks
751
+ WHERE workspace_id = ?
752
+ """,
753
+ (workspace.id,),
754
+ )
755
+ deleted_count = cursor.rowcount
756
+ conn.commit()
757
+ conn.close()
758
+
759
+ return deleted_count
760
+
761
+
762
+ def count_by_status(workspace: Workspace) -> dict[str, int]:
763
+ """Count tasks by status.
764
+
765
+ Args:
766
+ workspace: Workspace to query
767
+
768
+ Returns:
769
+ Dict mapping status string to count
770
+ """
771
+ conn = get_db_connection(workspace)
772
+ cursor = conn.cursor()
773
+
774
+ cursor.execute(
775
+ """
776
+ SELECT status, COUNT(*) as count
777
+ FROM tasks
778
+ WHERE workspace_id = ?
779
+ GROUP BY status
780
+ """,
781
+ (workspace.id,),
782
+ )
783
+ rows = cursor.fetchall()
784
+ conn.close()
785
+
786
+ return {row[0]: row[1] for row in rows}
787
+
788
+
789
+ def generate_from_prd(
790
+ workspace: Workspace,
791
+ prd: PrdRecord,
792
+ use_llm: bool = True,
793
+ ) -> list[Task]:
794
+ """Generate tasks from a PRD.
795
+
796
+ Uses LLM to decompose the PRD into actionable tasks.
797
+ Falls back to simple extraction if LLM is unavailable.
798
+
799
+ Args:
800
+ workspace: Target workspace
801
+ prd: PRD to generate tasks from
802
+ use_llm: Whether to use LLM for generation (default True)
803
+
804
+ Returns:
805
+ List of created Tasks
806
+ """
807
+ if use_llm:
808
+ try:
809
+ tasks_data = _generate_tasks_with_llm(prd.content)
810
+ except json.JSONDecodeError as e:
811
+ # Invalid JSON from LLM response — fall back to simple extraction
812
+ print(f"LLM generation failed ({e}), using simple extraction")
813
+ tasks_data = _extract_tasks_simple(prd.content)
814
+ except ValueError:
815
+ raise # Config errors (missing API key) should fail loudly
816
+ except Exception as e:
817
+ # Fall back to simple extraction
818
+ print(f"LLM generation failed ({e}), using simple extraction")
819
+ tasks_data = _extract_tasks_simple(prd.content)
820
+ else:
821
+ tasks_data = _extract_tasks_simple(prd.content)
822
+
823
+ created_tasks = []
824
+ for i, task_data in enumerate(tasks_data):
825
+ task = create(
826
+ workspace=workspace,
827
+ title=task_data["title"],
828
+ description=task_data.get("description", ""),
829
+ status=TaskStatus.BACKLOG,
830
+ priority=i, # Priority based on order
831
+ prd_id=prd.id,
832
+ complexity_score=task_data.get("complexity"),
833
+ estimated_hours=task_data.get("estimated_hours"),
834
+ uncertainty_level=task_data.get("uncertainty"),
835
+ )
836
+ created_tasks.append(task)
837
+
838
+ # Resolve title-based dependencies to task IDs
839
+ title_to_id = {t.title: t.id for t in created_tasks}
840
+ for task_data, task in zip(tasks_data, created_tasks):
841
+ dep_titles = task_data.get("depends_on_titles", [])
842
+ dep_ids = [title_to_id[t] for t in dep_titles if t in title_to_id]
843
+ if dep_ids:
844
+ update_depends_on(workspace, task.id, dep_ids)
845
+
846
+ return created_tasks
847
+
848
+
849
+ def _generate_tasks_with_llm(prd_content: str) -> list[dict]:
850
+ """Use LLM to generate tasks from PRD content.
851
+
852
+ Args:
853
+ prd_content: PRD text
854
+
855
+ Returns:
856
+ List of task dicts with rich metadata fields
857
+ """
858
+ # Use the LLM adapter for provider-agnostic access
859
+ from codeframe.adapters.llm import get_provider, Purpose
860
+
861
+ provider = get_provider()
862
+
863
+ prompt = f"""Analyze the following PRD and generate a list of actionable development tasks.
864
+
865
+ For each task, provide:
866
+ 1. "title": Clear, specific title (under 80 characters)
867
+ 2. "description": What needs to be done
868
+ 3. "depends_on_titles": List of other task titles this depends on (empty list if none)
869
+ 4. "complexity": Complexity score 1-5 (1=trivial, 5=very complex)
870
+ 5. "estimated_hours": Estimated hours to complete (float)
871
+ 6. "uncertainty": "low", "medium", or "high"
872
+ 7. "files_to_modify": List of file paths likely to be modified (best guess)
873
+
874
+ Order tasks by logical dependency/priority.
875
+
876
+ Return ONLY a JSON array of objects with these fields.
877
+
878
+ PRD:
879
+ {prd_content}"""
880
+
881
+ response = provider.complete(
882
+ messages=[{"role": "user", "content": prompt}],
883
+ purpose=Purpose.GENERATION,
884
+ max_tokens=2000,
885
+ )
886
+
887
+ # Extract JSON from response
888
+ response_text = response.content.strip()
889
+
890
+ # Try to find JSON array in response
891
+ json_match = re.search(r"\[[\s\S]*\]", response_text)
892
+ if json_match:
893
+ tasks_raw = json.loads(json_match.group())
894
+ else:
895
+ tasks_raw = json.loads(response_text)
896
+
897
+ # Validate and extract rich fields
898
+ validated = []
899
+ for task in tasks_raw:
900
+ if not isinstance(task, dict) or "title" not in task:
901
+ continue
902
+
903
+ # Extract rich fields with defaults and validation
904
+ complexity = task.get("complexity")
905
+ if complexity is not None:
906
+ complexity = max(1, min(5, int(complexity)))
907
+
908
+ estimated_hours = task.get("estimated_hours")
909
+ if estimated_hours is not None:
910
+ estimated_hours = max(0.1, float(estimated_hours))
911
+
912
+ uncertainty = task.get("uncertainty")
913
+ if uncertainty not in ("low", "medium", "high"):
914
+ uncertainty = None
915
+
916
+ files = task.get("files_to_modify", [])
917
+ desc = str(task.get("description", ""))[:2000]
918
+ if files:
919
+ desc += "\n\nFiles to modify: " + ", ".join(str(f) for f in files)
920
+
921
+ validated.append({
922
+ "title": str(task["title"])[:200],
923
+ "description": desc,
924
+ "depends_on_titles": task.get("depends_on_titles", []),
925
+ "complexity": complexity,
926
+ "estimated_hours": estimated_hours,
927
+ "uncertainty": uncertainty,
928
+ })
929
+
930
+ return validated
931
+
932
+
933
+ def _extract_tasks_simple(prd_content: str) -> list[dict]:
934
+ """Simple task extraction without LLM.
935
+
936
+ Extracts bullet points and numbered items as tasks.
937
+
938
+ Args:
939
+ prd_content: PRD text
940
+
941
+ Returns:
942
+ List of task dicts
943
+ """
944
+ tasks = []
945
+
946
+ # Find bullet points and numbered items
947
+ patterns = [
948
+ r"^[-*]\s+(.+)$", # Bullet points
949
+ r"^\d+\.\s+(.+)$", # Numbered items
950
+ r"^#{2,3}\s+(.+)$", # H2/H3 headings as potential features
951
+ ]
952
+
953
+ for pattern in patterns:
954
+ matches = re.findall(pattern, prd_content, re.MULTILINE)
955
+ for match in matches:
956
+ title = match.strip()
957
+ # Skip very short items or ones that look like headers
958
+ if len(title) > 10 and not title.endswith(":"):
959
+ tasks.append({
960
+ "title": title[:200],
961
+ "description": "",
962
+ })
963
+
964
+ # Deduplicate by title
965
+ seen = set()
966
+ unique_tasks = []
967
+ for task in tasks:
968
+ if task["title"] not in seen:
969
+ seen.add(task["title"])
970
+ unique_tasks.append(task)
971
+
972
+ return unique_tasks[:20] # Limit to 20 tasks
973
+
974
+
975
+ def _row_to_task(row: tuple) -> Task:
976
+ """Convert a database row to a Task object.
977
+
978
+ Row columns: id, workspace_id, prd_id, title, description, status, priority,
979
+ depends_on, estimated_hours, complexity_score, uncertainty_level,
980
+ created_at, updated_at, github_issue_number, parent_id, lineage,
981
+ is_leaf, hierarchical_id, requirement_ids, external_url,
982
+ auto_close_github_issue
983
+ """
984
+ # Parse depends_on from JSON string (default to empty list if null)
985
+ depends_on_raw = row[7]
986
+ depends_on = json.loads(depends_on_raw) if depends_on_raw else []
987
+
988
+ # Parse lineage from JSON string (default to empty list if null)
989
+ lineage_raw = row[15] if len(row) > 15 else None
990
+ lineage = json.loads(lineage_raw) if lineage_raw else []
991
+
992
+ # Parse is_leaf from integer (default to True if null)
993
+ is_leaf_raw = row[16] if len(row) > 16 else 1
994
+ is_leaf = bool(is_leaf_raw) if is_leaf_raw is not None else True
995
+
996
+ # Parse requirement_ids from JSON string (default to empty list if null)
997
+ requirement_ids_raw = row[18] if len(row) > 18 else None
998
+ requirement_ids = json.loads(requirement_ids_raw) if requirement_ids_raw else []
999
+
1000
+ return Task(
1001
+ id=row[0],
1002
+ workspace_id=row[1],
1003
+ prd_id=row[2],
1004
+ title=row[3],
1005
+ description=row[4],
1006
+ status=TaskStatus(row[5]),
1007
+ priority=row[6],
1008
+ depends_on=depends_on,
1009
+ estimated_hours=row[8],
1010
+ complexity_score=row[9],
1011
+ uncertainty_level=row[10],
1012
+ created_at=datetime.fromisoformat(row[11]),
1013
+ updated_at=datetime.fromisoformat(row[12]),
1014
+ github_issue_number=row[13] if len(row) > 13 else None,
1015
+ parent_id=row[14] if len(row) > 14 else None,
1016
+ lineage=lineage,
1017
+ is_leaf=is_leaf,
1018
+ hierarchical_id=row[17] if len(row) > 17 else None,
1019
+ requirement_ids=requirement_ids,
1020
+ external_url=row[19] if len(row) > 19 else None,
1021
+ auto_close_github_issue=bool(row[20]) if len(row) > 20 and row[20] is not None else False,
1022
+ )