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,400 @@
1
+ """Recursive task decomposition and tree operations.
2
+
3
+ Provides functions to classify, decompose, and recursively build task trees
4
+ using LLM-powered analysis. Also handles tree display and status propagation.
5
+
6
+ This module is headless - no FastAPI or HTTP dependencies.
7
+ """
8
+
9
+ import json
10
+ import re
11
+ from typing import Optional
12
+
13
+ from codeframe.adapters.llm.base import Purpose
14
+ from codeframe.core import tasks as task_module
15
+ from codeframe.core.state_machine import TaskStatus
16
+ from codeframe.core.workspace import Workspace
17
+
18
+
19
+ CLASSIFY_SYSTEM_PROMPT = (
20
+ "You are a task decomposition expert. Classify whether a task is 'atomic' "
21
+ "(can be done in 1-2 hours by one developer) or 'composite' (should be broken "
22
+ "into subtasks). When in doubt, choose 'atomic'. Return ONLY the word 'atomic' "
23
+ "or 'composite'."
24
+ )
25
+
26
+ DECOMPOSE_SYSTEM_PROMPT = (
27
+ "Break this task into 2-7 concrete subtasks. Each should be actionable and "
28
+ "testable. Return a JSON array of objects with 'title' and 'description' fields."
29
+ )
30
+
31
+ # Status display icons
32
+ _STATUS_ICONS = {
33
+ TaskStatus.DONE: "\u2713",
34
+ TaskStatus.IN_PROGRESS: "\u25cf",
35
+ TaskStatus.FAILED: "\u2717",
36
+ TaskStatus.BLOCKED: "\u2298",
37
+ TaskStatus.BACKLOG: "\u25cb",
38
+ TaskStatus.READY: "\u25cb",
39
+ TaskStatus.MERGED: "\u2713",
40
+ }
41
+
42
+
43
+ def classify_task(
44
+ provider, description: str, lineage: list[str]
45
+ ) -> str:
46
+ """Classify a task as 'atomic' or 'composite' using LLM.
47
+
48
+ Args:
49
+ provider: LLM provider instance
50
+ description: Task description to classify
51
+ lineage: List of ancestor task descriptions for context
52
+
53
+ Returns:
54
+ 'atomic' or 'composite'
55
+ """
56
+ lineage_context = ""
57
+ if lineage:
58
+ lineage_context = "\n\nParent context:\n" + "\n".join(
59
+ f"- {desc}" for desc in lineage
60
+ )
61
+
62
+ user_message = f"Task: {description}{lineage_context}"
63
+
64
+ response = provider.complete(
65
+ messages=[{"role": "user", "content": user_message}],
66
+ purpose=Purpose.PLANNING,
67
+ system=CLASSIFY_SYSTEM_PROMPT,
68
+ max_tokens=50,
69
+ temperature=0.0,
70
+ )
71
+
72
+ result = response.content.strip().lower()
73
+ if result in ("atomic", "composite"):
74
+ return result
75
+ return "atomic"
76
+
77
+
78
+ def decompose_task(
79
+ provider, description: str, lineage: list[str]
80
+ ) -> list[dict]:
81
+ """Decompose a task into 2-7 subtasks using LLM.
82
+
83
+ Args:
84
+ provider: LLM provider instance
85
+ description: Task description to decompose
86
+ lineage: List of ancestor task descriptions for context
87
+
88
+ Returns:
89
+ List of dicts with 'title' and 'description' keys (2-7 items)
90
+ """
91
+ lineage_context = ""
92
+ if lineage:
93
+ lineage_context = "\n\nParent context:\n" + "\n".join(
94
+ f"- {desc}" for desc in lineage
95
+ )
96
+
97
+ user_message = f"Task to decompose: {description}{lineage_context}"
98
+
99
+ response = provider.complete(
100
+ messages=[{"role": "user", "content": user_message}],
101
+ purpose=Purpose.PLANNING,
102
+ system=DECOMPOSE_SYSTEM_PROMPT,
103
+ max_tokens=2048,
104
+ temperature=0.0,
105
+ )
106
+
107
+ subtasks = _parse_subtasks(response.content)
108
+
109
+ # Clamp to 2-7 items
110
+ if len(subtasks) > 7:
111
+ subtasks = subtasks[:7]
112
+ while len(subtasks) < 2:
113
+ subtasks.append({
114
+ "title": f"Part {len(subtasks) + 1} of: {description[:60]}",
115
+ "description": f"Additional subtask for: {description}",
116
+ })
117
+
118
+ return subtasks
119
+
120
+
121
+ def _parse_subtasks(content: str) -> list[dict]:
122
+ """Parse LLM response into subtask list.
123
+
124
+ Handles JSON arrays directly or wrapped in markdown code blocks.
125
+
126
+ Args:
127
+ content: Raw LLM response
128
+
129
+ Returns:
130
+ List of dicts with 'title' and 'description' keys
131
+ """
132
+ # Try markdown-wrapped JSON first
133
+ json_match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content)
134
+ if json_match:
135
+ json_str = json_match.group(1)
136
+ else:
137
+ # Try raw JSON array
138
+ json_match = re.search(r"\[[\s\S]*\]", content)
139
+ if json_match:
140
+ json_str = json_match.group(0)
141
+ else:
142
+ return []
143
+
144
+ try:
145
+ raw = json.loads(json_str)
146
+ except json.JSONDecodeError:
147
+ return []
148
+
149
+ if not isinstance(raw, list):
150
+ return []
151
+
152
+ result = []
153
+ for item in raw:
154
+ if isinstance(item, dict) and "title" in item:
155
+ result.append({
156
+ "title": str(item["title"])[:200],
157
+ "description": str(item.get("description", ""))[:2000],
158
+ })
159
+
160
+ return result
161
+
162
+
163
+ def generate_task_tree(
164
+ provider,
165
+ description: str,
166
+ lineage: Optional[list[str]] = None,
167
+ depth: int = 0,
168
+ max_depth: int = 3,
169
+ ) -> dict:
170
+ """Recursively generate a task tree using LLM classification and decomposition.
171
+
172
+ Args:
173
+ provider: LLM provider instance
174
+ description: Task description
175
+ lineage: Ancestor task descriptions (accumulates through recursion)
176
+ depth: Current recursion depth
177
+ max_depth: Maximum recursion depth before forcing leaf nodes
178
+
179
+ Returns:
180
+ Tree dict with keys: title, description, is_leaf, children, lineage
181
+ """
182
+ lineage = lineage or []
183
+
184
+ # Force leaf at max depth
185
+ if depth >= max_depth:
186
+ return {
187
+ "title": description[:80],
188
+ "description": description,
189
+ "is_leaf": True,
190
+ "children": [],
191
+ "lineage": lineage,
192
+ }
193
+
194
+ kind = classify_task(provider, description, lineage)
195
+
196
+ if kind == "atomic":
197
+ return {
198
+ "title": description[:80],
199
+ "description": description,
200
+ "is_leaf": True,
201
+ "children": [],
202
+ "lineage": lineage,
203
+ }
204
+
205
+ # Composite: decompose and recurse
206
+ subtasks = decompose_task(provider, description, lineage)
207
+ children = []
208
+ child_lineage = lineage + [description]
209
+
210
+ for sub in subtasks:
211
+ child_tree = generate_task_tree(
212
+ provider,
213
+ sub["description"] or sub["title"],
214
+ lineage=child_lineage,
215
+ depth=depth + 1,
216
+ max_depth=max_depth,
217
+ )
218
+ # Use the subtask title if it's better than the truncated description
219
+ child_tree["title"] = sub["title"][:80]
220
+ children.append(child_tree)
221
+
222
+ return {
223
+ "title": description[:80],
224
+ "description": description,
225
+ "is_leaf": False,
226
+ "children": children,
227
+ "lineage": lineage,
228
+ }
229
+
230
+
231
+ def flatten_task_tree(
232
+ tree: dict,
233
+ workspace: Workspace,
234
+ prd_id: Optional[str] = None,
235
+ parent_id: Optional[str] = None,
236
+ position: int = 1,
237
+ prefix: str = "",
238
+ ) -> list:
239
+ """Walk the tree and create task records in the workspace.
240
+
241
+ Args:
242
+ tree: Tree dict from generate_task_tree()
243
+ workspace: Target workspace
244
+ prd_id: Optional PRD ID to associate tasks with
245
+ parent_id: Parent task ID (for children)
246
+ position: Position among siblings (1-based)
247
+ prefix: Hierarchical ID prefix (e.g., "1.2")
248
+
249
+ Returns:
250
+ Flat list of all created Task objects
251
+ """
252
+ h_id = f"{prefix}{position}" if prefix else str(position)
253
+
254
+ task = task_module.create(
255
+ workspace=workspace,
256
+ title=tree["title"],
257
+ description=tree.get("description", ""),
258
+ prd_id=prd_id,
259
+ parent_id=parent_id,
260
+ lineage=tree.get("lineage", []),
261
+ is_leaf=tree["is_leaf"],
262
+ hierarchical_id=h_id,
263
+ )
264
+
265
+ result = [task]
266
+
267
+ for i, child in enumerate(tree.get("children", []), start=1):
268
+ child_tasks = flatten_task_tree(
269
+ child,
270
+ workspace,
271
+ prd_id=prd_id,
272
+ parent_id=task.id,
273
+ position=i,
274
+ prefix=f"{h_id}.",
275
+ )
276
+ result.extend(child_tasks)
277
+
278
+ return result
279
+
280
+
281
+ def display_task_tree(workspace: Workspace) -> str:
282
+ """Build an ASCII tree display of all tasks in a workspace.
283
+
284
+ Args:
285
+ workspace: Workspace to display tasks from
286
+
287
+ Returns:
288
+ Formatted ASCII tree string
289
+ """
290
+ all_tasks = task_module.list_tasks(workspace)
291
+
292
+ if not all_tasks:
293
+ return "No tasks found."
294
+
295
+ # Build children map and find roots
296
+ children_map: dict[str, list] = {}
297
+
298
+ for t in all_tasks:
299
+ pid = t.parent_id
300
+ if pid not in children_map:
301
+ children_map[pid] = []
302
+ children_map[pid].append(t)
303
+
304
+ # Sort children by hierarchical_id if available, else by title
305
+ for pid in children_map:
306
+ children_map[pid].sort(
307
+ key=lambda t: t.hierarchical_id or t.title
308
+ )
309
+
310
+ roots = children_map.get(None, [])
311
+
312
+ if not roots:
313
+ # No tree structure — display flat list
314
+ lines = []
315
+ for t in all_tasks:
316
+ icon = _STATUS_ICONS.get(t.status, "\u25cb")
317
+ label = t.hierarchical_id or t.id[:8]
318
+ kind = "composite" if not t.is_leaf else "atomic"
319
+ lines.append(f"{label}. {t.title} [{kind}] {icon}")
320
+ return "\n".join(lines)
321
+
322
+ lines = []
323
+ for root in roots:
324
+ _render_node(root, children_map, lines, indent="", is_last=True)
325
+
326
+ return "\n".join(lines)
327
+
328
+
329
+ def _render_node(
330
+ task,
331
+ children_map: dict,
332
+ lines: list[str],
333
+ indent: str,
334
+ is_last: bool,
335
+ ) -> None:
336
+ """Recursively render a task node in ASCII tree format."""
337
+ icon = _STATUS_ICONS.get(task.status, "\u25cb")
338
+ label = task.hierarchical_id or task.id[:8]
339
+ kind = "composite" if not task.is_leaf else "atomic"
340
+
341
+ connector = "\u2514\u2500\u2500 " if is_last else "\u251c\u2500\u2500 "
342
+ if not indent:
343
+ # Root node — no connector
344
+ lines.append(f"{label}. {task.title} [{kind}] {icon}")
345
+ else:
346
+ lines.append(f"{indent}{connector}{label}. {task.title} [{kind}] {icon}")
347
+
348
+ children = children_map.get(task.id, [])
349
+ child_indent = indent + (" " if is_last else "\u2502 ")
350
+
351
+ for i, child in enumerate(children):
352
+ _render_node(
353
+ child, children_map, lines, child_indent, is_last=(i == len(children) - 1)
354
+ )
355
+
356
+
357
+ def propagate_status(workspace: Workspace, task_id: str) -> None:
358
+ """Propagate status changes from a child task up to its parent(s).
359
+
360
+ When a child task changes status, the parent's status may need to update:
361
+ - All children DONE -> parent DONE
362
+ - Any child FAILED -> parent FAILED
363
+ - Any child IN_PROGRESS -> parent IN_PROGRESS
364
+ - Otherwise -> no change
365
+
366
+ Args:
367
+ workspace: Workspace containing the tasks
368
+ task_id: ID of the task whose status just changed
369
+ """
370
+ task = task_module.get(workspace, task_id)
371
+ if not task or not task.parent_id:
372
+ return
373
+
374
+ parent = task_module.get(workspace, task.parent_id)
375
+ if not parent or parent.is_leaf:
376
+ return
377
+
378
+ # Load all children of the parent
379
+ all_tasks = task_module.list_tasks(workspace)
380
+ children = [t for t in all_tasks if t.parent_id == parent.id]
381
+
382
+ if not children:
383
+ return
384
+
385
+ child_statuses = [c.status for c in children]
386
+
387
+ # Determine new parent status
388
+ new_status = None
389
+ if all(s == TaskStatus.DONE for s in child_statuses):
390
+ new_status = TaskStatus.DONE
391
+ elif any(s == TaskStatus.FAILED for s in child_statuses):
392
+ new_status = TaskStatus.FAILED
393
+ elif any(s == TaskStatus.IN_PROGRESS for s in child_statuses):
394
+ new_status = TaskStatus.IN_PROGRESS
395
+
396
+ if new_status and new_status != parent.status:
397
+ task_module.update_status(workspace, parent.id, new_status)
398
+
399
+ # Recursively propagate upward
400
+ propagate_status(workspace, parent.id)