scopemate 0.1.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.
- scopemate/__init__.py +16 -0
- scopemate/__main__.py +10 -0
- scopemate/breakdown.py +466 -0
- scopemate/cli.py +174 -0
- scopemate/core.py +23 -0
- scopemate/engine.py +226 -0
- scopemate/interaction.py +292 -0
- scopemate/llm.py +343 -0
- scopemate/models.py +157 -0
- scopemate/storage.py +106 -0
- scopemate/task_analysis.py +357 -0
- scopemate-0.1.0.dist-info/METADATA +410 -0
- scopemate-0.1.0.dist-info/RECORD +17 -0
- scopemate-0.1.0.dist-info/WHEEL +5 -0
- scopemate-0.1.0.dist-info/entry_points.txt +2 -0
- scopemate-0.1.0.dist-info/licenses/LICENSE +21 -0
- scopemate-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,357 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
scopemate Task Analysis - Functions for analyzing and validating tasks
|
4
|
+
|
5
|
+
This module provides functions for analyzing task structures, validating
|
6
|
+
task relationships, and ensuring estimate consistency.
|
7
|
+
"""
|
8
|
+
from typing import List, Dict, Tuple, Optional, Set
|
9
|
+
|
10
|
+
from .models import (
|
11
|
+
ScopeMateTask, SIZE_COMPLEXITY, TIME_COMPLEXITY, get_utc_now
|
12
|
+
)
|
13
|
+
|
14
|
+
def check_and_update_parent_estimates(tasks: List[ScopeMateTask]) -> List[ScopeMateTask]:
|
15
|
+
"""
|
16
|
+
Check and update parent task estimates based on child task complexity.
|
17
|
+
|
18
|
+
Args:
|
19
|
+
tasks: List of ScopeMateTask objects
|
20
|
+
|
21
|
+
Returns:
|
22
|
+
Updated list of ScopeMateTask objects
|
23
|
+
"""
|
24
|
+
# Create a map of tasks by ID for easy access
|
25
|
+
task_map = {t.id: t for t in tasks}
|
26
|
+
|
27
|
+
# Create a map of parent IDs for each task
|
28
|
+
parent_map = {t.id: t.parent_id for t in tasks}
|
29
|
+
|
30
|
+
# Map to track size and time complexity values
|
31
|
+
size_complexity_map = {}
|
32
|
+
time_complexity_map = {}
|
33
|
+
|
34
|
+
# First pass: Calculate complexity values for all tasks
|
35
|
+
for task in tasks:
|
36
|
+
size_complexity_map[task.id] = SIZE_COMPLEXITY.get(task.scope.size, 3)
|
37
|
+
time_complexity_map[task.id] = TIME_COMPLEXITY.get(task.scope.time_estimate, 4)
|
38
|
+
|
39
|
+
# Second pass: Check for inconsistencies and update parent estimates
|
40
|
+
inconsistencies_fixed = 0
|
41
|
+
|
42
|
+
# Create a list of child tasks (tasks with parents)
|
43
|
+
child_tasks = [t for t in tasks if t.parent_id]
|
44
|
+
|
45
|
+
# Process child tasks to update parents
|
46
|
+
for child in child_tasks:
|
47
|
+
if not child.parent_id or child.parent_id not in task_map:
|
48
|
+
continue
|
49
|
+
|
50
|
+
parent = task_map[child.parent_id]
|
51
|
+
parent_size_complexity = size_complexity_map[parent.id]
|
52
|
+
parent_time_complexity = time_complexity_map[parent.id]
|
53
|
+
child_size_complexity = size_complexity_map[child.id]
|
54
|
+
child_time_complexity = time_complexity_map[child.id]
|
55
|
+
|
56
|
+
# Check if child has higher complexity than parent
|
57
|
+
size_inconsistent = child_size_complexity > parent_size_complexity
|
58
|
+
time_inconsistent = child_time_complexity > parent_time_complexity
|
59
|
+
|
60
|
+
if size_inconsistent or time_inconsistent:
|
61
|
+
# Prepare update data
|
62
|
+
parent_copy = parent.model_copy(deep=True)
|
63
|
+
updated = False
|
64
|
+
|
65
|
+
# Update size estimate if needed
|
66
|
+
if size_inconsistent:
|
67
|
+
# Find the corresponding size value
|
68
|
+
for size_name, complexity in SIZE_COMPLEXITY.items():
|
69
|
+
if complexity >= child_size_complexity:
|
70
|
+
parent_copy.scope.size = size_name
|
71
|
+
updated = True
|
72
|
+
break
|
73
|
+
|
74
|
+
# Update time estimate if needed
|
75
|
+
if time_inconsistent:
|
76
|
+
# Find the corresponding time value
|
77
|
+
for time_name, complexity in TIME_COMPLEXITY.items():
|
78
|
+
if complexity >= child_time_complexity:
|
79
|
+
parent_copy.scope.time_estimate = time_name
|
80
|
+
updated = True
|
81
|
+
break
|
82
|
+
|
83
|
+
# Apply updates if needed
|
84
|
+
if updated:
|
85
|
+
parent_copy.meta.updated = get_utc_now()
|
86
|
+
task_map[parent.id] = parent_copy
|
87
|
+
|
88
|
+
# Update the complexity maps
|
89
|
+
size_complexity_map[parent.id] = SIZE_COMPLEXITY.get(parent_copy.scope.size, 3)
|
90
|
+
time_complexity_map[parent.id] = TIME_COMPLEXITY.get(parent_copy.scope.time_estimate, 4)
|
91
|
+
|
92
|
+
# Propagate changes to ancestors
|
93
|
+
_update_ancestors(
|
94
|
+
parent.id,
|
95
|
+
task_map,
|
96
|
+
parent_map,
|
97
|
+
update_size=size_inconsistent,
|
98
|
+
update_time=time_inconsistent,
|
99
|
+
size_value=SIZE_COMPLEXITY.get(parent_copy.scope.size, 3),
|
100
|
+
time_value=TIME_COMPLEXITY.get(parent_copy.scope.time_estimate, 4)
|
101
|
+
)
|
102
|
+
|
103
|
+
inconsistencies_fixed += 1
|
104
|
+
|
105
|
+
# Return the updated task list
|
106
|
+
updated_tasks = list(task_map.values())
|
107
|
+
|
108
|
+
if inconsistencies_fixed > 0:
|
109
|
+
print(f"✅ Fixed {inconsistencies_fixed} estimate inconsistencies")
|
110
|
+
|
111
|
+
return updated_tasks
|
112
|
+
|
113
|
+
|
114
|
+
def _update_ancestors(
|
115
|
+
task_id: str,
|
116
|
+
task_map: Dict[str, ScopeMateTask],
|
117
|
+
parent_map: Dict[str, str],
|
118
|
+
update_size: bool = False,
|
119
|
+
update_time: bool = False,
|
120
|
+
size_value: Optional[int] = None,
|
121
|
+
time_value: Optional[int] = None
|
122
|
+
) -> None:
|
123
|
+
"""
|
124
|
+
Recursively update ancestors' estimates.
|
125
|
+
|
126
|
+
Args:
|
127
|
+
task_id: ID of the task whose parent should be updated
|
128
|
+
task_map: Dictionary mapping task IDs to ScopeMateTask objects
|
129
|
+
parent_map: Dictionary mapping task IDs to parent IDs
|
130
|
+
update_size: Whether to update size estimate
|
131
|
+
update_time: Whether to update time estimate
|
132
|
+
size_value: Size complexity value to propagate
|
133
|
+
time_value: Time complexity value to propagate
|
134
|
+
"""
|
135
|
+
# Exit if we have nothing to update
|
136
|
+
if not update_size and not update_time:
|
137
|
+
return
|
138
|
+
|
139
|
+
# Exit if no parent ID
|
140
|
+
if task_id not in parent_map or not parent_map[task_id]:
|
141
|
+
return
|
142
|
+
|
143
|
+
# Get parent ID and task
|
144
|
+
parent_id = parent_map[task_id]
|
145
|
+
if parent_id not in task_map:
|
146
|
+
return
|
147
|
+
|
148
|
+
parent = task_map[parent_id]
|
149
|
+
parent_copy = parent.model_copy(deep=True)
|
150
|
+
updated = False
|
151
|
+
|
152
|
+
# Update size estimate if needed
|
153
|
+
if update_size and size_value is not None:
|
154
|
+
parent_size_complexity = SIZE_COMPLEXITY.get(parent_copy.scope.size, 3)
|
155
|
+
|
156
|
+
if size_value > parent_size_complexity:
|
157
|
+
# Find the corresponding size value
|
158
|
+
for size_name, complexity in SIZE_COMPLEXITY.items():
|
159
|
+
if complexity >= size_value:
|
160
|
+
parent_copy.scope.size = size_name
|
161
|
+
updated = True
|
162
|
+
break
|
163
|
+
|
164
|
+
# Update time estimate if needed
|
165
|
+
if update_time and time_value is not None:
|
166
|
+
parent_time_complexity = TIME_COMPLEXITY.get(parent_copy.scope.time_estimate, 4)
|
167
|
+
|
168
|
+
if time_value > parent_time_complexity:
|
169
|
+
# Find the corresponding time value
|
170
|
+
for time_name, complexity in TIME_COMPLEXITY.items():
|
171
|
+
if complexity >= time_value:
|
172
|
+
parent_copy.scope.time_estimate = time_name
|
173
|
+
updated = True
|
174
|
+
break
|
175
|
+
|
176
|
+
# Apply updates if needed
|
177
|
+
if updated:
|
178
|
+
parent_copy.meta.updated = get_utc_now()
|
179
|
+
task_map[parent_id] = parent_copy
|
180
|
+
|
181
|
+
# Continue updating ancestors
|
182
|
+
_update_ancestors(
|
183
|
+
parent_id,
|
184
|
+
task_map,
|
185
|
+
parent_map,
|
186
|
+
update_size=update_size,
|
187
|
+
update_time=update_time,
|
188
|
+
size_value=SIZE_COMPLEXITY.get(parent_copy.scope.size, 3) if update_size else None,
|
189
|
+
time_value=TIME_COMPLEXITY.get(parent_copy.scope.time_estimate, 4) if update_time else None
|
190
|
+
)
|
191
|
+
|
192
|
+
|
193
|
+
def find_long_duration_leaf_tasks(tasks: List[ScopeMateTask]) -> List[ScopeMateTask]:
|
194
|
+
"""
|
195
|
+
Find leaf tasks (tasks without children) that have long durations.
|
196
|
+
|
197
|
+
Args:
|
198
|
+
tasks: List of ScopeMateTask objects to analyze
|
199
|
+
|
200
|
+
Returns:
|
201
|
+
List of tasks with 'week', 'sprint' or 'multi-sprint' estimates that don't have subtasks,
|
202
|
+
sorted with longest durations first.
|
203
|
+
"""
|
204
|
+
# Build task hierarchy maps
|
205
|
+
task_map = {t.id: t for t in tasks}
|
206
|
+
has_children = set()
|
207
|
+
|
208
|
+
for task in tasks:
|
209
|
+
if task.parent_id and task.parent_id in task_map:
|
210
|
+
has_children.add(task.parent_id)
|
211
|
+
|
212
|
+
# Find leaf tasks with long durations
|
213
|
+
long_durations = ["week", "sprint", "multi-sprint"]
|
214
|
+
leaf_tasks_with_long_durations = []
|
215
|
+
|
216
|
+
for task in tasks:
|
217
|
+
if task.id not in has_children and task.scope.time_estimate in long_durations:
|
218
|
+
leaf_tasks_with_long_durations.append(task)
|
219
|
+
|
220
|
+
# Sort by duration (longest first)
|
221
|
+
return sorted(
|
222
|
+
leaf_tasks_with_long_durations,
|
223
|
+
key=lambda t: TIME_COMPLEXITY.get(t.scope.time_estimate, 3),
|
224
|
+
reverse=True
|
225
|
+
)
|
226
|
+
|
227
|
+
|
228
|
+
def should_decompose_task(task: ScopeMateTask, depth: int, max_depth: int, is_leaf: bool = False) -> bool:
|
229
|
+
"""
|
230
|
+
Determine if a task should be broken down based on complexity and time estimates.
|
231
|
+
|
232
|
+
Args:
|
233
|
+
task: The ScopeMateTask to evaluate
|
234
|
+
depth: Current depth in the task hierarchy
|
235
|
+
max_depth: Maximum allowed depth
|
236
|
+
is_leaf: Whether this is a leaf task (has no children)
|
237
|
+
|
238
|
+
Returns:
|
239
|
+
True if the task should be broken down, False otherwise
|
240
|
+
"""
|
241
|
+
# Always respect max depth limit
|
242
|
+
if depth >= max_depth:
|
243
|
+
return False
|
244
|
+
|
245
|
+
# Define complexity thresholds
|
246
|
+
complex_sizes = ["complex", "uncertain", "pioneering"]
|
247
|
+
long_durations = ["week", "sprint", "multi-sprint"]
|
248
|
+
|
249
|
+
# Always break down complex tasks
|
250
|
+
if task.scope.size in complex_sizes:
|
251
|
+
return True
|
252
|
+
|
253
|
+
# Also break down long-duration tasks, even if they're not "complex"
|
254
|
+
if task.scope.time_estimate in long_durations:
|
255
|
+
# For leaf tasks, always consider breaking down if they're long
|
256
|
+
if is_leaf:
|
257
|
+
return True
|
258
|
+
|
259
|
+
# Break down tasks with "week" duration up to max_depth
|
260
|
+
if task.scope.time_estimate == "week":
|
261
|
+
return True
|
262
|
+
|
263
|
+
# If it's already at depth 2+, only break down multi-sprint tasks
|
264
|
+
if depth >= 2 and task.scope.time_estimate != "multi-sprint":
|
265
|
+
return False
|
266
|
+
return True
|
267
|
+
|
268
|
+
return False
|
269
|
+
|
270
|
+
|
271
|
+
def _initialize_task_depths(tasks: List[ScopeMateTask]) -> Dict[str, int]:
|
272
|
+
"""
|
273
|
+
Initialize the depth tracking dictionary for tasks.
|
274
|
+
|
275
|
+
Args:
|
276
|
+
tasks: List of ScopeMateTask objects
|
277
|
+
|
278
|
+
Returns:
|
279
|
+
Dictionary mapping task IDs to depth values
|
280
|
+
"""
|
281
|
+
task_depths = {}
|
282
|
+
parent_map = {t.id: t.parent_id for t in tasks}
|
283
|
+
|
284
|
+
# Initialize depths for all tasks
|
285
|
+
for task in tasks:
|
286
|
+
if not task.parent_id:
|
287
|
+
# Root tasks are at depth 0
|
288
|
+
task_depths[task.id] = 0
|
289
|
+
|
290
|
+
# Process tasks with parents
|
291
|
+
for task in tasks:
|
292
|
+
if task.id not in task_depths and task.parent_id:
|
293
|
+
# Traverse up to find a task with known depth
|
294
|
+
depth = 0
|
295
|
+
current_id = task.id
|
296
|
+
while current_id in parent_map and parent_map[current_id]:
|
297
|
+
depth += 1
|
298
|
+
current_id = parent_map[current_id]
|
299
|
+
# If we found a parent with known depth, use that
|
300
|
+
if current_id in task_depths:
|
301
|
+
task_depths[task.id] = task_depths[current_id] + depth
|
302
|
+
break
|
303
|
+
|
304
|
+
# If we couldn't find a parent with known depth, assume depth 0 for the root
|
305
|
+
if task.id not in task_depths:
|
306
|
+
task_depths[task.id] = depth
|
307
|
+
|
308
|
+
return task_depths
|
309
|
+
|
310
|
+
|
311
|
+
def get_task_depth(task: ScopeMateTask, task_depths: Dict[str, int], tasks: List[ScopeMateTask]) -> int:
|
312
|
+
"""
|
313
|
+
Get the depth of a task in the hierarchy.
|
314
|
+
|
315
|
+
Args:
|
316
|
+
task: The ScopeMateTask to find depth for
|
317
|
+
task_depths: Dictionary mapping task IDs to depths
|
318
|
+
tasks: List of all ScopeMateTask objects
|
319
|
+
|
320
|
+
Returns:
|
321
|
+
The depth of the task
|
322
|
+
"""
|
323
|
+
# If depth is already known, return it
|
324
|
+
if task.id in task_depths:
|
325
|
+
return task_depths[task.id]
|
326
|
+
|
327
|
+
# If task has no parent, it's at root level (0)
|
328
|
+
if not task.parent_id:
|
329
|
+
task_depths[task.id] = 0
|
330
|
+
return 0
|
331
|
+
|
332
|
+
# Find the parent task
|
333
|
+
parent = next((t for t in tasks if t.id == task.parent_id), None)
|
334
|
+
if parent:
|
335
|
+
# Get parent's depth (recursively if needed) and add 1
|
336
|
+
parent_depth = get_task_depth(parent, task_depths, tasks)
|
337
|
+
task_depths[task.id] = parent_depth + 1
|
338
|
+
return parent_depth + 1
|
339
|
+
|
340
|
+
# If parent not found, assume depth 0
|
341
|
+
task_depths[task.id] = 0
|
342
|
+
return 0
|
343
|
+
|
344
|
+
|
345
|
+
def is_leaf_task(task_id: str, tasks: List[ScopeMateTask]) -> bool:
|
346
|
+
"""
|
347
|
+
Check if a task is a leaf task (has no children).
|
348
|
+
|
349
|
+
Args:
|
350
|
+
task_id: ID of the task to check
|
351
|
+
tasks: List of all ScopeMateTask objects
|
352
|
+
|
353
|
+
Returns:
|
354
|
+
True if task has no children, False otherwise
|
355
|
+
"""
|
356
|
+
# A task is a leaf if no other task has it as a parent
|
357
|
+
return not any(t.parent_id == task_id for t in tasks)
|