alma-memory 0.5.0__py3-none-any.whl → 0.7.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 (111) hide show
  1. alma/__init__.py +296 -194
  2. alma/compression/__init__.py +33 -0
  3. alma/compression/pipeline.py +980 -0
  4. alma/confidence/__init__.py +47 -47
  5. alma/confidence/engine.py +540 -540
  6. alma/confidence/types.py +351 -351
  7. alma/config/loader.py +157 -157
  8. alma/consolidation/__init__.py +23 -23
  9. alma/consolidation/engine.py +678 -678
  10. alma/consolidation/prompts.py +84 -84
  11. alma/core.py +1189 -322
  12. alma/domains/__init__.py +30 -30
  13. alma/domains/factory.py +359 -359
  14. alma/domains/schemas.py +448 -448
  15. alma/domains/types.py +272 -272
  16. alma/events/__init__.py +75 -75
  17. alma/events/emitter.py +285 -284
  18. alma/events/storage_mixin.py +246 -246
  19. alma/events/types.py +126 -126
  20. alma/events/webhook.py +425 -425
  21. alma/exceptions.py +49 -49
  22. alma/extraction/__init__.py +31 -31
  23. alma/extraction/auto_learner.py +265 -264
  24. alma/extraction/extractor.py +420 -420
  25. alma/graph/__init__.py +106 -81
  26. alma/graph/backends/__init__.py +32 -18
  27. alma/graph/backends/kuzu.py +624 -0
  28. alma/graph/backends/memgraph.py +432 -0
  29. alma/graph/backends/memory.py +236 -236
  30. alma/graph/backends/neo4j.py +417 -417
  31. alma/graph/base.py +159 -159
  32. alma/graph/extraction.py +198 -198
  33. alma/graph/store.py +860 -860
  34. alma/harness/__init__.py +35 -35
  35. alma/harness/base.py +386 -386
  36. alma/harness/domains.py +705 -705
  37. alma/initializer/__init__.py +37 -37
  38. alma/initializer/initializer.py +418 -418
  39. alma/initializer/types.py +250 -250
  40. alma/integration/__init__.py +62 -62
  41. alma/integration/claude_agents.py +444 -432
  42. alma/integration/helena.py +423 -423
  43. alma/integration/victor.py +471 -471
  44. alma/learning/__init__.py +101 -86
  45. alma/learning/decay.py +878 -0
  46. alma/learning/forgetting.py +1446 -1446
  47. alma/learning/heuristic_extractor.py +390 -390
  48. alma/learning/protocols.py +374 -374
  49. alma/learning/validation.py +346 -346
  50. alma/mcp/__init__.py +123 -45
  51. alma/mcp/__main__.py +156 -156
  52. alma/mcp/resources.py +122 -122
  53. alma/mcp/server.py +955 -591
  54. alma/mcp/tools.py +3254 -511
  55. alma/observability/__init__.py +91 -0
  56. alma/observability/config.py +302 -0
  57. alma/observability/guidelines.py +170 -0
  58. alma/observability/logging.py +424 -0
  59. alma/observability/metrics.py +583 -0
  60. alma/observability/tracing.py +440 -0
  61. alma/progress/__init__.py +21 -21
  62. alma/progress/tracker.py +607 -607
  63. alma/progress/types.py +250 -250
  64. alma/retrieval/__init__.py +134 -53
  65. alma/retrieval/budget.py +525 -0
  66. alma/retrieval/cache.py +1304 -1061
  67. alma/retrieval/embeddings.py +202 -202
  68. alma/retrieval/engine.py +850 -366
  69. alma/retrieval/modes.py +365 -0
  70. alma/retrieval/progressive.py +560 -0
  71. alma/retrieval/scoring.py +344 -344
  72. alma/retrieval/trust_scoring.py +637 -0
  73. alma/retrieval/verification.py +797 -0
  74. alma/session/__init__.py +19 -19
  75. alma/session/manager.py +442 -399
  76. alma/session/types.py +288 -288
  77. alma/storage/__init__.py +101 -61
  78. alma/storage/archive.py +233 -0
  79. alma/storage/azure_cosmos.py +1259 -1048
  80. alma/storage/base.py +1083 -525
  81. alma/storage/chroma.py +1443 -1443
  82. alma/storage/constants.py +103 -0
  83. alma/storage/file_based.py +614 -619
  84. alma/storage/migrations/__init__.py +21 -0
  85. alma/storage/migrations/base.py +321 -0
  86. alma/storage/migrations/runner.py +323 -0
  87. alma/storage/migrations/version_stores.py +337 -0
  88. alma/storage/migrations/versions/__init__.py +11 -0
  89. alma/storage/migrations/versions/v1_0_0.py +373 -0
  90. alma/storage/migrations/versions/v1_1_0_workflow_context.py +551 -0
  91. alma/storage/pinecone.py +1080 -1080
  92. alma/storage/postgresql.py +1948 -1452
  93. alma/storage/qdrant.py +1306 -1306
  94. alma/storage/sqlite_local.py +3041 -1358
  95. alma/testing/__init__.py +46 -0
  96. alma/testing/factories.py +301 -0
  97. alma/testing/mocks.py +389 -0
  98. alma/types.py +292 -264
  99. alma/utils/__init__.py +19 -0
  100. alma/utils/tokenizer.py +521 -0
  101. alma/workflow/__init__.py +83 -0
  102. alma/workflow/artifacts.py +170 -0
  103. alma/workflow/checkpoint.py +311 -0
  104. alma/workflow/context.py +228 -0
  105. alma/workflow/outcomes.py +189 -0
  106. alma/workflow/reducers.py +393 -0
  107. {alma_memory-0.5.0.dist-info → alma_memory-0.7.0.dist-info}/METADATA +244 -72
  108. alma_memory-0.7.0.dist-info/RECORD +112 -0
  109. alma_memory-0.5.0.dist-info/RECORD +0 -76
  110. {alma_memory-0.5.0.dist-info → alma_memory-0.7.0.dist-info}/WHEEL +0 -0
  111. {alma_memory-0.5.0.dist-info → alma_memory-0.7.0.dist-info}/top_level.txt +0 -0
alma/progress/tracker.py CHANGED
@@ -1,607 +1,607 @@
1
- """
2
- Progress Tracker.
3
-
4
- Manages work items and provides progress tracking functionality.
5
- """
6
-
7
- import logging
8
- from datetime import datetime, timezone
9
- from typing import Any, Dict, List, Literal, Optional
10
-
11
- from alma.progress.types import (
12
- ProgressLog,
13
- ProgressSummary,
14
- WorkItem,
15
- WorkItemStatus,
16
- )
17
- from alma.storage.base import StorageBackend
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
-
22
- SelectionStrategy = Literal[
23
- "priority", # Highest priority first
24
- "blocked_unblock", # Items that unblock others
25
- "quick_win", # Smallest/easiest first
26
- "fifo", # First in, first out
27
- ]
28
-
29
-
30
- class ProgressTracker:
31
- """
32
- Track work item progress.
33
-
34
- Provides methods for creating, updating, and querying work items,
35
- as well as generating progress summaries and suggesting next actions.
36
- """
37
-
38
- def __init__(
39
- self,
40
- project_id: str,
41
- storage: Optional[StorageBackend] = None,
42
- ):
43
- """
44
- Initialize progress tracker.
45
-
46
- Args:
47
- project_id: Project to track progress for
48
- storage: Optional storage backend for persistence.
49
- If not provided, uses in-memory storage only.
50
- """
51
- self.storage = storage
52
- self.project_id = project_id
53
- self._work_items: Dict[str, WorkItem] = {}
54
- self._progress_logs: List[ProgressLog] = []
55
-
56
- # ==================== WORK ITEM CRUD ====================
57
-
58
- def create_work_item(
59
- self,
60
- title: str,
61
- description: str,
62
- item_type: str = "task",
63
- agent: Optional[str] = None,
64
- priority: int = 50,
65
- parent_id: Optional[str] = None,
66
- **kwargs,
67
- ) -> WorkItem:
68
- """
69
- Create a new work item.
70
-
71
- Args:
72
- title: Short title for the work item
73
- description: Detailed description
74
- item_type: Type of work (task, feature, bug, etc.)
75
- agent: Agent responsible for this item
76
- priority: Priority 0-100 (higher = more important)
77
- parent_id: Parent work item ID for hierarchies
78
- **kwargs: Additional fields for WorkItem
79
-
80
- Returns:
81
- Created WorkItem
82
- """
83
- item = WorkItem.create(
84
- project_id=self.project_id,
85
- title=title,
86
- description=description,
87
- item_type=item_type,
88
- agent=agent,
89
- priority=priority,
90
- parent_id=parent_id,
91
- **kwargs,
92
- )
93
- self._work_items[item.id] = item
94
- logger.info(f"Created work item: {item.id} - {item.title}")
95
- return item
96
-
97
- def get_work_item(self, item_id: str) -> Optional[WorkItem]:
98
- """Get a work item by ID."""
99
- return self._work_items.get(item_id)
100
-
101
- def update_work_item(
102
- self,
103
- item_id: str,
104
- **updates,
105
- ) -> Optional[WorkItem]:
106
- """
107
- Update a work item's fields.
108
-
109
- Args:
110
- item_id: ID of work item to update
111
- **updates: Fields to update
112
-
113
- Returns:
114
- Updated WorkItem or None if not found
115
- """
116
- item = self._work_items.get(item_id)
117
- if not item:
118
- logger.warning(f"Work item not found: {item_id}")
119
- return None
120
-
121
- for key, value in updates.items():
122
- if hasattr(item, key):
123
- setattr(item, key, value)
124
-
125
- item.updated_at = datetime.now(timezone.utc)
126
- logger.debug(f"Updated work item: {item_id}")
127
- return item
128
-
129
- def delete_work_item(self, item_id: str) -> bool:
130
- """Delete a work item."""
131
- if item_id in self._work_items:
132
- del self._work_items[item_id]
133
- logger.info(f"Deleted work item: {item_id}")
134
- return True
135
- return False
136
-
137
- # ==================== STATUS UPDATES ====================
138
-
139
- def update_status(
140
- self,
141
- item_id: str,
142
- status: WorkItemStatus,
143
- notes: Optional[str] = None,
144
- ) -> Optional[WorkItem]:
145
- """
146
- Update work item status.
147
-
148
- Args:
149
- item_id: ID of work item
150
- status: New status
151
- notes: Optional notes about the status change
152
-
153
- Returns:
154
- Updated WorkItem or None if not found
155
- """
156
- item = self._work_items.get(item_id)
157
- if not item:
158
- return None
159
-
160
- old_status = item.status
161
- item.status = status
162
- item.updated_at = datetime.now(timezone.utc)
163
-
164
- # Handle status-specific updates
165
- if status == "in_progress" and old_status != "in_progress":
166
- item.start()
167
- elif status == "done":
168
- item.complete()
169
- elif status == "blocked":
170
- item.block(reason=notes or "")
171
- elif status == "failed":
172
- item.fail(reason=notes or "")
173
-
174
- if notes:
175
- if "status_notes" not in item.metadata:
176
- item.metadata["status_notes"] = []
177
- item.metadata["status_notes"].append(
178
- {
179
- "from": old_status,
180
- "to": status,
181
- "notes": notes,
182
- "timestamp": datetime.now(timezone.utc).isoformat(),
183
- }
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
266
- for item in self._work_items.values()
267
- if item.is_actionable()
268
- and (agent is None or item.agent == agent or item.agent is None)
269
- ]
270
-
271
- def get_blocked_items(
272
- self,
273
- agent: Optional[str] = None,
274
- ) -> List[WorkItem]:
275
- """Get blocked items."""
276
- return self.get_items(status="blocked", agent=agent)
277
-
278
- def get_in_progress_items(
279
- self,
280
- agent: Optional[str] = None,
281
- ) -> List[WorkItem]:
282
- """Get items currently in progress."""
283
- return self.get_items(status="in_progress", agent=agent)
284
-
285
- # ==================== NEXT ITEM SELECTION ====================
286
-
287
- def get_next_item(
288
- self,
289
- agent: Optional[str] = None,
290
- strategy: SelectionStrategy = "priority",
291
- ) -> Optional[WorkItem]:
292
- """
293
- Get next work item to focus on.
294
-
295
- Args:
296
- agent: Filter to specific agent
297
- strategy: Selection strategy
298
-
299
- Returns:
300
- Suggested next work item
301
- """
302
- actionable = self.get_actionable_items(agent=agent)
303
- if not actionable:
304
- return None
305
-
306
- if strategy == "priority":
307
- # Highest priority first
308
- actionable.sort(key=lambda x: -x.priority)
309
- return actionable[0]
310
-
311
- elif strategy == "blocked_unblock":
312
- # Items that unblock the most other items
313
- unblock_counts = {}
314
- for item in actionable:
315
- count = sum(
316
- 1
317
- for other in self._work_items.values()
318
- if item.id in other.blocked_by
319
- )
320
- unblock_counts[item.id] = count
321
- actionable.sort(key=lambda x: -unblock_counts.get(x.id, 0))
322
- return actionable[0]
323
-
324
- elif strategy == "quick_win":
325
- # Prefer items with fewer acceptance criteria (proxy for complexity)
326
- actionable.sort(key=lambda x: len(x.acceptance_criteria))
327
- return actionable[0]
328
-
329
- elif strategy == "fifo":
330
- # First created first
331
- actionable.sort(key=lambda x: x.created_at)
332
- return actionable[0]
333
-
334
- return actionable[0] if actionable else None
335
-
336
- # ==================== PROGRESS SUMMARY ====================
337
-
338
- def get_progress_summary(
339
- self,
340
- agent: Optional[str] = None,
341
- ) -> ProgressSummary:
342
- """
343
- Get progress summary.
344
-
345
- Args:
346
- agent: Filter to specific agent
347
-
348
- Returns:
349
- ProgressSummary with counts and statistics
350
- """
351
- items = list(self._work_items.values())
352
- if agent:
353
- items = [i for i in items if i.agent == agent or i.agent is None]
354
-
355
- total = len(items)
356
- done = len([i for i in items if i.status == "done"])
357
- in_progress = len([i for i in items if i.status == "in_progress"])
358
- blocked = len([i for i in items if i.status == "blocked"])
359
- pending = len([i for i in items if i.status == "pending"])
360
- failed = len([i for i in items if i.status == "failed"])
361
-
362
- completion_rate = done / total if total > 0 else 0.0
363
- success_rate = done / (done + failed) if (done + failed) > 0 else 0.0
364
-
365
- total_time = sum(i.time_spent_ms for i in items)
366
- avg_time = total_time / done if done > 0 else 0.0
367
-
368
- current_item = None
369
- in_progress_items = [i for i in items if i.status == "in_progress"]
370
- if in_progress_items:
371
- current_item = in_progress_items[0]
372
-
373
- last_activity = None
374
- if items:
375
- last_activity = max(i.updated_at for i in items)
376
-
377
- return ProgressSummary(
378
- project_id=self.project_id,
379
- agent=agent,
380
- total=total,
381
- done=done,
382
- in_progress=in_progress,
383
- blocked=blocked,
384
- pending=pending,
385
- failed=failed,
386
- completion_rate=completion_rate,
387
- success_rate=success_rate,
388
- current_item=current_item,
389
- next_suggested=self.get_next_item(agent=agent),
390
- blockers=self.get_blocked_items(agent=agent),
391
- total_time_ms=total_time,
392
- avg_time_per_item_ms=avg_time,
393
- last_activity=last_activity,
394
- )
395
-
396
- # ==================== PROGRESS LOGGING ====================
397
-
398
- def log_progress(
399
- self,
400
- agent: str,
401
- session_id: str,
402
- current_action: str = "",
403
- ) -> ProgressLog:
404
- """
405
- Create a progress snapshot for the current session.
406
-
407
- Args:
408
- agent: Agent recording progress
409
- session_id: Current session ID
410
- current_action: What is currently being done
411
-
412
- Returns:
413
- ProgressLog snapshot
414
- """
415
- summary = self.get_progress_summary(agent=agent)
416
-
417
- log = ProgressLog.create(
418
- project_id=self.project_id,
419
- agent=agent,
420
- session_id=session_id,
421
- items_total=summary.total,
422
- items_done=summary.done,
423
- items_in_progress=summary.in_progress,
424
- items_blocked=summary.blocked,
425
- items_pending=summary.pending,
426
- current_item_id=summary.current_item.id if summary.current_item else None,
427
- current_action=current_action,
428
- )
429
-
430
- self._progress_logs.append(log)
431
- logger.debug(f"Progress logged: {log.id}")
432
- return log
433
-
434
- def get_progress_history(
435
- self,
436
- agent: Optional[str] = None,
437
- session_id: Optional[str] = None,
438
- limit: int = 10,
439
- ) -> List[ProgressLog]:
440
- """Get progress log history."""
441
- logs = self._progress_logs
442
-
443
- if agent:
444
- logs = [log for log in logs if log.agent == agent]
445
- if session_id:
446
- logs = [log for log in logs if log.session_id == session_id]
447
-
448
- # Sort by created_at descending and limit
449
- logs.sort(key=lambda x: x.created_at, reverse=True)
450
- return logs[:limit]
451
-
452
- # ==================== BULK OPERATIONS ====================
453
-
454
- def create_from_list(
455
- self,
456
- items: List[Dict[str, Any]],
457
- agent: Optional[str] = None,
458
- ) -> List[WorkItem]:
459
- """
460
- Create multiple work items from a list of dicts.
461
-
462
- Args:
463
- items: List of item definitions
464
- agent: Default agent for items
465
-
466
- Returns:
467
- List of created WorkItems
468
- """
469
- created = []
470
- for item_def in items:
471
- if "agent" not in item_def and agent:
472
- item_def["agent"] = agent
473
- item = self.create_work_item(**item_def)
474
- created.append(item)
475
- return created
476
-
477
- def expand_to_items(
478
- self,
479
- text: str,
480
- item_type: str = "task",
481
- agent: Optional[str] = None,
482
- ) -> List[WorkItem]:
483
- """
484
- Parse text into work items (simple line-based parsing).
485
-
486
- Each line starting with "- " becomes a work item.
487
-
488
- Args:
489
- text: Text to parse
490
- item_type: Type for created items
491
- agent: Agent for items
492
-
493
- Returns:
494
- List of created WorkItems
495
- """
496
- items = []
497
- for line in text.strip().split("\n"):
498
- line = line.strip()
499
- if line.startswith("- "):
500
- title = line[2:].strip()
501
- if title:
502
- item = self.create_work_item(
503
- title=title,
504
- description=title, # Same as title for now
505
- item_type=item_type,
506
- agent=agent,
507
- )
508
- items.append(item)
509
- return items
510
-
511
- # ==================== SERIALIZATION ====================
512
-
513
- def to_dict(self) -> Dict[str, Any]:
514
- """Export tracker state to dict."""
515
- return {
516
- "project_id": self.project_id,
517
- "work_items": [
518
- {
519
- "id": item.id,
520
- "title": item.title,
521
- "description": item.description,
522
- "item_type": item.item_type,
523
- "status": item.status,
524
- "priority": item.priority,
525
- "agent": item.agent,
526
- "parent_id": item.parent_id,
527
- "blocks": item.blocks,
528
- "blocked_by": item.blocked_by,
529
- "tests": item.tests,
530
- "tests_passing": item.tests_passing,
531
- "time_spent_ms": item.time_spent_ms,
532
- "attempt_count": item.attempt_count,
533
- "created_at": item.created_at.isoformat(),
534
- "updated_at": item.updated_at.isoformat(),
535
- "started_at": (
536
- item.started_at.isoformat() if item.started_at else None
537
- ),
538
- "completed_at": (
539
- item.completed_at.isoformat() if item.completed_at else None
540
- ),
541
- "metadata": item.metadata,
542
- }
543
- for item in self._work_items.values()
544
- ],
545
- "progress_logs": [
546
- {
547
- "id": log.id,
548
- "session_id": log.session_id,
549
- "agent": log.agent,
550
- "items_total": log.items_total,
551
- "items_done": log.items_done,
552
- "current_action": log.current_action,
553
- "created_at": log.created_at.isoformat(),
554
- }
555
- for log in self._progress_logs
556
- ],
557
- }
558
-
559
- @classmethod
560
- def from_dict(
561
- cls,
562
- data: Dict[str, Any],
563
- storage: StorageBackend,
564
- ) -> "ProgressTracker":
565
- """Load tracker state from dict."""
566
- tracker = cls(storage=storage, project_id=data["project_id"])
567
-
568
- for item_data in data.get("work_items", []):
569
- # Parse dates
570
- created_at = datetime.fromisoformat(item_data["created_at"])
571
- updated_at = datetime.fromisoformat(item_data["updated_at"])
572
- started_at = (
573
- datetime.fromisoformat(item_data["started_at"])
574
- if item_data.get("started_at")
575
- else None
576
- )
577
- completed_at = (
578
- datetime.fromisoformat(item_data["completed_at"])
579
- if item_data.get("completed_at")
580
- else None
581
- )
582
-
583
- item = WorkItem(
584
- id=item_data["id"],
585
- project_id=data["project_id"],
586
- title=item_data["title"],
587
- description=item_data["description"],
588
- item_type=item_data["item_type"],
589
- status=item_data["status"],
590
- priority=item_data["priority"],
591
- agent=item_data.get("agent"),
592
- parent_id=item_data.get("parent_id"),
593
- blocks=item_data.get("blocks", []),
594
- blocked_by=item_data.get("blocked_by", []),
595
- tests=item_data.get("tests", []),
596
- tests_passing=item_data.get("tests_passing", False),
597
- time_spent_ms=item_data.get("time_spent_ms", 0),
598
- attempt_count=item_data.get("attempt_count", 0),
599
- created_at=created_at,
600
- updated_at=updated_at,
601
- started_at=started_at,
602
- completed_at=completed_at,
603
- metadata=item_data.get("metadata", {}),
604
- )
605
- tracker._work_items[item.id] = item
606
-
607
- return tracker
1
+ """
2
+ Progress Tracker.
3
+
4
+ Manages work items and provides progress tracking functionality.
5
+ """
6
+
7
+ import logging
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Dict, List, Literal, Optional
10
+
11
+ from alma.progress.types import (
12
+ ProgressLog,
13
+ ProgressSummary,
14
+ WorkItem,
15
+ WorkItemStatus,
16
+ )
17
+ from alma.storage.base import StorageBackend
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ SelectionStrategy = Literal[
23
+ "priority", # Highest priority first
24
+ "blocked_unblock", # Items that unblock others
25
+ "quick_win", # Smallest/easiest first
26
+ "fifo", # First in, first out
27
+ ]
28
+
29
+
30
+ class ProgressTracker:
31
+ """
32
+ Track work item progress.
33
+
34
+ Provides methods for creating, updating, and querying work items,
35
+ as well as generating progress summaries and suggesting next actions.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ project_id: str,
41
+ storage: Optional[StorageBackend] = None,
42
+ ):
43
+ """
44
+ Initialize progress tracker.
45
+
46
+ Args:
47
+ project_id: Project to track progress for
48
+ storage: Optional storage backend for persistence.
49
+ If not provided, uses in-memory storage only.
50
+ """
51
+ self.storage = storage
52
+ self.project_id = project_id
53
+ self._work_items: Dict[str, WorkItem] = {}
54
+ self._progress_logs: List[ProgressLog] = []
55
+
56
+ # ==================== WORK ITEM CRUD ====================
57
+
58
+ def create_work_item(
59
+ self,
60
+ title: str,
61
+ description: str,
62
+ item_type: str = "task",
63
+ agent: Optional[str] = None,
64
+ priority: int = 50,
65
+ parent_id: Optional[str] = None,
66
+ **kwargs,
67
+ ) -> WorkItem:
68
+ """
69
+ Create a new work item.
70
+
71
+ Args:
72
+ title: Short title for the work item
73
+ description: Detailed description
74
+ item_type: Type of work (task, feature, bug, etc.)
75
+ agent: Agent responsible for this item
76
+ priority: Priority 0-100 (higher = more important)
77
+ parent_id: Parent work item ID for hierarchies
78
+ **kwargs: Additional fields for WorkItem
79
+
80
+ Returns:
81
+ Created WorkItem
82
+ """
83
+ item = WorkItem.create(
84
+ project_id=self.project_id,
85
+ title=title,
86
+ description=description,
87
+ item_type=item_type,
88
+ agent=agent,
89
+ priority=priority,
90
+ parent_id=parent_id,
91
+ **kwargs,
92
+ )
93
+ self._work_items[item.id] = item
94
+ logger.debug(f"Created work item: {item.id} - {item.title}")
95
+ return item
96
+
97
+ def get_work_item(self, item_id: str) -> Optional[WorkItem]:
98
+ """Get a work item by ID."""
99
+ return self._work_items.get(item_id)
100
+
101
+ def update_work_item(
102
+ self,
103
+ item_id: str,
104
+ **updates,
105
+ ) -> Optional[WorkItem]:
106
+ """
107
+ Update a work item's fields.
108
+
109
+ Args:
110
+ item_id: ID of work item to update
111
+ **updates: Fields to update
112
+
113
+ Returns:
114
+ Updated WorkItem or None if not found
115
+ """
116
+ item = self._work_items.get(item_id)
117
+ if not item:
118
+ logger.warning(f"Work item not found: {item_id}")
119
+ return None
120
+
121
+ for key, value in updates.items():
122
+ if hasattr(item, key):
123
+ setattr(item, key, value)
124
+
125
+ item.updated_at = datetime.now(timezone.utc)
126
+ logger.debug(f"Updated work item: {item_id}")
127
+ return item
128
+
129
+ def delete_work_item(self, item_id: str) -> bool:
130
+ """Delete a work item."""
131
+ if item_id in self._work_items:
132
+ del self._work_items[item_id]
133
+ logger.debug(f"Deleted work item: {item_id}")
134
+ return True
135
+ return False
136
+
137
+ # ==================== STATUS UPDATES ====================
138
+
139
+ def update_status(
140
+ self,
141
+ item_id: str,
142
+ status: WorkItemStatus,
143
+ notes: Optional[str] = None,
144
+ ) -> Optional[WorkItem]:
145
+ """
146
+ Update work item status.
147
+
148
+ Args:
149
+ item_id: ID of work item
150
+ status: New status
151
+ notes: Optional notes about the status change
152
+
153
+ Returns:
154
+ Updated WorkItem or None if not found
155
+ """
156
+ item = self._work_items.get(item_id)
157
+ if not item:
158
+ return None
159
+
160
+ old_status = item.status
161
+ item.status = status
162
+ item.updated_at = datetime.now(timezone.utc)
163
+
164
+ # Handle status-specific updates
165
+ if status == "in_progress" and old_status != "in_progress":
166
+ item.start()
167
+ elif status == "done":
168
+ item.complete()
169
+ elif status == "blocked":
170
+ item.block(reason=notes or "")
171
+ elif status == "failed":
172
+ item.fail(reason=notes or "")
173
+
174
+ if notes:
175
+ if "status_notes" not in item.metadata:
176
+ item.metadata["status_notes"] = []
177
+ item.metadata["status_notes"].append(
178
+ {
179
+ "from": old_status,
180
+ "to": status,
181
+ "notes": notes,
182
+ "timestamp": datetime.now(timezone.utc).isoformat(),
183
+ }
184
+ )
185
+
186
+ logger.debug(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
266
+ for item in self._work_items.values()
267
+ if item.is_actionable()
268
+ and (agent is None or item.agent == agent or item.agent is None)
269
+ ]
270
+
271
+ def get_blocked_items(
272
+ self,
273
+ agent: Optional[str] = None,
274
+ ) -> List[WorkItem]:
275
+ """Get blocked items."""
276
+ return self.get_items(status="blocked", agent=agent)
277
+
278
+ def get_in_progress_items(
279
+ self,
280
+ agent: Optional[str] = None,
281
+ ) -> List[WorkItem]:
282
+ """Get items currently in progress."""
283
+ return self.get_items(status="in_progress", agent=agent)
284
+
285
+ # ==================== NEXT ITEM SELECTION ====================
286
+
287
+ def get_next_item(
288
+ self,
289
+ agent: Optional[str] = None,
290
+ strategy: SelectionStrategy = "priority",
291
+ ) -> Optional[WorkItem]:
292
+ """
293
+ Get next work item to focus on.
294
+
295
+ Args:
296
+ agent: Filter to specific agent
297
+ strategy: Selection strategy
298
+
299
+ Returns:
300
+ Suggested next work item
301
+ """
302
+ actionable = self.get_actionable_items(agent=agent)
303
+ if not actionable:
304
+ return None
305
+
306
+ if strategy == "priority":
307
+ # Highest priority first
308
+ actionable.sort(key=lambda x: -x.priority)
309
+ return actionable[0]
310
+
311
+ elif strategy == "blocked_unblock":
312
+ # Items that unblock the most other items
313
+ unblock_counts = {}
314
+ for item in actionable:
315
+ count = sum(
316
+ 1
317
+ for other in self._work_items.values()
318
+ if item.id in other.blocked_by
319
+ )
320
+ unblock_counts[item.id] = count
321
+ actionable.sort(key=lambda x: -unblock_counts.get(x.id, 0))
322
+ return actionable[0]
323
+
324
+ elif strategy == "quick_win":
325
+ # Prefer items with fewer acceptance criteria (proxy for complexity)
326
+ actionable.sort(key=lambda x: len(x.acceptance_criteria))
327
+ return actionable[0]
328
+
329
+ elif strategy == "fifo":
330
+ # First created first
331
+ actionable.sort(key=lambda x: x.created_at)
332
+ return actionable[0]
333
+
334
+ return actionable[0] if actionable else None
335
+
336
+ # ==================== PROGRESS SUMMARY ====================
337
+
338
+ def get_progress_summary(
339
+ self,
340
+ agent: Optional[str] = None,
341
+ ) -> ProgressSummary:
342
+ """
343
+ Get progress summary.
344
+
345
+ Args:
346
+ agent: Filter to specific agent
347
+
348
+ Returns:
349
+ ProgressSummary with counts and statistics
350
+ """
351
+ items = list(self._work_items.values())
352
+ if agent:
353
+ items = [i for i in items if i.agent == agent or i.agent is None]
354
+
355
+ total = len(items)
356
+ done = len([i for i in items if i.status == "done"])
357
+ in_progress = len([i for i in items if i.status == "in_progress"])
358
+ blocked = len([i for i in items if i.status == "blocked"])
359
+ pending = len([i for i in items if i.status == "pending"])
360
+ failed = len([i for i in items if i.status == "failed"])
361
+
362
+ completion_rate = done / total if total > 0 else 0.0
363
+ success_rate = done / (done + failed) if (done + failed) > 0 else 0.0
364
+
365
+ total_time = sum(i.time_spent_ms for i in items)
366
+ avg_time = total_time / done if done > 0 else 0.0
367
+
368
+ current_item = None
369
+ in_progress_items = [i for i in items if i.status == "in_progress"]
370
+ if in_progress_items:
371
+ current_item = in_progress_items[0]
372
+
373
+ last_activity = None
374
+ if items:
375
+ last_activity = max(i.updated_at for i in items)
376
+
377
+ return ProgressSummary(
378
+ project_id=self.project_id,
379
+ agent=agent,
380
+ total=total,
381
+ done=done,
382
+ in_progress=in_progress,
383
+ blocked=blocked,
384
+ pending=pending,
385
+ failed=failed,
386
+ completion_rate=completion_rate,
387
+ success_rate=success_rate,
388
+ current_item=current_item,
389
+ next_suggested=self.get_next_item(agent=agent),
390
+ blockers=self.get_blocked_items(agent=agent),
391
+ total_time_ms=total_time,
392
+ avg_time_per_item_ms=avg_time,
393
+ last_activity=last_activity,
394
+ )
395
+
396
+ # ==================== PROGRESS LOGGING ====================
397
+
398
+ def log_progress(
399
+ self,
400
+ agent: str,
401
+ session_id: str,
402
+ current_action: str = "",
403
+ ) -> ProgressLog:
404
+ """
405
+ Create a progress snapshot for the current session.
406
+
407
+ Args:
408
+ agent: Agent recording progress
409
+ session_id: Current session ID
410
+ current_action: What is currently being done
411
+
412
+ Returns:
413
+ ProgressLog snapshot
414
+ """
415
+ summary = self.get_progress_summary(agent=agent)
416
+
417
+ log = ProgressLog.create(
418
+ project_id=self.project_id,
419
+ agent=agent,
420
+ session_id=session_id,
421
+ items_total=summary.total,
422
+ items_done=summary.done,
423
+ items_in_progress=summary.in_progress,
424
+ items_blocked=summary.blocked,
425
+ items_pending=summary.pending,
426
+ current_item_id=summary.current_item.id if summary.current_item else None,
427
+ current_action=current_action,
428
+ )
429
+
430
+ self._progress_logs.append(log)
431
+ logger.debug(f"Progress logged: {log.id}")
432
+ return log
433
+
434
+ def get_progress_history(
435
+ self,
436
+ agent: Optional[str] = None,
437
+ session_id: Optional[str] = None,
438
+ limit: int = 10,
439
+ ) -> List[ProgressLog]:
440
+ """Get progress log history."""
441
+ logs = self._progress_logs
442
+
443
+ if agent:
444
+ logs = [log for log in logs if log.agent == agent]
445
+ if session_id:
446
+ logs = [log for log in logs if log.session_id == session_id]
447
+
448
+ # Sort by created_at descending and limit
449
+ logs.sort(key=lambda x: x.created_at, reverse=True)
450
+ return logs[:limit]
451
+
452
+ # ==================== BULK OPERATIONS ====================
453
+
454
+ def create_from_list(
455
+ self,
456
+ items: List[Dict[str, Any]],
457
+ agent: Optional[str] = None,
458
+ ) -> List[WorkItem]:
459
+ """
460
+ Create multiple work items from a list of dicts.
461
+
462
+ Args:
463
+ items: List of item definitions
464
+ agent: Default agent for items
465
+
466
+ Returns:
467
+ List of created WorkItems
468
+ """
469
+ created = []
470
+ for item_def in items:
471
+ if "agent" not in item_def and agent:
472
+ item_def["agent"] = agent
473
+ item = self.create_work_item(**item_def)
474
+ created.append(item)
475
+ return created
476
+
477
+ def expand_to_items(
478
+ self,
479
+ text: str,
480
+ item_type: str = "task",
481
+ agent: Optional[str] = None,
482
+ ) -> List[WorkItem]:
483
+ """
484
+ Parse text into work items (simple line-based parsing).
485
+
486
+ Each line starting with "- " becomes a work item.
487
+
488
+ Args:
489
+ text: Text to parse
490
+ item_type: Type for created items
491
+ agent: Agent for items
492
+
493
+ Returns:
494
+ List of created WorkItems
495
+ """
496
+ items = []
497
+ for line in text.strip().split("\n"):
498
+ line = line.strip()
499
+ if line.startswith("- "):
500
+ title = line[2:].strip()
501
+ if title:
502
+ item = self.create_work_item(
503
+ title=title,
504
+ description=title, # Same as title for now
505
+ item_type=item_type,
506
+ agent=agent,
507
+ )
508
+ items.append(item)
509
+ return items
510
+
511
+ # ==================== SERIALIZATION ====================
512
+
513
+ def to_dict(self) -> Dict[str, Any]:
514
+ """Export tracker state to dict."""
515
+ return {
516
+ "project_id": self.project_id,
517
+ "work_items": [
518
+ {
519
+ "id": item.id,
520
+ "title": item.title,
521
+ "description": item.description,
522
+ "item_type": item.item_type,
523
+ "status": item.status,
524
+ "priority": item.priority,
525
+ "agent": item.agent,
526
+ "parent_id": item.parent_id,
527
+ "blocks": item.blocks,
528
+ "blocked_by": item.blocked_by,
529
+ "tests": item.tests,
530
+ "tests_passing": item.tests_passing,
531
+ "time_spent_ms": item.time_spent_ms,
532
+ "attempt_count": item.attempt_count,
533
+ "created_at": item.created_at.isoformat(),
534
+ "updated_at": item.updated_at.isoformat(),
535
+ "started_at": (
536
+ item.started_at.isoformat() if item.started_at else None
537
+ ),
538
+ "completed_at": (
539
+ item.completed_at.isoformat() if item.completed_at else None
540
+ ),
541
+ "metadata": item.metadata,
542
+ }
543
+ for item in self._work_items.values()
544
+ ],
545
+ "progress_logs": [
546
+ {
547
+ "id": log.id,
548
+ "session_id": log.session_id,
549
+ "agent": log.agent,
550
+ "items_total": log.items_total,
551
+ "items_done": log.items_done,
552
+ "current_action": log.current_action,
553
+ "created_at": log.created_at.isoformat(),
554
+ }
555
+ for log in self._progress_logs
556
+ ],
557
+ }
558
+
559
+ @classmethod
560
+ def from_dict(
561
+ cls,
562
+ data: Dict[str, Any],
563
+ storage: StorageBackend,
564
+ ) -> "ProgressTracker":
565
+ """Load tracker state from dict."""
566
+ tracker = cls(storage=storage, project_id=data["project_id"])
567
+
568
+ for item_data in data.get("work_items", []):
569
+ # Parse dates
570
+ created_at = datetime.fromisoformat(item_data["created_at"])
571
+ updated_at = datetime.fromisoformat(item_data["updated_at"])
572
+ started_at = (
573
+ datetime.fromisoformat(item_data["started_at"])
574
+ if item_data.get("started_at")
575
+ else None
576
+ )
577
+ completed_at = (
578
+ datetime.fromisoformat(item_data["completed_at"])
579
+ if item_data.get("completed_at")
580
+ else None
581
+ )
582
+
583
+ item = WorkItem(
584
+ id=item_data["id"],
585
+ project_id=data["project_id"],
586
+ title=item_data["title"],
587
+ description=item_data["description"],
588
+ item_type=item_data["item_type"],
589
+ status=item_data["status"],
590
+ priority=item_data["priority"],
591
+ agent=item_data.get("agent"),
592
+ parent_id=item_data.get("parent_id"),
593
+ blocks=item_data.get("blocks", []),
594
+ blocked_by=item_data.get("blocked_by", []),
595
+ tests=item_data.get("tests", []),
596
+ tests_passing=item_data.get("tests_passing", False),
597
+ time_spent_ms=item_data.get("time_spent_ms", 0),
598
+ attempt_count=item_data.get("attempt_count", 0),
599
+ created_at=created_at,
600
+ updated_at=updated_at,
601
+ started_at=started_at,
602
+ completed_at=completed_at,
603
+ metadata=item_data.get("metadata", {}),
604
+ )
605
+ tracker._work_items[item.id] = item
606
+
607
+ return tracker