htmlgraph 0.26.25__py3-none-any.whl → 0.27.1__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 (175) hide show
  1. htmlgraph/__init__.py +23 -1
  2. htmlgraph/__init__.pyi +123 -0
  3. htmlgraph/agent_registry.py +2 -1
  4. htmlgraph/analytics/cli.py +3 -3
  5. htmlgraph/analytics/cost_analyzer.py +5 -1
  6. htmlgraph/analytics/cost_monitor.py +664 -0
  7. htmlgraph/analytics/cross_session.py +13 -9
  8. htmlgraph/analytics/dependency.py +10 -6
  9. htmlgraph/analytics/strategic/__init__.py +80 -0
  10. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  11. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  12. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  13. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  14. htmlgraph/analytics/work_type.py +15 -11
  15. htmlgraph/analytics_index.py +2 -1
  16. htmlgraph/api/cost_alerts_websocket.py +416 -0
  17. htmlgraph/api/main.py +167 -62
  18. htmlgraph/api/websocket.py +538 -0
  19. htmlgraph/attribute_index.py +2 -1
  20. htmlgraph/builders/base.py +2 -1
  21. htmlgraph/builders/bug.py +2 -1
  22. htmlgraph/builders/chore.py +2 -1
  23. htmlgraph/builders/epic.py +2 -1
  24. htmlgraph/builders/feature.py +2 -1
  25. htmlgraph/builders/insight.py +2 -1
  26. htmlgraph/builders/metric.py +2 -1
  27. htmlgraph/builders/pattern.py +2 -1
  28. htmlgraph/builders/phase.py +2 -1
  29. htmlgraph/builders/spike.py +2 -1
  30. htmlgraph/builders/track.py +2 -1
  31. htmlgraph/cli/analytics.py +2 -1
  32. htmlgraph/cli/base.py +2 -1
  33. htmlgraph/cli/core.py +2 -1
  34. htmlgraph/cli/main.py +2 -1
  35. htmlgraph/cli/models.py +2 -1
  36. htmlgraph/cli/templates/cost_dashboard.py +2 -1
  37. htmlgraph/cli/work/__init__.py +2 -1
  38. htmlgraph/cli/work/browse.py +2 -1
  39. htmlgraph/cli/work/features.py +2 -1
  40. htmlgraph/cli/work/orchestration.py +2 -1
  41. htmlgraph/cli/work/report.py +2 -1
  42. htmlgraph/cli/work/sessions.py +2 -1
  43. htmlgraph/cli/work/snapshot.py +2 -1
  44. htmlgraph/cli/work/tracks.py +2 -1
  45. htmlgraph/collections/base.py +10 -5
  46. htmlgraph/collections/bug.py +2 -1
  47. htmlgraph/collections/chore.py +2 -1
  48. htmlgraph/collections/epic.py +2 -1
  49. htmlgraph/collections/feature.py +2 -1
  50. htmlgraph/collections/insight.py +2 -1
  51. htmlgraph/collections/metric.py +2 -1
  52. htmlgraph/collections/pattern.py +2 -1
  53. htmlgraph/collections/phase.py +2 -1
  54. htmlgraph/collections/session.py +12 -7
  55. htmlgraph/collections/spike.py +6 -1
  56. htmlgraph/collections/task_delegation.py +7 -2
  57. htmlgraph/collections/todo.py +2 -1
  58. htmlgraph/collections/traces.py +15 -10
  59. htmlgraph/config/cost_models.json +56 -0
  60. htmlgraph/context_analytics.py +2 -1
  61. htmlgraph/db/schema.py +67 -6
  62. htmlgraph/dependency_models.py +2 -1
  63. htmlgraph/edge_index.py +2 -1
  64. htmlgraph/event_log.py +83 -64
  65. htmlgraph/event_migration.py +2 -1
  66. htmlgraph/file_watcher.py +12 -8
  67. htmlgraph/find_api.py +2 -1
  68. htmlgraph/git_events.py +6 -2
  69. htmlgraph/hooks/cigs_pretool_enforcer.py +5 -1
  70. htmlgraph/hooks/drift_handler.py +3 -3
  71. htmlgraph/hooks/event_tracker.py +40 -61
  72. htmlgraph/hooks/installer.py +5 -1
  73. htmlgraph/hooks/orchestrator.py +4 -0
  74. htmlgraph/hooks/orchestrator_reflector.py +4 -0
  75. htmlgraph/hooks/post_tool_use_failure.py +7 -3
  76. htmlgraph/hooks/posttooluse.py +4 -0
  77. htmlgraph/hooks/prompt_analyzer.py +5 -5
  78. htmlgraph/hooks/session_handler.py +2 -1
  79. htmlgraph/hooks/session_summary.py +6 -2
  80. htmlgraph/hooks/validator.py +8 -4
  81. htmlgraph/ids.py +2 -1
  82. htmlgraph/learning.py +2 -1
  83. htmlgraph/mcp_server.py +2 -1
  84. htmlgraph/operations/analytics.py +2 -1
  85. htmlgraph/operations/bootstrap.py +2 -1
  86. htmlgraph/operations/events.py +2 -1
  87. htmlgraph/operations/fastapi_server.py +2 -1
  88. htmlgraph/operations/hooks.py +2 -1
  89. htmlgraph/operations/initialization.py +2 -1
  90. htmlgraph/operations/server.py +2 -1
  91. htmlgraph/orchestration/claude_launcher.py +23 -20
  92. htmlgraph/orchestration/command_builder.py +2 -1
  93. htmlgraph/orchestration/headless_spawner.py +6 -2
  94. htmlgraph/orchestration/model_selection.py +7 -3
  95. htmlgraph/orchestration/plugin_manager.py +24 -19
  96. htmlgraph/orchestration/spawners/claude.py +5 -2
  97. htmlgraph/orchestration/spawners/codex.py +12 -19
  98. htmlgraph/orchestration/spawners/copilot.py +13 -18
  99. htmlgraph/orchestration/spawners/gemini.py +12 -19
  100. htmlgraph/orchestration/subprocess_runner.py +6 -3
  101. htmlgraph/orchestration/task_coordination.py +16 -8
  102. htmlgraph/orchestrator.py +2 -1
  103. htmlgraph/parallel.py +2 -1
  104. htmlgraph/query_builder.py +2 -1
  105. htmlgraph/reflection.py +2 -1
  106. htmlgraph/refs.py +2 -1
  107. htmlgraph/repo_hash.py +2 -1
  108. htmlgraph/repositories/__init__.py +292 -0
  109. htmlgraph/repositories/analytics_repository.py +455 -0
  110. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  111. htmlgraph/repositories/feature_repository.py +581 -0
  112. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  113. htmlgraph/repositories/feature_repository_memory.py +607 -0
  114. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  115. htmlgraph/repositories/filter_service.py +620 -0
  116. htmlgraph/repositories/filter_service_standard.py +445 -0
  117. htmlgraph/repositories/shared_cache.py +621 -0
  118. htmlgraph/repositories/shared_cache_memory.py +395 -0
  119. htmlgraph/repositories/track_repository.py +552 -0
  120. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  121. htmlgraph/repositories/track_repository_memory.py +508 -0
  122. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  123. htmlgraph/sdk/__init__.py +398 -0
  124. htmlgraph/sdk/__init__.pyi +14 -0
  125. htmlgraph/sdk/analytics/__init__.py +19 -0
  126. htmlgraph/sdk/analytics/engine.py +155 -0
  127. htmlgraph/sdk/analytics/helpers.py +178 -0
  128. htmlgraph/sdk/analytics/registry.py +109 -0
  129. htmlgraph/sdk/base.py +484 -0
  130. htmlgraph/sdk/constants.py +216 -0
  131. htmlgraph/sdk/core.pyi +308 -0
  132. htmlgraph/sdk/discovery.py +120 -0
  133. htmlgraph/sdk/help/__init__.py +12 -0
  134. htmlgraph/sdk/help/mixin.py +699 -0
  135. htmlgraph/sdk/mixins/__init__.py +15 -0
  136. htmlgraph/sdk/mixins/attribution.py +113 -0
  137. htmlgraph/sdk/mixins/mixin.py +410 -0
  138. htmlgraph/sdk/operations/__init__.py +12 -0
  139. htmlgraph/sdk/operations/mixin.py +427 -0
  140. htmlgraph/sdk/orchestration/__init__.py +17 -0
  141. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  142. htmlgraph/sdk/orchestration/spawner.py +204 -0
  143. htmlgraph/sdk/planning/__init__.py +19 -0
  144. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  145. htmlgraph/sdk/planning/mixin.py +211 -0
  146. htmlgraph/sdk/planning/parallel.py +186 -0
  147. htmlgraph/sdk/planning/queue.py +210 -0
  148. htmlgraph/sdk/planning/recommendations.py +87 -0
  149. htmlgraph/sdk/planning/smart_planning.py +319 -0
  150. htmlgraph/sdk/session/__init__.py +19 -0
  151. htmlgraph/sdk/session/continuity.py +57 -0
  152. htmlgraph/sdk/session/handoff.py +110 -0
  153. htmlgraph/sdk/session/info.py +309 -0
  154. htmlgraph/sdk/session/manager.py +103 -0
  155. htmlgraph/sdk/strategic/__init__.py +26 -0
  156. htmlgraph/sdk/strategic/mixin.py +563 -0
  157. htmlgraph/server.py +21 -17
  158. htmlgraph/session_warning.py +2 -1
  159. htmlgraph/sessions/handoff.py +4 -3
  160. htmlgraph/system_prompts.py +2 -1
  161. htmlgraph/track_builder.py +2 -1
  162. htmlgraph/transcript.py +2 -1
  163. htmlgraph/watch.py +2 -1
  164. htmlgraph/work_type_utils.py +2 -1
  165. {htmlgraph-0.26.25.dist-info → htmlgraph-0.27.1.dist-info}/METADATA +1 -1
  166. htmlgraph-0.27.1.dist-info/RECORD +332 -0
  167. htmlgraph/sdk.py +0 -3500
  168. htmlgraph-0.26.25.dist-info/RECORD +0 -274
  169. {htmlgraph-0.26.25.data → htmlgraph-0.27.1.data}/data/htmlgraph/dashboard.html +0 -0
  170. {htmlgraph-0.26.25.data → htmlgraph-0.27.1.data}/data/htmlgraph/styles.css +0 -0
  171. {htmlgraph-0.26.25.data → htmlgraph-0.27.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  172. {htmlgraph-0.26.25.data → htmlgraph-0.27.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  173. {htmlgraph-0.26.25.data → htmlgraph-0.27.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  174. {htmlgraph-0.26.25.dist-info → htmlgraph-0.27.1.dist-info}/WHEEL +0 -0
  175. {htmlgraph-0.26.25.dist-info → htmlgraph-0.27.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,668 @@
1
+ """
2
+ HTMLFileFeatureRepository - HTML file-based Feature storage.
3
+
4
+ Loads and saves features from HTML files using HtmlGraph's existing parser.
5
+ """
6
+
7
+ import builtins
8
+ from collections.abc import Callable
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from htmlgraph.converter import html_to_node, node_to_html
14
+ from htmlgraph.models import Node
15
+ from htmlgraph.repositories.feature_repository import (
16
+ FeatureNotFoundError,
17
+ FeatureRepository,
18
+ FeatureValidationError,
19
+ RepositoryQuery,
20
+ )
21
+
22
+
23
+ class HTMLFileRepositoryQuery(RepositoryQuery):
24
+ """Query builder for HTML file filtering."""
25
+
26
+ def __init__(self, repo: "HTMLFileFeatureRepository", filters: dict[str, Any]):
27
+ super().__init__(filters)
28
+ self._repo = repo
29
+
30
+ def where(self, **kwargs: Any) -> "HTMLFileRepositoryQuery":
31
+ """Chain additional filters."""
32
+ # Validate filter keys
33
+ valid_attrs = {
34
+ "status",
35
+ "priority",
36
+ "track_id",
37
+ "agent_assigned",
38
+ "type",
39
+ "title",
40
+ "id",
41
+ "created",
42
+ "updated",
43
+ }
44
+ for key in kwargs:
45
+ if key not in valid_attrs:
46
+ raise FeatureValidationError(f"Invalid filter attribute: {key}")
47
+
48
+ # Merge filters
49
+ new_filters = {**self.filters, **kwargs}
50
+ return HTMLFileRepositoryQuery(self._repo, new_filters)
51
+
52
+ def execute(self) -> list[Any]:
53
+ """Execute the query and return results."""
54
+ return self._repo.list(self.filters)
55
+
56
+
57
+ class HTMLFileFeatureRepository(FeatureRepository):
58
+ """
59
+ HTML file-based FeatureRepository implementation.
60
+
61
+ Loads and saves features from HTML files in a directory.
62
+ Uses HtmlGraph's existing HTML parsing and serialization.
63
+
64
+ Features are stored as: `.htmlgraph/features/feat-XXXXX.html`
65
+
66
+ Performance:
67
+ - get(id): O(1) with cache, O(n) cold start
68
+ - list(): O(n) where n = total features
69
+ - save(): O(1) file write
70
+ - Cache hit ratio: >90% in steady state
71
+
72
+ Example:
73
+ >>> repo = HTMLFileFeatureRepository(Path(".htmlgraph/features"))
74
+ >>> feature = repo.get("feat-001")
75
+ >>> feature.status = "done"
76
+ >>> repo.save(feature)
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ directory: Path | str,
82
+ auto_load: bool = True,
83
+ stylesheet_path: str = "../styles.css",
84
+ ):
85
+ """
86
+ Initialize HTML file repository.
87
+
88
+ Args:
89
+ directory: Directory containing feature HTML files
90
+ auto_load: Whether to auto-load features on first access
91
+ stylesheet_path: Relative path to CSS stylesheet for new files
92
+ """
93
+ self._directory = Path(directory)
94
+ self._directory.mkdir(parents=True, exist_ok=True)
95
+ self._auto_load = auto_load
96
+ self._stylesheet_path = stylesheet_path
97
+
98
+ # Identity cache: feature_id -> Node instance
99
+ self._cache: dict[str, Node] = {}
100
+ self._loaded = False
101
+
102
+ def _ensure_loaded(self) -> None:
103
+ """Ensure features are loaded from disk."""
104
+ if not self._loaded and self._auto_load:
105
+ self.reload()
106
+
107
+ def _load_from_file(self, filepath: Path) -> Node:
108
+ """Load a single feature from HTML file."""
109
+ try:
110
+ node = html_to_node(filepath)
111
+ if node.type != "feature":
112
+ raise FeatureValidationError(
113
+ f"File {filepath} contains node of type '{node.type}', not 'feature'"
114
+ )
115
+ return node
116
+ except Exception as e:
117
+ raise FeatureValidationError(f"Failed to load {filepath}: {e}") from e
118
+
119
+ def _find_file(self, feature_id: str) -> Path | None:
120
+ """Find HTML file for a feature ID."""
121
+ # Try direct match: feat-abc123.html
122
+ direct = self._directory / f"{feature_id}.html"
123
+ if direct.exists():
124
+ return direct
125
+
126
+ # Try scanning all files (fallback)
127
+ for filepath in self._directory.glob("*.html"):
128
+ if filepath.stem == feature_id:
129
+ return filepath
130
+
131
+ return None
132
+
133
+ def _generate_id(self) -> str:
134
+ """Generate unique feature ID."""
135
+ import uuid
136
+
137
+ return f"feat-{uuid.uuid4().hex[:8]}"
138
+
139
+ def _validate_feature(self, feature: Any) -> None:
140
+ """Validate feature object."""
141
+ if not hasattr(feature, "id"):
142
+ raise FeatureValidationError("Feature must have 'id' attribute")
143
+ if not hasattr(feature, "title"):
144
+ raise FeatureValidationError("Feature must have 'title' attribute")
145
+ if not feature.id or not str(feature.id).strip():
146
+ raise FeatureValidationError("Feature ID cannot be empty")
147
+ if not feature.title or not str(feature.title).strip():
148
+ raise FeatureValidationError("Feature title cannot be empty")
149
+
150
+ def _matches_filters(self, feature: Node, filters: dict[str, Any]) -> bool:
151
+ """Check if feature matches all filters."""
152
+ if not filters:
153
+ return True
154
+
155
+ for key, value in filters.items():
156
+ if not hasattr(feature, key):
157
+ return False
158
+ if getattr(feature, key) != value:
159
+ return False
160
+ return True
161
+
162
+ # ===== READ OPERATIONS =====
163
+
164
+ def get(self, feature_id: str) -> Node | None:
165
+ """
166
+ Get single feature by ID.
167
+
168
+ Returns same object instance for multiple calls (identity caching).
169
+
170
+ Args:
171
+ feature_id: Feature ID to retrieve
172
+
173
+ Returns:
174
+ Feature object if found, None if not found
175
+
176
+ Raises:
177
+ ValueError: If feature_id is invalid format
178
+
179
+ Performance: O(1) with cache, O(log n) without cache
180
+
181
+ Examples:
182
+ >>> feature = repo.get("feat-001")
183
+ >>> feature2 = repo.get("feat-001")
184
+ >>> assert feature is feature2 # Same instance
185
+ """
186
+ if not feature_id or not isinstance(feature_id, str):
187
+ raise ValueError(f"Invalid feature_id: {feature_id}")
188
+
189
+ self._ensure_loaded()
190
+
191
+ # Check cache first
192
+ if feature_id in self._cache:
193
+ return self._cache[feature_id]
194
+
195
+ # Load from file
196
+ filepath = self._find_file(feature_id)
197
+ if not filepath:
198
+ return None
199
+
200
+ feature = self._load_from_file(filepath)
201
+ self._cache[feature_id] = feature
202
+ return feature
203
+
204
+ def list(self, filters: dict[str, Any] | None = None) -> list[Node]:
205
+ """
206
+ List all features with optional filters.
207
+
208
+ Args:
209
+ filters: Optional dict of attribute->value filters
210
+
211
+ Returns:
212
+ List of Feature objects (empty list if no matches)
213
+
214
+ Raises:
215
+ FeatureValidationError: If filter keys are invalid
216
+
217
+ Performance: O(n) where n = total features
218
+
219
+ Examples:
220
+ >>> all_features = repo.list()
221
+ >>> todo_features = repo.list({"status": "todo"})
222
+ """
223
+ if filters:
224
+ # Validate filter keys
225
+ valid_attrs = {
226
+ "status",
227
+ "priority",
228
+ "track_id",
229
+ "agent_assigned",
230
+ "type",
231
+ "title",
232
+ "id",
233
+ "created",
234
+ "updated",
235
+ }
236
+ for key in filters:
237
+ if key not in valid_attrs:
238
+ raise FeatureValidationError(f"Invalid filter attribute: {key}")
239
+
240
+ self._ensure_loaded()
241
+
242
+ results = []
243
+ for feature in self._cache.values():
244
+ if self._matches_filters(feature, filters or {}):
245
+ results.append(feature)
246
+ return results
247
+
248
+ def where(self, **kwargs: Any) -> RepositoryQuery:
249
+ """
250
+ Build a filtered query with chaining support.
251
+
252
+ Args:
253
+ **kwargs: Attribute->value filter pairs
254
+
255
+ Returns:
256
+ RepositoryQuery object that can be further filtered
257
+
258
+ Raises:
259
+ FeatureValidationError: If invalid attribute names
260
+
261
+ Examples:
262
+ >>> query = repo.where(status='todo')
263
+ >>> results = query.where(priority='high').execute()
264
+ """
265
+ return HTMLFileRepositoryQuery(self, kwargs)
266
+
267
+ def by_track(self, track_id: str) -> builtins.list[Node]:
268
+ """Get all features belonging to a track."""
269
+ if not track_id:
270
+ raise ValueError("track_id cannot be empty")
271
+ return self.list({"track_id": track_id})
272
+
273
+ def by_status(self, status: str) -> builtins.list[Node]:
274
+ """Filter features by status."""
275
+ return self.list({"status": status})
276
+
277
+ def by_priority(self, priority: str) -> builtins.list[Node]:
278
+ """Filter features by priority."""
279
+ return self.list({"priority": priority})
280
+
281
+ def by_assigned_to(self, agent: str) -> builtins.list[Node]:
282
+ """Get features assigned to an agent."""
283
+ return self.list({"agent_assigned": agent})
284
+
285
+ def batch_get(self, feature_ids: builtins.list[str]) -> builtins.list[Node]:
286
+ """
287
+ Bulk retrieve multiple features.
288
+
289
+ Args:
290
+ feature_ids: List of feature IDs
291
+
292
+ Returns:
293
+ List of found features
294
+
295
+ Raises:
296
+ ValueError: If feature_ids is not a list
297
+
298
+ Performance: O(k) where k = batch size
299
+ """
300
+ if not isinstance(feature_ids, list):
301
+ raise ValueError("feature_ids must be a list")
302
+
303
+ results = []
304
+ for fid in feature_ids:
305
+ feature = self.get(fid)
306
+ if feature:
307
+ results.append(feature)
308
+ return results
309
+
310
+ # ===== WRITE OPERATIONS =====
311
+
312
+ def create(self, title: str, **kwargs: Any) -> Node:
313
+ """
314
+ Create new feature.
315
+
316
+ Args:
317
+ title: Feature title (required)
318
+ **kwargs: Additional properties
319
+
320
+ Returns:
321
+ Created Feature object (with generated ID)
322
+
323
+ Raises:
324
+ FeatureValidationError: If invalid data provided
325
+
326
+ Performance: O(1)
327
+ """
328
+ if not title or not title.strip():
329
+ raise FeatureValidationError("Feature title cannot be empty")
330
+
331
+ # Generate ID if not provided
332
+ feature_id = kwargs.pop("id", None) or self._generate_id()
333
+
334
+ # Extract known fields from kwargs to avoid conflicts
335
+ node_type = kwargs.pop("type", "feature")
336
+ status = kwargs.pop("status", "todo")
337
+ priority = kwargs.pop("priority", "medium")
338
+ created = kwargs.pop("created", datetime.now())
339
+ updated = kwargs.pop("updated", datetime.now())
340
+
341
+ # Remove title from kwargs if present (already have it as parameter)
342
+ kwargs.pop("title", None)
343
+
344
+ # Create Node object
345
+ feature = Node(
346
+ id=feature_id,
347
+ title=title,
348
+ type=node_type,
349
+ status=status,
350
+ priority=priority,
351
+ created=created,
352
+ updated=updated,
353
+ **kwargs,
354
+ )
355
+
356
+ # Validate and save
357
+ self._validate_feature(feature)
358
+ self.save(feature)
359
+
360
+ return feature
361
+
362
+ def save(self, feature: Node) -> Node:
363
+ """
364
+ Save existing feature (update or insert).
365
+
366
+ Args:
367
+ feature: Feature object to save
368
+
369
+ Returns:
370
+ Saved feature (same instance)
371
+
372
+ Raises:
373
+ FeatureValidationError: If feature is invalid
374
+
375
+ Performance: O(1)
376
+ """
377
+ self._validate_feature(feature)
378
+
379
+ # Update timestamp
380
+ feature.updated = datetime.now()
381
+
382
+ # Write to file
383
+ filepath = self._directory / f"{feature.id}.html"
384
+ node_to_html(feature, filepath, stylesheet_path=self._stylesheet_path)
385
+
386
+ # Update cache
387
+ self._cache[feature.id] = feature
388
+
389
+ return feature
390
+
391
+ def batch_update(
392
+ self, feature_ids: builtins.list[str], updates: dict[str, Any]
393
+ ) -> int:
394
+ """
395
+ Vectorized batch update operation.
396
+
397
+ Args:
398
+ feature_ids: List of feature IDs to update
399
+ updates: Dict of attribute->value to set
400
+
401
+ Returns:
402
+ Number of features successfully updated
403
+
404
+ Raises:
405
+ FeatureValidationError: If invalid updates
406
+
407
+ Performance: O(k) where k = batch size
408
+ """
409
+ if not isinstance(feature_ids, list):
410
+ raise ValueError("feature_ids must be a list")
411
+ if not isinstance(updates, dict):
412
+ raise FeatureValidationError("updates must be a dict")
413
+
414
+ count = 0
415
+ for fid in feature_ids:
416
+ feature = self.get(fid)
417
+ if feature:
418
+ # Apply updates
419
+ for key, value in updates.items():
420
+ setattr(feature, key, value)
421
+ self.save(feature)
422
+ count += 1
423
+
424
+ return count
425
+
426
+ def delete(self, feature_id: str) -> bool:
427
+ """
428
+ Delete a feature by ID.
429
+
430
+ Args:
431
+ feature_id: Feature ID to delete
432
+
433
+ Returns:
434
+ True if deleted, False if not found
435
+
436
+ Performance: O(1)
437
+ """
438
+ if not feature_id:
439
+ raise FeatureValidationError("feature_id cannot be empty")
440
+
441
+ # Find and delete file
442
+ filepath = self._find_file(feature_id)
443
+ if not filepath:
444
+ return False
445
+
446
+ filepath.unlink()
447
+
448
+ # Remove from cache
449
+ self._cache.pop(feature_id, None)
450
+
451
+ return True
452
+
453
+ def batch_delete(self, feature_ids: builtins.list[str]) -> int:
454
+ """
455
+ Delete multiple features.
456
+
457
+ Args:
458
+ feature_ids: List of feature IDs to delete
459
+
460
+ Returns:
461
+ Number of features successfully deleted
462
+
463
+ Performance: O(k) where k = batch size
464
+ """
465
+ if not isinstance(feature_ids, list):
466
+ raise ValueError("feature_ids must be a list")
467
+
468
+ count = 0
469
+ for fid in feature_ids:
470
+ if self.delete(fid):
471
+ count += 1
472
+ return count
473
+
474
+ # ===== ADVANCED QUERIES =====
475
+
476
+ def find_dependencies(self, feature_id: str) -> builtins.list[Node]:
477
+ """
478
+ Find transitive feature dependencies.
479
+
480
+ Args:
481
+ feature_id: Feature to find dependencies for
482
+
483
+ Returns:
484
+ List of features this feature depends on
485
+
486
+ Raises:
487
+ FeatureNotFoundError: If feature not found
488
+
489
+ Performance: O(n) graph traversal
490
+ """
491
+ feature = self.get(feature_id)
492
+ if not feature:
493
+ raise FeatureNotFoundError(feature_id)
494
+
495
+ dependencies = []
496
+ visited = set()
497
+
498
+ def traverse(f: Node) -> None:
499
+ if f.id in visited:
500
+ return
501
+ visited.add(f.id)
502
+
503
+ # Check edges for dependencies
504
+ if hasattr(f, "edges") and f.edges:
505
+ depends_on = (
506
+ f.edges.get("depends_on", []) if isinstance(f.edges, dict) else []
507
+ )
508
+ for edge in depends_on:
509
+ target_id = (
510
+ edge.target_id
511
+ if hasattr(edge, "target_id")
512
+ else edge.get("target_id")
513
+ if isinstance(edge, dict)
514
+ else None
515
+ )
516
+ if target_id:
517
+ dep = self.get(target_id)
518
+ if dep and dep not in dependencies:
519
+ dependencies.append(dep)
520
+ traverse(dep)
521
+
522
+ traverse(feature)
523
+ return dependencies
524
+
525
+ def find_blocking(self, feature_id: str) -> builtins.list[Node]:
526
+ """
527
+ Find what blocks this feature.
528
+
529
+ Args:
530
+ feature_id: Feature to find blockers for
531
+
532
+ Returns:
533
+ Features that depend on this feature
534
+
535
+ Raises:
536
+ FeatureNotFoundError: If feature not found
537
+ """
538
+ feature = self.get(feature_id)
539
+ if not feature:
540
+ raise FeatureNotFoundError(feature_id)
541
+
542
+ self._ensure_loaded()
543
+
544
+ blocking = []
545
+ for f in self._cache.values():
546
+ if hasattr(f, "edges") and f.edges:
547
+ depends_on = (
548
+ f.edges.get("depends_on", []) if isinstance(f.edges, dict) else []
549
+ )
550
+ for edge in depends_on:
551
+ target_id = (
552
+ edge.target_id
553
+ if hasattr(edge, "target_id")
554
+ else edge.get("target_id")
555
+ if isinstance(edge, dict)
556
+ else None
557
+ )
558
+ if target_id == feature_id:
559
+ blocking.append(f)
560
+
561
+ return blocking
562
+
563
+ def filter(self, predicate: Callable[[Node], bool]) -> builtins.list[Node]:
564
+ """
565
+ Filter features with custom predicate function.
566
+
567
+ Args:
568
+ predicate: Function that takes Feature and returns True/False
569
+
570
+ Returns:
571
+ Features matching predicate
572
+ """
573
+ self._ensure_loaded()
574
+ return [f for f in self._cache.values() if predicate(f)]
575
+
576
+ # ===== CACHE/LIFECYCLE MANAGEMENT =====
577
+
578
+ def invalidate_cache(self, feature_id: str | None = None) -> None:
579
+ """
580
+ Invalidate cache for single feature or all features.
581
+
582
+ Forces reload from storage on next access.
583
+
584
+ Args:
585
+ feature_id: Specific feature to invalidate, or None for all
586
+ """
587
+ if feature_id:
588
+ self._cache.pop(feature_id, None)
589
+ else:
590
+ self._cache.clear()
591
+
592
+ def reload(self) -> None:
593
+ """
594
+ Force reload all features from storage.
595
+
596
+ Invalidates all caches and reloads from disk.
597
+ Note: Preserves existing cache entries to maintain object identity
598
+ for features that have been created but not yet persisted to disk.
599
+ """
600
+ # Keep track of existing cached entries to preserve object identity
601
+ existing_cache = dict(self._cache)
602
+ self._cache.clear()
603
+
604
+ # Load all HTML files
605
+ for filepath in self._directory.glob("*.html"):
606
+ try:
607
+ feature_id = filepath.stem
608
+ # If we already have this feature in cache, keep the existing instance
609
+ if feature_id in existing_cache:
610
+ self._cache[feature_id] = existing_cache[feature_id]
611
+ else:
612
+ feature = self._load_from_file(filepath)
613
+ self._cache[feature.id] = feature
614
+ except Exception as e:
615
+ # Log and skip invalid files
616
+ import logging
617
+
618
+ logging.warning(f"Failed to load {filepath}: {e}")
619
+
620
+ self._loaded = True
621
+
622
+ @property
623
+ def auto_load(self) -> bool:
624
+ """Whether auto-loading is enabled."""
625
+ return self._auto_load
626
+
627
+ @auto_load.setter
628
+ def auto_load(self, enabled: bool) -> None:
629
+ """Enable/disable auto-loading."""
630
+ self._auto_load = enabled
631
+
632
+ # ===== UTILITY METHODS =====
633
+
634
+ def count(self, filters: dict[str, Any] | None = None) -> int:
635
+ """
636
+ Count features matching filters.
637
+
638
+ Args:
639
+ filters: Optional filters
640
+
641
+ Returns:
642
+ Number of matching features
643
+
644
+ Performance: O(n) or O(1) if cached and no filters
645
+ """
646
+ if not filters:
647
+ self._ensure_loaded()
648
+ return len(self._cache)
649
+ return len(self.list(filters))
650
+
651
+ def exists(self, feature_id: str) -> bool:
652
+ """
653
+ Check if feature exists without loading it.
654
+
655
+ Args:
656
+ feature_id: Feature ID to check
657
+
658
+ Returns:
659
+ True if exists, False otherwise
660
+
661
+ Performance: O(1)
662
+ """
663
+ # Check cache first
664
+ if feature_id in self._cache:
665
+ return True
666
+
667
+ # Check file system
668
+ return self._find_file(feature_id) is not None