alma-memory 0.5.1__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.
- alma/__init__.py +296 -226
- alma/compression/__init__.py +33 -0
- alma/compression/pipeline.py +980 -0
- alma/confidence/__init__.py +47 -47
- alma/confidence/engine.py +540 -540
- alma/confidence/types.py +351 -351
- alma/config/loader.py +157 -157
- alma/consolidation/__init__.py +23 -23
- alma/consolidation/engine.py +678 -678
- alma/consolidation/prompts.py +84 -84
- alma/core.py +1189 -430
- alma/domains/__init__.py +30 -30
- alma/domains/factory.py +359 -359
- alma/domains/schemas.py +448 -448
- alma/domains/types.py +272 -272
- alma/events/__init__.py +75 -75
- alma/events/emitter.py +285 -284
- alma/events/storage_mixin.py +246 -246
- alma/events/types.py +126 -126
- alma/events/webhook.py +425 -425
- alma/exceptions.py +49 -49
- alma/extraction/__init__.py +31 -31
- alma/extraction/auto_learner.py +265 -265
- alma/extraction/extractor.py +420 -420
- alma/graph/__init__.py +106 -106
- alma/graph/backends/__init__.py +32 -32
- alma/graph/backends/kuzu.py +624 -624
- alma/graph/backends/memgraph.py +432 -432
- alma/graph/backends/memory.py +236 -236
- alma/graph/backends/neo4j.py +417 -417
- alma/graph/base.py +159 -159
- alma/graph/extraction.py +198 -198
- alma/graph/store.py +860 -860
- alma/harness/__init__.py +35 -35
- alma/harness/base.py +386 -386
- alma/harness/domains.py +705 -705
- alma/initializer/__init__.py +37 -37
- alma/initializer/initializer.py +418 -418
- alma/initializer/types.py +250 -250
- alma/integration/__init__.py +62 -62
- alma/integration/claude_agents.py +444 -444
- alma/integration/helena.py +423 -423
- alma/integration/victor.py +471 -471
- alma/learning/__init__.py +101 -86
- alma/learning/decay.py +878 -0
- alma/learning/forgetting.py +1446 -1446
- alma/learning/heuristic_extractor.py +390 -390
- alma/learning/protocols.py +374 -374
- alma/learning/validation.py +346 -346
- alma/mcp/__init__.py +123 -45
- alma/mcp/__main__.py +156 -156
- alma/mcp/resources.py +122 -122
- alma/mcp/server.py +955 -591
- alma/mcp/tools.py +3254 -509
- alma/observability/__init__.py +91 -84
- alma/observability/config.py +302 -302
- alma/observability/guidelines.py +170 -0
- alma/observability/logging.py +424 -424
- alma/observability/metrics.py +583 -583
- alma/observability/tracing.py +440 -440
- alma/progress/__init__.py +21 -21
- alma/progress/tracker.py +607 -607
- alma/progress/types.py +250 -250
- alma/retrieval/__init__.py +134 -53
- alma/retrieval/budget.py +525 -0
- alma/retrieval/cache.py +1304 -1061
- alma/retrieval/embeddings.py +202 -202
- alma/retrieval/engine.py +850 -427
- alma/retrieval/modes.py +365 -0
- alma/retrieval/progressive.py +560 -0
- alma/retrieval/scoring.py +344 -344
- alma/retrieval/trust_scoring.py +637 -0
- alma/retrieval/verification.py +797 -0
- alma/session/__init__.py +19 -19
- alma/session/manager.py +442 -399
- alma/session/types.py +288 -288
- alma/storage/__init__.py +101 -90
- alma/storage/archive.py +233 -0
- alma/storage/azure_cosmos.py +1259 -1259
- alma/storage/base.py +1083 -583
- alma/storage/chroma.py +1443 -1443
- alma/storage/constants.py +103 -103
- alma/storage/file_based.py +614 -614
- alma/storage/migrations/__init__.py +21 -21
- alma/storage/migrations/base.py +321 -321
- alma/storage/migrations/runner.py +323 -323
- alma/storage/migrations/version_stores.py +337 -337
- alma/storage/migrations/versions/__init__.py +11 -11
- alma/storage/migrations/versions/v1_0_0.py +373 -373
- alma/storage/migrations/versions/v1_1_0_workflow_context.py +551 -0
- alma/storage/pinecone.py +1080 -1080
- alma/storage/postgresql.py +1948 -1559
- alma/storage/qdrant.py +1306 -1306
- alma/storage/sqlite_local.py +3041 -1457
- alma/testing/__init__.py +46 -46
- alma/testing/factories.py +301 -301
- alma/testing/mocks.py +389 -389
- alma/types.py +292 -264
- alma/utils/__init__.py +19 -0
- alma/utils/tokenizer.py +521 -0
- alma/workflow/__init__.py +83 -0
- alma/workflow/artifacts.py +170 -0
- alma/workflow/checkpoint.py +311 -0
- alma/workflow/context.py +228 -0
- alma/workflow/outcomes.py +189 -0
- alma/workflow/reducers.py +393 -0
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/METADATA +210 -72
- alma_memory-0.7.0.dist-info/RECORD +112 -0
- alma_memory-0.5.1.dist-info/RECORD +0 -93
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/WHEEL +0 -0
- {alma_memory-0.5.1.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.
|
|
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.
|
|
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.
|
|
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
|