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/engine.py ADDED
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ scopemate Engine - Main application logic for scopemate
4
+
5
+ This module contains the TaskEngine class which coordinates all the functionality
6
+ of scopemate, handling the workflow for creating, breaking down, and saving tasks.
7
+ """
8
+ import os
9
+ from typing import List, Optional, Dict, Any
10
+
11
+ from .models import ScopeMateTask
12
+ from .storage import (
13
+ save_checkpoint, save_plan, load_plan,
14
+ checkpoint_exists, delete_checkpoint, CHECKPOINT_FILE
15
+ )
16
+ from .task_analysis import (
17
+ check_and_update_parent_estimates, find_long_duration_leaf_tasks,
18
+ should_decompose_task, _initialize_task_depths,
19
+ get_task_depth, is_leaf_task
20
+ )
21
+ from .breakdown import suggest_breakdown
22
+ from .interaction import prompt_user, build_root_task, print_summary
23
+
24
+
25
+ class TaskEngine:
26
+ """
27
+ Main engine for scopemate that coordinates task creation, breakdown, and management.
28
+ """
29
+
30
+ def __init__(self):
31
+ """Initialize the TaskEngine."""
32
+ self.tasks: List[ScopeMateTask] = []
33
+ self.task_depths: Dict[str, int] = {}
34
+ self.max_depth: int = 5 # Maximum depth of task hierarchy
35
+
36
+ def load_from_checkpoint(self) -> bool:
37
+ """
38
+ Load tasks from checkpoint file if it exists.
39
+
40
+ Returns:
41
+ True if checkpoint was loaded, False otherwise
42
+ """
43
+ if checkpoint_exists():
44
+ resume = prompt_user(
45
+ f"Found checkpoint '{CHECKPOINT_FILE}'. Resume?",
46
+ default="y",
47
+ choices=["y","n"]
48
+ )
49
+ if resume.lower() == "y":
50
+ self.tasks = load_plan(CHECKPOINT_FILE)
51
+ return True
52
+ else:
53
+ delete_checkpoint()
54
+
55
+ return False
56
+
57
+ def load_from_file(self, default_filename: str = "scopemate_plan.json") -> bool:
58
+ """
59
+ Load tasks from a user-specified file.
60
+
61
+ Args:
62
+ default_filename: Default filename to suggest
63
+
64
+ Returns:
65
+ True if file was loaded, False otherwise
66
+ """
67
+ choice = prompt_user("Load existing plan?", default="n", choices=["y","n"])
68
+ if choice.lower() == "y":
69
+ fname = prompt_user("Enter filename to load", default=default_filename)
70
+ try:
71
+ self.tasks = load_plan(fname)
72
+ return True
73
+ except FileNotFoundError:
74
+ print(f"File not found: {fname}")
75
+
76
+ return False
77
+
78
+ def create_new_task(self) -> None:
79
+ """Create a new root task interactively."""
80
+ self.tasks.append(build_root_task())
81
+ save_checkpoint(self.tasks)
82
+
83
+ def breakdown_complex_tasks(self) -> None:
84
+ """Process all tasks and break down complex ones."""
85
+ # Initialize depth tracking
86
+ self.task_depths = _initialize_task_depths(self.tasks)
87
+
88
+ # Maintain a list of tasks to process (for recursive handling)
89
+ tasks_to_process = list(self.tasks)
90
+
91
+ # Process tasks with checkpointing
92
+ while tasks_to_process:
93
+ # Get the next task to process
94
+ current_task = tasks_to_process.pop(0)
95
+
96
+ # Calculate depth for this task if not already tracked
97
+ current_depth = get_task_depth(current_task, self.task_depths, self.tasks)
98
+
99
+ # Check if this task is a leaf (has no children)
100
+ is_leaf = is_leaf_task(current_task.id, self.tasks)
101
+
102
+ # Only decompose if criteria met
103
+ if should_decompose_task(current_task, current_depth, self.max_depth, is_leaf):
104
+ print(f"\nDecomposing task {current_task.id} at depth {current_depth}...")
105
+ print(f" Size: {current_task.scope.size}, Time: {current_task.scope.time_estimate}")
106
+
107
+ # Get subtask breakdown from LLM with user interaction
108
+ subtasks = suggest_breakdown(current_task)
109
+
110
+ if subtasks:
111
+ # Set depth for new subtasks
112
+ for sub in subtasks:
113
+ self.task_depths[sub.id] = current_depth + 1
114
+
115
+ # Add subtasks to the task list
116
+ self.tasks.extend(subtasks)
117
+
118
+ # Add the newly created subtasks to the processing queue
119
+ tasks_to_process.extend(subtasks)
120
+
121
+ save_checkpoint(self.tasks)
122
+
123
+ print(f"Created {len(subtasks)} subtasks for {current_task.id}")
124
+
125
+ # Check and update parent estimates if needed
126
+ self.tasks = check_and_update_parent_estimates(self.tasks)
127
+ save_checkpoint(self.tasks)
128
+
129
+ def handle_long_duration_tasks(self) -> None:
130
+ """Find and handle long duration leaf tasks."""
131
+ # Find long duration leaf tasks
132
+ long_duration_leaf_tasks = find_long_duration_leaf_tasks(self.tasks)
133
+
134
+ if long_duration_leaf_tasks:
135
+ print("\n=== Found Long-Duration Leaf Tasks ===")
136
+ print("These tasks have long durations but no subtasks:")
137
+
138
+ for i, task in enumerate(long_duration_leaf_tasks):
139
+ print(f"{i+1}. [{task.id}] {task.title} - {task.scope.time_estimate}")
140
+
141
+ print("\nDo you want to break down any of these tasks?")
142
+ choice = prompt_user("Enter task numbers to break down (comma-separated) or 'n' to skip", default="n")
143
+
144
+ if choice.lower() != "n":
145
+ try:
146
+ # Parse the choices
147
+ selected_indices = [int(idx.strip()) - 1 for idx in choice.split(",")]
148
+ for idx in selected_indices:
149
+ if 0 <= idx < len(long_duration_leaf_tasks):
150
+ task_to_breakdown = long_duration_leaf_tasks[idx]
151
+ print(f"\nBreaking down: [{task_to_breakdown.id}] {task_to_breakdown.title}")
152
+
153
+ # Get subtask suggestions
154
+ suggested_subtasks = suggest_breakdown(task_to_breakdown)
155
+
156
+ if suggested_subtasks:
157
+ # Set depth for new subtasks
158
+ for sub in suggested_subtasks:
159
+ if task_to_breakdown.id in self.task_depths:
160
+ self.task_depths[sub.id] = self.task_depths[task_to_breakdown.id] + 1
161
+ else:
162
+ self.task_depths[sub.id] = 1
163
+
164
+ self.tasks.extend(suggested_subtasks)
165
+ save_checkpoint(self.tasks)
166
+ print(f"Created {len(suggested_subtasks)} subtasks for {task_to_breakdown.id}")
167
+
168
+ # Check and update parent estimates
169
+ self.tasks = check_and_update_parent_estimates(self.tasks)
170
+ save_checkpoint(self.tasks)
171
+ except ValueError:
172
+ print("Invalid selection, skipping breakdown.")
173
+
174
+ def finalize_plan(self) -> None:
175
+ """Review and save the final plan."""
176
+ # Final check of parent-child estimate consistency
177
+ self.tasks = check_and_update_parent_estimates(self.tasks)
178
+
179
+ # Review and final save
180
+ print_summary(self.tasks)
181
+ proceed = prompt_user("Save final plan?", default="y", choices=["y","n"])
182
+ if proceed.lower() == 'y':
183
+ fname = prompt_user("Save plan to file", default="scopemate_plan.json")
184
+ save_plan(self.tasks, fname)
185
+ delete_checkpoint()
186
+ else:
187
+ print(f"Plan left in checkpoint '{CHECKPOINT_FILE}'. Run again to resume.")
188
+
189
+ def run(self) -> None:
190
+ """Run the full interactive workflow."""
191
+ # Display introduction
192
+ print("=== scopemate Action Plan Builder ===")
193
+ print("This tool helps break down complex tasks and maintain consistent time estimates.")
194
+ print("Now with interactive task breakdown - choose from alternative approaches and customize subtasks!")
195
+ print("Note: Parent tasks will be automatically adjusted if child tasks take longer.\n")
196
+
197
+ # Try to load existing checkpoint
198
+ if not self.load_from_checkpoint():
199
+ # If no checkpoint, try loading from file or create new
200
+ if not self.load_from_file():
201
+ self.create_new_task()
202
+
203
+ # Process the tasks
204
+ self.breakdown_complex_tasks()
205
+
206
+ # Check for leaf tasks with long durations
207
+ self.handle_long_duration_tasks()
208
+
209
+ # Finalize the plan
210
+ self.finalize_plan()
211
+
212
+ def run_interactive(self) -> None:
213
+ """Run the interactive mode of the application."""
214
+ print("=== scopemate Action Plan Builder ===")
215
+
216
+
217
+ def interactive_builder():
218
+ """
219
+ Legacy function for backward compatibility that runs the TaskEngine.
220
+ """
221
+ engine = TaskEngine()
222
+ try:
223
+ engine.run()
224
+ except KeyboardInterrupt:
225
+ print("\nOperation cancelled. Progress saved in checkpoint.")
226
+ save_checkpoint(engine.tasks)
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ scopemate Interaction - Functions for user interaction
4
+
5
+ This module handles all interactive aspects of scopemate, including
6
+ collecting user input and displaying information.
7
+ """
8
+ from typing import List, Optional, Dict, Any
9
+
10
+ from .models import (
11
+ ScopeMateTask, Purpose, Scope, Outcome, Meta,
12
+ get_utc_now, VALID_URGENCY_TYPES, VALID_OUTCOME_TYPES,
13
+ VALID_SIZE_TYPES, VALID_TIME_ESTIMATES, VALID_CONFIDENCE_LEVELS,
14
+ VALID_TEAMS
15
+ )
16
+
17
+ # -------------------------------
18
+ # User Input Functions
19
+ # -------------------------------
20
+ def prompt_user(
21
+ prompt: str,
22
+ default: Optional[str] = None,
23
+ choices: Optional[List[str]] = None
24
+ ) -> str:
25
+ """
26
+ Prompt user for input with optional default and choices validation.
27
+
28
+ Args:
29
+ prompt: The prompt text to display
30
+ default: Optional default value if user enters nothing
31
+ choices: Optional list of valid choices
32
+
33
+ Returns:
34
+ User's validated input as a string
35
+ """
36
+ while True:
37
+ suffix = f" [{default}]" if default is not None else ""
38
+ resp = input(f"{prompt}{suffix}: ").strip()
39
+
40
+ if not resp and default is not None:
41
+ resp = default
42
+
43
+ if choices:
44
+ low = resp.lower()
45
+ if low not in [c.lower() for c in choices]:
46
+ print(f"Please choose from {choices}.")
47
+ continue
48
+
49
+ if resp:
50
+ return resp
51
+
52
+ # If we get here with an empty response and no default, loop again
53
+
54
+
55
+ def generate_concise_title(parent_title: str, subtask_title: str) -> str:
56
+ """
57
+ Generate a concise subtask title without repeating the parent title.
58
+
59
+ Args:
60
+ parent_title: The title of the parent task
61
+ subtask_title: The proposed title for the subtask
62
+
63
+ Returns:
64
+ A concise title for the subtask
65
+ """
66
+ # If subtask title already contains parent title, extract the unique part
67
+ if parent_title and parent_title.lower() in subtask_title.lower():
68
+ # Try to find the part after the parent title
69
+ suffix = subtask_title[subtask_title.lower().find(parent_title.lower()) + len(parent_title):].strip()
70
+ if suffix:
71
+ # Remove any leading separators like "-" or ":"
72
+ return suffix.lstrip(" -:").strip()
73
+
74
+ # If parent title isn't in subtask or couldn't extract suffix, use the subtask title directly
75
+ return subtask_title
76
+
77
+
78
+ def build_custom_subtask(parent_task: ScopeMateTask) -> ScopeMateTask:
79
+ """
80
+ Interactively gather information to create a custom subtask.
81
+
82
+ Args:
83
+ parent_task: The parent ScopeMateTask
84
+
85
+ Returns:
86
+ A new ScopeMateTask object as a subtask of the parent
87
+ """
88
+ # This is a simplified version - the full version should include all the prompts
89
+ # from the original code in core.py
90
+
91
+ import uuid
92
+
93
+ print(f"\n=== Creating Custom Subtask for: {parent_task.title} ===")
94
+
95
+ title = prompt_user("Give a short TITLE for this subtask")
96
+ title = generate_concise_title(parent_task.title, title)
97
+
98
+ summary = prompt_user("What is the primary PURPOSE of this subtask?")
99
+ outcome_def = prompt_user("Define the desired OUTCOME")
100
+
101
+ # Ask for team assignment
102
+ print("\nTEAM options:")
103
+ print("- Product: Product management team")
104
+ print("- Design: Design and user experience team")
105
+ print("- Frontend: Frontend development team")
106
+ print("- Backend: Backend development team")
107
+ print("- ML: Machine learning team")
108
+ print("- Infra: Infrastructure and DevOps team")
109
+ print("- Testing: QA and testing team")
110
+ print("- Other: Any other team")
111
+ team = prompt_user(
112
+ "TEAM responsible",
113
+ default=parent_task.meta.team,
114
+ choices=VALID_TEAMS
115
+ )
116
+
117
+ # Create the subtask with sensible defaults inheriting from parent
118
+ subtask = ScopeMateTask(
119
+ id=f"TASK-{uuid.uuid4().hex[:6]}",
120
+ title=title,
121
+ purpose=Purpose(
122
+ detailed_description=summary,
123
+ alignment=parent_task.purpose.alignment.copy(),
124
+ urgency=parent_task.purpose.urgency
125
+ ),
126
+ scope=Scope(
127
+ size="straightforward",
128
+ time_estimate="days",
129
+ dependencies=[],
130
+ risks=[]
131
+ ),
132
+ outcome=Outcome(
133
+ type=parent_task.outcome.type,
134
+ detailed_outcome_definition=outcome_def,
135
+ acceptance_criteria=[],
136
+ metric=None,
137
+ validation_method=None
138
+ ),
139
+ meta=Meta(
140
+ status="backlog",
141
+ priority=None,
142
+ created=get_utc_now(),
143
+ updated=get_utc_now(),
144
+ due_date=None,
145
+ confidence="medium",
146
+ team=team
147
+ ),
148
+ parent_id=parent_task.id
149
+ )
150
+
151
+ return subtask
152
+
153
+
154
+ def build_root_task() -> ScopeMateTask:
155
+ """
156
+ Interactively gather information to create a new root task.
157
+
158
+ Returns:
159
+ A new ScopeMateTask object
160
+ """
161
+ # This is a simplified version - the full version should include all the prompts
162
+ # from the original code in core.py
163
+
164
+ import uuid
165
+ from .llm import estimate_scope
166
+
167
+ print("=== scopemate Action Plan Builder ===")
168
+
169
+ title = prompt_user("Give a short TITLE for this task")
170
+ summary = prompt_user("What is the primary PURPOSE of this task?")
171
+ outcome_def = prompt_user("Define the desired OUTCOME")
172
+
173
+ # Ask for team assignment
174
+ print("\nTEAM options:")
175
+ print("- Product: Product management team")
176
+ print("- Design: Design and user experience team")
177
+ print("- Frontend: Frontend development team")
178
+ print("- Backend: Backend development team")
179
+ print("- ML: Machine learning team")
180
+ print("- Infra: Infrastructure and DevOps team")
181
+ print("- Testing: QA and testing team")
182
+ print("- Other: Any other team")
183
+ team = prompt_user(
184
+ "TEAM responsible",
185
+ default="Product",
186
+ choices=VALID_TEAMS
187
+ )
188
+
189
+ # Create root task with sensible defaults
190
+ root = ScopeMateTask(
191
+ id=f"TASK-{uuid.uuid4().hex[:6]}",
192
+ title=title,
193
+ purpose=Purpose(
194
+ detailed_description=summary,
195
+ alignment=[],
196
+ urgency="strategic"
197
+ ),
198
+ scope=Scope(
199
+ size="straightforward",
200
+ time_estimate="week",
201
+ dependencies=[],
202
+ risks=[]
203
+ ),
204
+ outcome=Outcome(
205
+ type="customer-facing",
206
+ detailed_outcome_definition=outcome_def,
207
+ acceptance_criteria=[],
208
+ metric=None,
209
+ validation_method=None
210
+ ),
211
+ meta=Meta(
212
+ status="backlog",
213
+ priority=None,
214
+ created=get_utc_now(),
215
+ updated=get_utc_now(),
216
+ due_date=None,
217
+ confidence="medium",
218
+ team=team
219
+ )
220
+ )
221
+
222
+ # Use LLM to estimate scope
223
+ root.scope = estimate_scope(root)
224
+ return root
225
+
226
+
227
+ def print_summary(tasks: List[ScopeMateTask]) -> None:
228
+ """
229
+ Print a hierarchical summary of tasks with complexity indicators and statistics.
230
+
231
+ Args:
232
+ tasks: List of ScopeMateTask objects to summarize
233
+ """
234
+ print("\n=== Task Summary ===")
235
+
236
+ # Build hierarchy maps
237
+ task_map = {t.id: t for t in tasks}
238
+ children_map = {}
239
+
240
+ for t in tasks:
241
+ if t.parent_id:
242
+ if t.parent_id not in children_map:
243
+ children_map[t.parent_id] = []
244
+ children_map[t.parent_id].append(t.id)
245
+
246
+ # Find root tasks (those without parents or with unknown parents)
247
+ root_tasks = [t.id for t in tasks if not t.parent_id or t.parent_id not in task_map]
248
+
249
+ # Print the hierarchy starting from root tasks
250
+ for root_id in root_tasks:
251
+ _print_task_hierarchy(root_id, task_map, children_map)
252
+
253
+ # Print some statistics about task complexity
254
+ complex_count = sum(1 for t in tasks if t.scope.size in ["complex", "uncertain", "pioneering"])
255
+ long_tasks = sum(1 for t in tasks if t.scope.time_estimate in ["sprint", "multi-sprint"])
256
+ leaf_tasks = sum(1 for t_id in task_map if t_id not in children_map)
257
+
258
+ print("\n=== Task Statistics ===")
259
+ print(f"Total tasks: {len(tasks)}")
260
+ print(f"Leaf tasks (no subtasks): {leaf_tasks}")
261
+ print(f"Complex+ tasks: {complex_count} ({(complex_count/len(tasks))*100:.1f}%)")
262
+ print(f"Sprint+ duration tasks: {long_tasks} ({(long_tasks/len(tasks))*100:.1f}%)")
263
+
264
+
265
+ def _print_task_hierarchy(
266
+ task_id: str,
267
+ task_map: Dict[str, ScopeMateTask],
268
+ children_map: Dict[str, List[str]],
269
+ level: int = 0
270
+ ) -> None:
271
+ """
272
+ Recursively print a task and its children with hierarchical indentation.
273
+
274
+ Args:
275
+ task_id: ID of the task to print
276
+ task_map: Dictionary mapping task IDs to tasks
277
+ children_map: Dictionary mapping task IDs to lists of child IDs
278
+ level: Current indentation level
279
+ """
280
+ if task_id not in task_map:
281
+ return
282
+
283
+ t = task_map[task_id]
284
+ indent = " " * level
285
+
286
+ print(f"{indent}{'└─' if level > 0 else ''} [{t.id}] {t.title}")
287
+ print(f"{indent} Scope: {t.scope.size} | Est: {t.scope.time_estimate} | Team: {t.meta.team or 'Not assigned'}")
288
+
289
+ # Print children recursively
290
+ if task_id in children_map:
291
+ for child_id in children_map[task_id]:
292
+ _print_task_hierarchy(child_id, task_map, children_map, level + 1)