alma-memory 0.2.0__py3-none-any.whl → 0.4.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.
@@ -0,0 +1,601 @@
1
+ """
2
+ Progress Tracker.
3
+
4
+ Manages work items and provides progress tracking functionality.
5
+ """
6
+
7
+ import logging
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from typing import Optional, List, Dict, Any, Literal
11
+
12
+ from alma.progress.types import (
13
+ WorkItem,
14
+ WorkItemStatus,
15
+ ProgressLog,
16
+ ProgressSummary,
17
+ )
18
+ from alma.storage.base import StorageBackend
19
+
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ SelectionStrategy = Literal[
25
+ "priority", # Highest priority first
26
+ "blocked_unblock", # Items that unblock others
27
+ "quick_win", # Smallest/easiest first
28
+ "fifo", # First in, first out
29
+ ]
30
+
31
+
32
+ class ProgressTracker:
33
+ """
34
+ Track work item progress.
35
+
36
+ Provides methods for creating, updating, and querying work items,
37
+ as well as generating progress summaries and suggesting next actions.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ project_id: str,
43
+ storage: Optional[StorageBackend] = None,
44
+ ):
45
+ """
46
+ Initialize progress tracker.
47
+
48
+ Args:
49
+ project_id: Project to track progress for
50
+ storage: Optional storage backend for persistence.
51
+ If not provided, uses in-memory storage only.
52
+ """
53
+ self.storage = storage
54
+ self.project_id = project_id
55
+ self._work_items: Dict[str, WorkItem] = {}
56
+ self._progress_logs: List[ProgressLog] = []
57
+
58
+ # ==================== WORK ITEM CRUD ====================
59
+
60
+ def create_work_item(
61
+ self,
62
+ title: str,
63
+ description: str,
64
+ item_type: str = "task",
65
+ agent: Optional[str] = None,
66
+ priority: int = 50,
67
+ parent_id: Optional[str] = None,
68
+ **kwargs,
69
+ ) -> WorkItem:
70
+ """
71
+ Create a new work item.
72
+
73
+ Args:
74
+ title: Short title for the work item
75
+ description: Detailed description
76
+ item_type: Type of work (task, feature, bug, etc.)
77
+ agent: Agent responsible for this item
78
+ priority: Priority 0-100 (higher = more important)
79
+ parent_id: Parent work item ID for hierarchies
80
+ **kwargs: Additional fields for WorkItem
81
+
82
+ Returns:
83
+ Created WorkItem
84
+ """
85
+ item = WorkItem.create(
86
+ project_id=self.project_id,
87
+ title=title,
88
+ description=description,
89
+ item_type=item_type,
90
+ agent=agent,
91
+ priority=priority,
92
+ parent_id=parent_id,
93
+ **kwargs,
94
+ )
95
+ self._work_items[item.id] = item
96
+ logger.info(f"Created work item: {item.id} - {item.title}")
97
+ return item
98
+
99
+ def get_work_item(self, item_id: str) -> Optional[WorkItem]:
100
+ """Get a work item by ID."""
101
+ return self._work_items.get(item_id)
102
+
103
+ def update_work_item(
104
+ self,
105
+ item_id: str,
106
+ **updates,
107
+ ) -> Optional[WorkItem]:
108
+ """
109
+ Update a work item's fields.
110
+
111
+ Args:
112
+ item_id: ID of work item to update
113
+ **updates: Fields to update
114
+
115
+ Returns:
116
+ Updated WorkItem or None if not found
117
+ """
118
+ item = self._work_items.get(item_id)
119
+ if not item:
120
+ logger.warning(f"Work item not found: {item_id}")
121
+ return None
122
+
123
+ for key, value in updates.items():
124
+ if hasattr(item, key):
125
+ setattr(item, key, value)
126
+
127
+ item.updated_at = datetime.now(timezone.utc)
128
+ logger.debug(f"Updated work item: {item_id}")
129
+ return item
130
+
131
+ def delete_work_item(self, item_id: str) -> bool:
132
+ """Delete a work item."""
133
+ if item_id in self._work_items:
134
+ del self._work_items[item_id]
135
+ logger.info(f"Deleted work item: {item_id}")
136
+ return True
137
+ return False
138
+
139
+ # ==================== STATUS UPDATES ====================
140
+
141
+ def update_status(
142
+ self,
143
+ item_id: str,
144
+ status: WorkItemStatus,
145
+ notes: Optional[str] = None,
146
+ ) -> Optional[WorkItem]:
147
+ """
148
+ Update work item status.
149
+
150
+ Args:
151
+ item_id: ID of work item
152
+ status: New status
153
+ notes: Optional notes about the status change
154
+
155
+ Returns:
156
+ Updated WorkItem or None if not found
157
+ """
158
+ item = self._work_items.get(item_id)
159
+ if not item:
160
+ return None
161
+
162
+ old_status = item.status
163
+ item.status = status
164
+ item.updated_at = datetime.now(timezone.utc)
165
+
166
+ # Handle status-specific updates
167
+ if status == "in_progress" and old_status != "in_progress":
168
+ item.start()
169
+ elif status == "done":
170
+ item.complete()
171
+ elif status == "blocked":
172
+ item.block(reason=notes or "")
173
+ elif status == "failed":
174
+ item.fail(reason=notes or "")
175
+
176
+ if notes:
177
+ if "status_notes" not in item.metadata:
178
+ item.metadata["status_notes"] = []
179
+ item.metadata["status_notes"].append({
180
+ "from": old_status,
181
+ "to": status,
182
+ "notes": notes,
183
+ "timestamp": datetime.now(timezone.utc).isoformat(),
184
+ })
185
+
186
+ logger.info(f"Status updated: {item_id} {old_status} -> {status}")
187
+ return item
188
+
189
+ def start_item(self, item_id: str) -> Optional[WorkItem]:
190
+ """Start working on an item."""
191
+ return self.update_status(item_id, "in_progress")
192
+
193
+ def complete_item(
194
+ self,
195
+ item_id: str,
196
+ tests_passing: bool = True,
197
+ ) -> Optional[WorkItem]:
198
+ """Complete an item."""
199
+ item = self.update_status(item_id, "done")
200
+ if item:
201
+ item.tests_passing = tests_passing
202
+ return item
203
+
204
+ def block_item(
205
+ self,
206
+ item_id: str,
207
+ blocked_by: Optional[str] = None,
208
+ reason: str = "",
209
+ ) -> Optional[WorkItem]:
210
+ """Block an item."""
211
+ item = self._work_items.get(item_id)
212
+ if item:
213
+ item.block(blocked_by=blocked_by, reason=reason)
214
+ return item
215
+
216
+ def unblock_item(self, item_id: str) -> Optional[WorkItem]:
217
+ """Unblock an item."""
218
+ item = self._work_items.get(item_id)
219
+ if item and item.status == "blocked":
220
+ item.status = "pending"
221
+ item.blocked_by = []
222
+ item.updated_at = datetime.now(timezone.utc)
223
+ return item
224
+
225
+ # ==================== QUERIES ====================
226
+
227
+ def get_items(
228
+ self,
229
+ status: Optional[WorkItemStatus] = None,
230
+ agent: Optional[str] = None,
231
+ item_type: Optional[str] = None,
232
+ parent_id: Optional[str] = None,
233
+ ) -> List[WorkItem]:
234
+ """
235
+ Get work items with optional filters.
236
+
237
+ Args:
238
+ status: Filter by status
239
+ agent: Filter by agent
240
+ item_type: Filter by type
241
+ parent_id: Filter by parent ID
242
+
243
+ Returns:
244
+ List of matching work items
245
+ """
246
+ results = []
247
+ for item in self._work_items.values():
248
+ if status and item.status != status:
249
+ continue
250
+ if agent and item.agent != agent:
251
+ continue
252
+ if item_type and item.item_type != item_type:
253
+ continue
254
+ if parent_id is not None and item.parent_id != parent_id:
255
+ continue
256
+ results.append(item)
257
+ return results
258
+
259
+ def get_actionable_items(
260
+ self,
261
+ agent: Optional[str] = None,
262
+ ) -> List[WorkItem]:
263
+ """Get items that can be worked on (not blocked, not done)."""
264
+ return [
265
+ item for item in self._work_items.values()
266
+ if item.is_actionable()
267
+ and (agent is None or item.agent == agent or item.agent is None)
268
+ ]
269
+
270
+ def get_blocked_items(
271
+ self,
272
+ agent: Optional[str] = None,
273
+ ) -> List[WorkItem]:
274
+ """Get blocked items."""
275
+ return self.get_items(status="blocked", agent=agent)
276
+
277
+ def get_in_progress_items(
278
+ self,
279
+ agent: Optional[str] = None,
280
+ ) -> List[WorkItem]:
281
+ """Get items currently in progress."""
282
+ return self.get_items(status="in_progress", agent=agent)
283
+
284
+ # ==================== NEXT ITEM SELECTION ====================
285
+
286
+ def get_next_item(
287
+ self,
288
+ agent: Optional[str] = None,
289
+ strategy: SelectionStrategy = "priority",
290
+ ) -> Optional[WorkItem]:
291
+ """
292
+ Get next work item to focus on.
293
+
294
+ Args:
295
+ agent: Filter to specific agent
296
+ strategy: Selection strategy
297
+
298
+ Returns:
299
+ Suggested next work item
300
+ """
301
+ actionable = self.get_actionable_items(agent=agent)
302
+ if not actionable:
303
+ return None
304
+
305
+ if strategy == "priority":
306
+ # Highest priority first
307
+ actionable.sort(key=lambda x: -x.priority)
308
+ return actionable[0]
309
+
310
+ elif strategy == "blocked_unblock":
311
+ # Items that unblock the most other items
312
+ unblock_counts = {}
313
+ for item in actionable:
314
+ count = sum(
315
+ 1 for other in self._work_items.values()
316
+ if item.id in other.blocked_by
317
+ )
318
+ unblock_counts[item.id] = count
319
+ actionable.sort(key=lambda x: -unblock_counts.get(x.id, 0))
320
+ return actionable[0]
321
+
322
+ elif strategy == "quick_win":
323
+ # Prefer items with fewer acceptance criteria (proxy for complexity)
324
+ actionable.sort(key=lambda x: len(x.acceptance_criteria))
325
+ return actionable[0]
326
+
327
+ elif strategy == "fifo":
328
+ # First created first
329
+ actionable.sort(key=lambda x: x.created_at)
330
+ return actionable[0]
331
+
332
+ return actionable[0] if actionable else None
333
+
334
+ # ==================== PROGRESS SUMMARY ====================
335
+
336
+ def get_progress_summary(
337
+ self,
338
+ agent: Optional[str] = None,
339
+ ) -> ProgressSummary:
340
+ """
341
+ Get progress summary.
342
+
343
+ Args:
344
+ agent: Filter to specific agent
345
+
346
+ Returns:
347
+ ProgressSummary with counts and statistics
348
+ """
349
+ items = list(self._work_items.values())
350
+ if agent:
351
+ items = [i for i in items if i.agent == agent or i.agent is None]
352
+
353
+ total = len(items)
354
+ done = len([i for i in items if i.status == "done"])
355
+ in_progress = len([i for i in items if i.status == "in_progress"])
356
+ blocked = len([i for i in items if i.status == "blocked"])
357
+ pending = len([i for i in items if i.status == "pending"])
358
+ failed = len([i for i in items if i.status == "failed"])
359
+
360
+ completion_rate = done / total if total > 0 else 0.0
361
+ success_rate = done / (done + failed) if (done + failed) > 0 else 0.0
362
+
363
+ total_time = sum(i.time_spent_ms for i in items)
364
+ avg_time = total_time / done if done > 0 else 0.0
365
+
366
+ current_item = None
367
+ in_progress_items = [i for i in items if i.status == "in_progress"]
368
+ if in_progress_items:
369
+ current_item = in_progress_items[0]
370
+
371
+ last_activity = None
372
+ if items:
373
+ last_activity = max(i.updated_at for i in items)
374
+
375
+ return ProgressSummary(
376
+ project_id=self.project_id,
377
+ agent=agent,
378
+ total=total,
379
+ done=done,
380
+ in_progress=in_progress,
381
+ blocked=blocked,
382
+ pending=pending,
383
+ failed=failed,
384
+ completion_rate=completion_rate,
385
+ success_rate=success_rate,
386
+ current_item=current_item,
387
+ next_suggested=self.get_next_item(agent=agent),
388
+ blockers=self.get_blocked_items(agent=agent),
389
+ total_time_ms=total_time,
390
+ avg_time_per_item_ms=avg_time,
391
+ last_activity=last_activity,
392
+ )
393
+
394
+ # ==================== PROGRESS LOGGING ====================
395
+
396
+ def log_progress(
397
+ self,
398
+ agent: str,
399
+ session_id: str,
400
+ current_action: str = "",
401
+ ) -> ProgressLog:
402
+ """
403
+ Create a progress snapshot for the current session.
404
+
405
+ Args:
406
+ agent: Agent recording progress
407
+ session_id: Current session ID
408
+ current_action: What is currently being done
409
+
410
+ Returns:
411
+ ProgressLog snapshot
412
+ """
413
+ summary = self.get_progress_summary(agent=agent)
414
+
415
+ log = ProgressLog.create(
416
+ project_id=self.project_id,
417
+ agent=agent,
418
+ session_id=session_id,
419
+ items_total=summary.total,
420
+ items_done=summary.done,
421
+ items_in_progress=summary.in_progress,
422
+ items_blocked=summary.blocked,
423
+ items_pending=summary.pending,
424
+ current_item_id=summary.current_item.id if summary.current_item else None,
425
+ current_action=current_action,
426
+ )
427
+
428
+ self._progress_logs.append(log)
429
+ logger.debug(f"Progress logged: {log.id}")
430
+ return log
431
+
432
+ def get_progress_history(
433
+ self,
434
+ agent: Optional[str] = None,
435
+ session_id: Optional[str] = None,
436
+ limit: int = 10,
437
+ ) -> List[ProgressLog]:
438
+ """Get progress log history."""
439
+ logs = self._progress_logs
440
+
441
+ if agent:
442
+ logs = [l for l in logs if l.agent == agent]
443
+ if session_id:
444
+ logs = [l for l in logs if l.session_id == session_id]
445
+
446
+ # Sort by created_at descending and limit
447
+ logs.sort(key=lambda x: x.created_at, reverse=True)
448
+ return logs[:limit]
449
+
450
+ # ==================== BULK OPERATIONS ====================
451
+
452
+ def create_from_list(
453
+ self,
454
+ items: List[Dict[str, Any]],
455
+ agent: Optional[str] = None,
456
+ ) -> List[WorkItem]:
457
+ """
458
+ Create multiple work items from a list of dicts.
459
+
460
+ Args:
461
+ items: List of item definitions
462
+ agent: Default agent for items
463
+
464
+ Returns:
465
+ List of created WorkItems
466
+ """
467
+ created = []
468
+ for item_def in items:
469
+ if "agent" not in item_def and agent:
470
+ item_def["agent"] = agent
471
+ item = self.create_work_item(**item_def)
472
+ created.append(item)
473
+ return created
474
+
475
+ def expand_to_items(
476
+ self,
477
+ text: str,
478
+ item_type: str = "task",
479
+ agent: Optional[str] = None,
480
+ ) -> List[WorkItem]:
481
+ """
482
+ Parse text into work items (simple line-based parsing).
483
+
484
+ Each line starting with "- " becomes a work item.
485
+
486
+ Args:
487
+ text: Text to parse
488
+ item_type: Type for created items
489
+ agent: Agent for items
490
+
491
+ Returns:
492
+ List of created WorkItems
493
+ """
494
+ items = []
495
+ for line in text.strip().split("\n"):
496
+ line = line.strip()
497
+ if line.startswith("- "):
498
+ title = line[2:].strip()
499
+ if title:
500
+ item = self.create_work_item(
501
+ title=title,
502
+ description=title, # Same as title for now
503
+ item_type=item_type,
504
+ agent=agent,
505
+ )
506
+ items.append(item)
507
+ return items
508
+
509
+ # ==================== SERIALIZATION ====================
510
+
511
+ def to_dict(self) -> Dict[str, Any]:
512
+ """Export tracker state to dict."""
513
+ return {
514
+ "project_id": self.project_id,
515
+ "work_items": [
516
+ {
517
+ "id": item.id,
518
+ "title": item.title,
519
+ "description": item.description,
520
+ "item_type": item.item_type,
521
+ "status": item.status,
522
+ "priority": item.priority,
523
+ "agent": item.agent,
524
+ "parent_id": item.parent_id,
525
+ "blocks": item.blocks,
526
+ "blocked_by": item.blocked_by,
527
+ "tests": item.tests,
528
+ "tests_passing": item.tests_passing,
529
+ "time_spent_ms": item.time_spent_ms,
530
+ "attempt_count": item.attempt_count,
531
+ "created_at": item.created_at.isoformat(),
532
+ "updated_at": item.updated_at.isoformat(),
533
+ "started_at": item.started_at.isoformat() if item.started_at else None,
534
+ "completed_at": item.completed_at.isoformat() if item.completed_at else None,
535
+ "metadata": item.metadata,
536
+ }
537
+ for item in self._work_items.values()
538
+ ],
539
+ "progress_logs": [
540
+ {
541
+ "id": log.id,
542
+ "session_id": log.session_id,
543
+ "agent": log.agent,
544
+ "items_total": log.items_total,
545
+ "items_done": log.items_done,
546
+ "current_action": log.current_action,
547
+ "created_at": log.created_at.isoformat(),
548
+ }
549
+ for log in self._progress_logs
550
+ ],
551
+ }
552
+
553
+ @classmethod
554
+ def from_dict(
555
+ cls,
556
+ data: Dict[str, Any],
557
+ storage: StorageBackend,
558
+ ) -> "ProgressTracker":
559
+ """Load tracker state from dict."""
560
+ tracker = cls(storage=storage, project_id=data["project_id"])
561
+
562
+ for item_data in data.get("work_items", []):
563
+ # Parse dates
564
+ created_at = datetime.fromisoformat(item_data["created_at"])
565
+ updated_at = datetime.fromisoformat(item_data["updated_at"])
566
+ started_at = (
567
+ datetime.fromisoformat(item_data["started_at"])
568
+ if item_data.get("started_at")
569
+ else None
570
+ )
571
+ completed_at = (
572
+ datetime.fromisoformat(item_data["completed_at"])
573
+ if item_data.get("completed_at")
574
+ else None
575
+ )
576
+
577
+ item = WorkItem(
578
+ id=item_data["id"],
579
+ project_id=data["project_id"],
580
+ title=item_data["title"],
581
+ description=item_data["description"],
582
+ item_type=item_data["item_type"],
583
+ status=item_data["status"],
584
+ priority=item_data["priority"],
585
+ agent=item_data.get("agent"),
586
+ parent_id=item_data.get("parent_id"),
587
+ blocks=item_data.get("blocks", []),
588
+ blocked_by=item_data.get("blocked_by", []),
589
+ tests=item_data.get("tests", []),
590
+ tests_passing=item_data.get("tests_passing", False),
591
+ time_spent_ms=item_data.get("time_spent_ms", 0),
592
+ attempt_count=item_data.get("attempt_count", 0),
593
+ created_at=created_at,
594
+ updated_at=updated_at,
595
+ started_at=started_at,
596
+ completed_at=completed_at,
597
+ metadata=item_data.get("metadata", {}),
598
+ )
599
+ tracker._work_items[item.id] = item
600
+
601
+ return tracker