htmlgraph 0.20.1__py3-none-any.whl → 0.27.5__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.
- htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/.htmlgraph/agents.json +72 -0
- htmlgraph/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/__init__.py +51 -1
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +26 -10
- htmlgraph/agent_registry.py +2 -1
- htmlgraph/analytics/__init__.py +8 -1
- htmlgraph/analytics/cli.py +86 -20
- htmlgraph/analytics/cost_analyzer.py +391 -0
- htmlgraph/analytics/cost_monitor.py +664 -0
- htmlgraph/analytics/cost_reporter.py +675 -0
- htmlgraph/analytics/cross_session.py +617 -0
- htmlgraph/analytics/dependency.py +10 -6
- htmlgraph/analytics/pattern_learning.py +771 -0
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/analytics/strategic/__init__.py +80 -0
- htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
- htmlgraph/analytics/strategic/pattern_detector.py +876 -0
- htmlgraph/analytics/strategic/preference_manager.py +709 -0
- htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
- htmlgraph/analytics/work_type.py +67 -27
- htmlgraph/analytics_index.py +53 -20
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/cost_alerts_websocket.py +416 -0
- htmlgraph/api/main.py +2498 -0
- htmlgraph/api/static/htmx.min.js +1 -0
- htmlgraph/api/static/style-redesign.css +1344 -0
- htmlgraph/api/static/style.css +1079 -0
- htmlgraph/api/templates/dashboard-redesign.html +1366 -0
- htmlgraph/api/templates/dashboard.html +794 -0
- htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
- htmlgraph/api/templates/partials/activity-feed.html +1100 -0
- htmlgraph/api/templates/partials/agents-redesign.html +317 -0
- htmlgraph/api/templates/partials/agents.html +317 -0
- htmlgraph/api/templates/partials/event-traces.html +373 -0
- htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
- htmlgraph/api/templates/partials/features.html +578 -0
- htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
- htmlgraph/api/templates/partials/metrics.html +346 -0
- htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
- htmlgraph/api/templates/partials/orchestration.html +198 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/api/templates/partials/work-items.html +613 -0
- htmlgraph/api/websocket.py +538 -0
- htmlgraph/archive/__init__.py +24 -0
- htmlgraph/archive/bloom.py +234 -0
- htmlgraph/archive/fts.py +297 -0
- htmlgraph/archive/manager.py +583 -0
- htmlgraph/archive/search.py +244 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/attribute_index.py +2 -1
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/base.py +57 -2
- htmlgraph/builders/bug.py +19 -3
- htmlgraph/builders/chore.py +19 -3
- htmlgraph/builders/epic.py +19 -3
- htmlgraph/builders/feature.py +27 -3
- htmlgraph/builders/insight.py +2 -1
- htmlgraph/builders/metric.py +2 -1
- htmlgraph/builders/pattern.py +2 -1
- htmlgraph/builders/phase.py +19 -3
- htmlgraph/builders/spike.py +29 -3
- htmlgraph/builders/track.py +42 -1
- htmlgraph/cigs/__init__.py +81 -0
- htmlgraph/cigs/autonomy.py +385 -0
- htmlgraph/cigs/cost.py +475 -0
- htmlgraph/cigs/messages_basic.py +472 -0
- htmlgraph/cigs/messaging.py +365 -0
- htmlgraph/cigs/models.py +771 -0
- htmlgraph/cigs/pattern_storage.py +427 -0
- htmlgraph/cigs/patterns.py +503 -0
- htmlgraph/cigs/posttool_analyzer.py +234 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cigs/tracker.py +317 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +1424 -0
- htmlgraph/cli/base.py +685 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +954 -0
- htmlgraph/cli/main.py +147 -0
- htmlgraph/cli/models.py +475 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +399 -0
- htmlgraph/cli/work/__init__.py +239 -0
- htmlgraph/cli/work/browse.py +115 -0
- htmlgraph/cli/work/features.py +568 -0
- htmlgraph/cli/work/orchestration.py +676 -0
- htmlgraph/cli/work/report.py +728 -0
- htmlgraph/cli/work/sessions.py +466 -0
- htmlgraph/cli/work/snapshot.py +559 -0
- htmlgraph/cli/work/tracks.py +486 -0
- htmlgraph/cli_commands/__init__.py +1 -0
- htmlgraph/cli_commands/feature.py +195 -0
- htmlgraph/cli_framework.py +115 -0
- htmlgraph/collections/__init__.py +2 -0
- htmlgraph/collections/base.py +197 -14
- htmlgraph/collections/bug.py +2 -1
- htmlgraph/collections/chore.py +2 -1
- htmlgraph/collections/epic.py +2 -1
- htmlgraph/collections/feature.py +2 -1
- htmlgraph/collections/insight.py +2 -1
- htmlgraph/collections/metric.py +2 -1
- htmlgraph/collections/pattern.py +2 -1
- htmlgraph/collections/phase.py +2 -1
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +13 -2
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +14 -1
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +2 -1
- htmlgraph/converter.py +116 -7
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2246 -248
- htmlgraph/dashboard.html.backup +6592 -0
- htmlgraph/dashboard.html.bak +7181 -0
- htmlgraph/dashboard.html.bak2 +7231 -0
- htmlgraph/dashboard.html.bak3 +7232 -0
- htmlgraph/db/__init__.py +38 -0
- htmlgraph/db/queries.py +790 -0
- htmlgraph/db/schema.py +1788 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/dependency_models.py +2 -1
- htmlgraph/deploy.py +26 -27
- htmlgraph/docs/API_REFERENCE.md +841 -0
- htmlgraph/docs/HTTP_API.md +750 -0
- htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
- htmlgraph/docs/README.md +532 -0
- htmlgraph/docs/__init__.py +77 -0
- htmlgraph/docs/docs_version.py +55 -0
- htmlgraph/docs/metadata.py +93 -0
- htmlgraph/docs/migrations.py +232 -0
- htmlgraph/docs/template_engine.py +143 -0
- htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
- htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
- htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
- htmlgraph/docs/templates/base_agents.md.j2 +78 -0
- htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
- htmlgraph/docs/version_check.py +163 -0
- htmlgraph/edge_index.py +2 -1
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +86 -37
- htmlgraph/event_migration.py +2 -1
- htmlgraph/file_watcher.py +12 -8
- htmlgraph/find_api.py +2 -1
- htmlgraph/git_events.py +67 -9
- htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/hooks/.htmlgraph/agents.json +72 -0
- htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
- htmlgraph/hooks/__init__.py +8 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +350 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +790 -99
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/installer.py +5 -1
- htmlgraph/hooks/orchestrator.py +327 -76
- htmlgraph/hooks/orchestrator_reflector.py +31 -4
- htmlgraph/hooks/post_tool_use_failure.py +32 -7
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +92 -19
- htmlgraph/hooks/pretooluse.py +527 -7
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +668 -0
- htmlgraph/hooks/session_summary.py +395 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +369 -0
- htmlgraph/hooks/task_enforcer.py +99 -4
- htmlgraph/hooks/validator.py +212 -91
- htmlgraph/ids.py +2 -1
- htmlgraph/learning.py +125 -100
- htmlgraph/mcp_server.py +2 -1
- htmlgraph/models.py +217 -18
- htmlgraph/operations/README.md +62 -0
- htmlgraph/operations/__init__.py +79 -0
- htmlgraph/operations/analytics.py +339 -0
- htmlgraph/operations/bootstrap.py +289 -0
- htmlgraph/operations/events.py +244 -0
- htmlgraph/operations/fastapi_server.py +231 -0
- htmlgraph/operations/hooks.py +350 -0
- htmlgraph/operations/initialization.py +597 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/operations/server.py +303 -0
- htmlgraph/orchestration/__init__.py +58 -0
- htmlgraph/orchestration/claude_launcher.py +179 -0
- htmlgraph/orchestration/command_builder.py +72 -0
- htmlgraph/orchestration/headless_spawner.py +281 -0
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/orchestration/model_selection.py +327 -0
- htmlgraph/orchestration/plugin_manager.py +140 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawner_event_tracker.py +383 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +173 -0
- htmlgraph/orchestration/spawners/codex.py +435 -0
- htmlgraph/orchestration/spawners/copilot.py +294 -0
- htmlgraph/orchestration/spawners/gemini.py +471 -0
- htmlgraph/orchestration/subprocess_runner.py +36 -0
- htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +2 -1
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +115 -4
- htmlgraph/parallel.py +2 -1
- htmlgraph/parser.py +86 -6
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +2 -1
- htmlgraph/query_composer.py +509 -0
- htmlgraph/reflection.py +443 -0
- htmlgraph/refs.py +344 -0
- htmlgraph/repo_hash.py +512 -0
- htmlgraph/repositories/__init__.py +292 -0
- htmlgraph/repositories/analytics_repository.py +455 -0
- htmlgraph/repositories/analytics_repository_standard.py +628 -0
- htmlgraph/repositories/feature_repository.py +581 -0
- htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
- htmlgraph/repositories/feature_repository_memory.py +607 -0
- htmlgraph/repositories/feature_repository_sqlite.py +858 -0
- htmlgraph/repositories/filter_service.py +620 -0
- htmlgraph/repositories/filter_service_standard.py +445 -0
- htmlgraph/repositories/shared_cache.py +621 -0
- htmlgraph/repositories/shared_cache_memory.py +395 -0
- htmlgraph/repositories/track_repository.py +552 -0
- htmlgraph/repositories/track_repository_htmlfile.py +619 -0
- htmlgraph/repositories/track_repository_memory.py +508 -0
- htmlgraph/repositories/track_repository_sqlite.py +711 -0
- htmlgraph/sdk/__init__.py +398 -0
- htmlgraph/sdk/__init__.pyi +14 -0
- htmlgraph/sdk/analytics/__init__.py +19 -0
- htmlgraph/sdk/analytics/engine.py +155 -0
- htmlgraph/sdk/analytics/helpers.py +178 -0
- htmlgraph/sdk/analytics/registry.py +109 -0
- htmlgraph/sdk/base.py +484 -0
- htmlgraph/sdk/constants.py +216 -0
- htmlgraph/sdk/core.pyi +308 -0
- htmlgraph/sdk/discovery.py +120 -0
- htmlgraph/sdk/help/__init__.py +12 -0
- htmlgraph/sdk/help/mixin.py +699 -0
- htmlgraph/sdk/mixins/__init__.py +15 -0
- htmlgraph/sdk/mixins/attribution.py +113 -0
- htmlgraph/sdk/mixins/mixin.py +410 -0
- htmlgraph/sdk/operations/__init__.py +12 -0
- htmlgraph/sdk/operations/mixin.py +427 -0
- htmlgraph/sdk/orchestration/__init__.py +17 -0
- htmlgraph/sdk/orchestration/coordinator.py +203 -0
- htmlgraph/sdk/orchestration/spawner.py +204 -0
- htmlgraph/sdk/planning/__init__.py +19 -0
- htmlgraph/sdk/planning/bottlenecks.py +93 -0
- htmlgraph/sdk/planning/mixin.py +211 -0
- htmlgraph/sdk/planning/parallel.py +186 -0
- htmlgraph/sdk/planning/queue.py +210 -0
- htmlgraph/sdk/planning/recommendations.py +87 -0
- htmlgraph/sdk/planning/smart_planning.py +319 -0
- htmlgraph/sdk/session/__init__.py +19 -0
- htmlgraph/sdk/session/continuity.py +57 -0
- htmlgraph/sdk/session/handoff.py +110 -0
- htmlgraph/sdk/session/info.py +309 -0
- htmlgraph/sdk/session/manager.py +103 -0
- htmlgraph/sdk/strategic/__init__.py +26 -0
- htmlgraph/sdk/strategic/mixin.py +563 -0
- htmlgraph/server.py +295 -107
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +285 -3
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +2 -1
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +33 -1
- htmlgraph/track_manager.py +38 -0
- htmlgraph/transcript.py +18 -5
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +2 -1
- htmlgraph/work_type_utils.py +2 -1
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -4839
- htmlgraph/sdk.py +0 -2359
- htmlgraph-0.20.1.dist-info/RECORD +0 -118
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +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
|