nucleus-mcp 1.0.7 → 1.2.0
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.
- package/package.json +2 -4
- package/.github/workflows/ci.yml +0 -72
- package/.smithery/shttp/module.js +0 -3
- package/.smithery/shttp/module.js.map +0 -7
- package/.smithery/stdio/index.cjs +0 -74
- package/.smithery/stdio/index.cjs.map +0 -7
- package/CHANGELOG.md +0 -65
- package/CODE_OF_CONDUCT.md +0 -34
- package/COMPARISON.md +0 -20
- package/CONTRIBUTING.md +0 -137
- package/DEVELOPMENT.md +0 -109
- package/LICENSE +0 -21
- package/README.md +0 -319
- package/ROADMAP.md +0 -24
- package/SECURITY.md +0 -88
- package/docs/COMMUNITY_FAQ.md +0 -48
- package/docs/COMPETITIVE_ANALYSIS.md +0 -90
- package/docs/DEMO_SCRIPT.md +0 -86
- package/docs/ENTERPRISE.md +0 -169
- package/docs/FAQ.md +0 -228
- package/docs/QUICK_START.md +0 -155
- package/docs/README.md +0 -67
- package/docs/RECURSIVE_SNAP_DEMO.gif +0 -0
- package/docs/social-preview-hq.png +0 -0
- package/docs/social-preview.png +0 -0
- package/examples/basic_usage.py +0 -84
- package/package.json.real +0 -24
- package/pyproject.toml +0 -50
- package/registry-server.json +0 -20
- package/scripts/demo_governance.py +0 -109
- package/scripts/sse_bridge.py +0 -87
- package/scripts/verify_windows_paths.py +0 -48
- package/server.json +0 -20
- package/src/mcp_server_nucleus/__init__.py +0 -8583
- package/src/mcp_server_nucleus/__main__.py +0 -9
- package/src/mcp_server_nucleus/cli.py +0 -1225
- package/src/mcp_server_nucleus/commitment_core.py +0 -146
- package/src/mcp_server_nucleus/commitment_ledger.py +0 -841
- package/src/mcp_server_nucleus/core/__init__.py +0 -6
- package/src/mcp_server_nucleus/core/orchestrator.py +0 -9
- package/src/mcp_server_nucleus/core/tool_registration_impl.py +0 -99
- package/src/mcp_server_nucleus/daemon/safety/lock.py +0 -121
- package/src/mcp_server_nucleus/deployment/render_integration.py +0 -4
- package/src/mcp_server_nucleus/hypervisor/__init__.py +0 -0
- package/src/mcp_server_nucleus/hypervisor/injector.py +0 -83
- package/src/mcp_server_nucleus/hypervisor/locker.py +0 -130
- package/src/mcp_server_nucleus/hypervisor/watchdog.py +0 -71
- package/src/mcp_server_nucleus/runtime/__init__.py +0 -58
- package/src/mcp_server_nucleus/runtime/agent.py +0 -517
- package/src/mcp_server_nucleus/runtime/agent_pool.py +0 -797
- package/src/mcp_server_nucleus/runtime/agents/base.py +0 -34
- package/src/mcp_server_nucleus/runtime/agents/oracle/manifest.json +0 -28
- package/src/mcp_server_nucleus/runtime/auth.py +0 -98
- package/src/mcp_server_nucleus/runtime/autopilot.py +0 -1057
- package/src/mcp_server_nucleus/runtime/broker.py +0 -126
- package/src/mcp_server_nucleus/runtime/budget.py +0 -153
- package/src/mcp_server_nucleus/runtime/capabilities/base.py +0 -20
- package/src/mcp_server_nucleus/runtime/capabilities/brain_ops.py +0 -253
- package/src/mcp_server_nucleus/runtime/capabilities/code_ops.py +0 -192
- package/src/mcp_server_nucleus/runtime/capabilities/depth_tracker.py +0 -81
- package/src/mcp_server_nucleus/runtime/capabilities/feature_map.py +0 -274
- package/src/mcp_server_nucleus/runtime/capabilities/marketing_engine.py +0 -205
- package/src/mcp_server_nucleus/runtime/capabilities/memory_ops.py +0 -79
- package/src/mcp_server_nucleus/runtime/capabilities/proof_system.py +0 -127
- package/src/mcp_server_nucleus/runtime/capabilities/render_ops.py +0 -80
- package/src/mcp_server_nucleus/runtime/capabilities/render_poller_cap.py +0 -266
- package/src/mcp_server_nucleus/runtime/capabilities/self_healing.py +0 -110
- package/src/mcp_server_nucleus/runtime/capabilities/strategy.py +0 -138
- package/src/mcp_server_nucleus/runtime/capabilities/synthesizer.py +0 -134
- package/src/mcp_server_nucleus/runtime/capabilities/web_ops.py +0 -94
- package/src/mcp_server_nucleus/runtime/common.py +0 -134
- package/src/mcp_server_nucleus/runtime/context_manager.py +0 -342
- package/src/mcp_server_nucleus/runtime/context_rules.json +0 -86
- package/src/mcp_server_nucleus/runtime/crdt_task_store.py +0 -349
- package/src/mcp_server_nucleus/runtime/critic.py +0 -150
- package/src/mcp_server_nucleus/runtime/daemon.py +0 -142
- package/src/mcp_server_nucleus/runtime/dashboard.py +0 -1087
- package/src/mcp_server_nucleus/runtime/depth_ops.py +0 -339
- package/src/mcp_server_nucleus/runtime/event_ops.py +0 -103
- package/src/mcp_server_nucleus/runtime/event_stream.py +0 -213
- package/src/mcp_server_nucleus/runtime/export.py +0 -64
- package/src/mcp_server_nucleus/runtime/factory.py +0 -594
- package/src/mcp_server_nucleus/runtime/federation.py +0 -1040
- package/src/mcp_server_nucleus/runtime/fetcher.py +0 -92
- package/src/mcp_server_nucleus/runtime/file_monitor.py +0 -208
- package/src/mcp_server_nucleus/runtime/firestore_bridge.py +0 -91
- package/src/mcp_server_nucleus/runtime/gcloud_ops.py +0 -186
- package/src/mcp_server_nucleus/runtime/hooks.py +0 -246
- package/src/mcp_server_nucleus/runtime/identity/gatekeeper.py +0 -106
- package/src/mcp_server_nucleus/runtime/identity/keygen.py +0 -147
- package/src/mcp_server_nucleus/runtime/identity/manifest.py +0 -62
- package/src/mcp_server_nucleus/runtime/identity/trust.py +0 -27
- package/src/mcp_server_nucleus/runtime/inspector.py +0 -74
- package/src/mcp_server_nucleus/runtime/installer.py +0 -81
- package/src/mcp_server_nucleus/runtime/ipc_auth.py +0 -377
- package/src/mcp_server_nucleus/runtime/lifecycle.py +0 -129
- package/src/mcp_server_nucleus/runtime/llm_client.py +0 -408
- package/src/mcp_server_nucleus/runtime/llm_tiers.json +0 -88
- package/src/mcp_server_nucleus/runtime/locking.py +0 -228
- package/src/mcp_server_nucleus/runtime/loops/fixer.py +0 -110
- package/src/mcp_server_nucleus/runtime/memory.py +0 -158
- package/src/mcp_server_nucleus/runtime/mounter.py +0 -210
- package/src/mcp_server_nucleus/runtime/mounter_ops.py +0 -250
- package/src/mcp_server_nucleus/runtime/nuke_protocol.py +0 -147
- package/src/mcp_server_nucleus/runtime/orchestrator.py +0 -370
- package/src/mcp_server_nucleus/runtime/orchestrator_unified.py +0 -263
- package/src/mcp_server_nucleus/runtime/orchestrator_v3.py +0 -617
- package/src/mcp_server_nucleus/runtime/plugin_loader.py +0 -84
- package/src/mcp_server_nucleus/runtime/policy.py +0 -117
- package/src/mcp_server_nucleus/runtime/profiling.py +0 -219
- package/src/mcp_server_nucleus/runtime/prometheus.py +0 -286
- package/src/mcp_server_nucleus/runtime/proposals.py +0 -101
- package/src/mcp_server_nucleus/runtime/publisher.py +0 -109
- package/src/mcp_server_nucleus/runtime/pulse.py +0 -516
- package/src/mcp_server_nucleus/runtime/registry.py +0 -82
- package/src/mcp_server_nucleus/runtime/render_ops.py +0 -83
- package/src/mcp_server_nucleus/runtime/schema_gen.py +0 -140
- package/src/mcp_server_nucleus/runtime/session_ops.py +0 -224
- package/src/mcp_server_nucleus/runtime/stdio_server.py +0 -633
- package/src/mcp_server_nucleus/runtime/storage.py +0 -83
- package/src/mcp_server_nucleus/runtime/strategy.py +0 -91
- package/src/mcp_server_nucleus/runtime/swarm.py +0 -170
- package/src/mcp_server_nucleus/runtime/sync_ops.py +0 -878
- package/src/mcp_server_nucleus/runtime/task_ingestion.py +0 -1020
- package/src/mcp_server_nucleus/runtime/task_ops.py +0 -449
- package/src/mcp_server_nucleus/runtime/task_scheduler.py +0 -488
- package/src/mcp_server_nucleus/runtime/team.py +0 -59
- package/src/mcp_server_nucleus/runtime/triggers.py +0 -237
- package/src/mcp_server_nucleus/runtime/vector_store.py +0 -125
- package/src/mcp_server_nucleus/runtime/watcher.py +0 -112
- package/src/mcp_server_nucleus/tool_tiers.py +0 -243
- package/tests/__init__.py +0 -1
- package/tests/conftest.py +0 -42
- package/tests/test_core.py +0 -235
- package/tests/test_search.py +0 -69
|
@@ -1,1020 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
TaskIngestionEngine - Multi-Source Task Ingestion System
|
|
3
|
-
Enterprise-grade ingestion from planning docs, TODOs, handoffs, meetings, APIs.
|
|
4
|
-
|
|
5
|
-
Features:
|
|
6
|
-
- Multi-source parsing (5+ types)
|
|
7
|
-
- Semantic deduplication (hash + optional embeddings)
|
|
8
|
-
- Full provenance tracking (source, line, chain)
|
|
9
|
-
- Batch and stream ingestion modes
|
|
10
|
-
- Conflict resolution (priority, assignee, resurrection)
|
|
11
|
-
- Rollback support with crash recovery
|
|
12
|
-
- AgentPool integration for auto-assignment
|
|
13
|
-
- Cost tracking and budget limits
|
|
14
|
-
|
|
15
|
-
Scales: 1 → 1000 sources, 10K tasks/sec
|
|
16
|
-
|
|
17
|
-
Author: NOP V3.1 - January 2026
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
import json
|
|
21
|
-
import re
|
|
22
|
-
import time
|
|
23
|
-
import hashlib
|
|
24
|
-
import threading
|
|
25
|
-
import uuid
|
|
26
|
-
import os
|
|
27
|
-
from pathlib import Path
|
|
28
|
-
from typing import Dict, List, Optional, Tuple, Any
|
|
29
|
-
from collections import defaultdict
|
|
30
|
-
from enum import Enum
|
|
31
|
-
from dataclasses import dataclass, field
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class SourceType(str, Enum):
|
|
35
|
-
"""Supported ingestion source types."""
|
|
36
|
-
PLANNING = "planning"
|
|
37
|
-
TODOS = "todos"
|
|
38
|
-
HANDOFFS = "handoffs"
|
|
39
|
-
MEETINGS = "meetings"
|
|
40
|
-
API = "api"
|
|
41
|
-
MANUAL = "manual"
|
|
42
|
-
SYNTHESIS = "synthesis"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class IngestionMode(str, Enum):
|
|
46
|
-
"""Ingestion processing modes."""
|
|
47
|
-
BATCH = "batch"
|
|
48
|
-
STREAM = "stream"
|
|
49
|
-
MICRO_BATCH = "micro"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@dataclass
|
|
53
|
-
class IngestionResult:
|
|
54
|
-
"""Result of an ingestion operation."""
|
|
55
|
-
success: bool
|
|
56
|
-
batch_id: str
|
|
57
|
-
tasks_created: int = 0
|
|
58
|
-
tasks_updated: int = 0
|
|
59
|
-
tasks_skipped: int = 0
|
|
60
|
-
tasks_failed: int = 0
|
|
61
|
-
errors: List[str] = field(default_factory=list)
|
|
62
|
-
rollback_id: str = None
|
|
63
|
-
cost_estimate: float = 0.0
|
|
64
|
-
auto_assigned: List[Dict] = field(default_factory=list)
|
|
65
|
-
created_task_ids: List[str] = field(default_factory=list)
|
|
66
|
-
|
|
67
|
-
def to_dict(self) -> Dict:
|
|
68
|
-
return {
|
|
69
|
-
"success": self.success,
|
|
70
|
-
"batch_id": self.batch_id,
|
|
71
|
-
"tasks_created": self.tasks_created,
|
|
72
|
-
"tasks_updated": self.tasks_updated,
|
|
73
|
-
"tasks_skipped": self.tasks_skipped,
|
|
74
|
-
"tasks_failed": self.tasks_failed,
|
|
75
|
-
"errors": self.errors,
|
|
76
|
-
"rollback_id": self.rollback_id,
|
|
77
|
-
"cost_estimate": self.cost_estimate,
|
|
78
|
-
"auto_assigned": self.auto_assigned,
|
|
79
|
-
"created_task_ids": self.created_task_ids,
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
@dataclass
|
|
84
|
-
class DuplicateCheckResult:
|
|
85
|
-
"""Result of duplicate check."""
|
|
86
|
-
is_duplicate: bool
|
|
87
|
-
matching_task_id: Optional[str] = None
|
|
88
|
-
similarity: float = 0.0
|
|
89
|
-
match_type: str = None # "exact", "semantic", "source"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
class InputSanitizer:
|
|
93
|
-
"""Sanitize inputs from all ingestion sources."""
|
|
94
|
-
|
|
95
|
-
MAX_DESCRIPTION_LENGTH = 2000
|
|
96
|
-
MAX_ORIGINAL_TEXT_LENGTH = 500
|
|
97
|
-
|
|
98
|
-
DANGEROUS_PATTERNS = [
|
|
99
|
-
r'<script', r'javascript:', r'\$\{', r'{{',
|
|
100
|
-
r'eval\s*\(', r'exec\s*\(', r'__import__',
|
|
101
|
-
]
|
|
102
|
-
|
|
103
|
-
def sanitize_description(self, text: str) -> str:
|
|
104
|
-
"""Sanitize task description."""
|
|
105
|
-
if not text:
|
|
106
|
-
return ""
|
|
107
|
-
|
|
108
|
-
text = text[:self.MAX_DESCRIPTION_LENGTH]
|
|
109
|
-
for pattern in self.DANGEROUS_PATTERNS:
|
|
110
|
-
text = re.sub(pattern, '[FILTERED]', text, flags=re.IGNORECASE)
|
|
111
|
-
text = re.sub(r'\s+', ' ', text).strip()
|
|
112
|
-
text = text.replace('\x00', '')
|
|
113
|
-
return text
|
|
114
|
-
|
|
115
|
-
def sanitize_file_path(self, path: str) -> Optional[str]:
|
|
116
|
-
"""Sanitize file path."""
|
|
117
|
-
if not path:
|
|
118
|
-
return None
|
|
119
|
-
path = os.path.normpath(path)
|
|
120
|
-
if '..' in path:
|
|
121
|
-
raise ValueError("Directory traversal not allowed")
|
|
122
|
-
return path
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
class TaskValidator:
|
|
126
|
-
"""Validate task data before ingestion."""
|
|
127
|
-
|
|
128
|
-
VALID_PRIORITIES = ["HIGH", "MEDIUM", "LOW"]
|
|
129
|
-
VALID_TIERS = ["T1_PLANNING", "T2_CODE", "T3_REVIEW", "T4_DEPLOY"]
|
|
130
|
-
|
|
131
|
-
def validate(self, task: Dict) -> Tuple[bool, List[str]]:
|
|
132
|
-
"""Validate a task."""
|
|
133
|
-
errors = []
|
|
134
|
-
|
|
135
|
-
description = task.get("description", "")
|
|
136
|
-
if not description:
|
|
137
|
-
errors.append("Missing required field: description")
|
|
138
|
-
elif len(description) < 3:
|
|
139
|
-
errors.append("Description too short (min 3 chars)")
|
|
140
|
-
elif len(description) > 2000:
|
|
141
|
-
errors.append("Description too long (max 2000 chars)")
|
|
142
|
-
|
|
143
|
-
priority = task.get("priority")
|
|
144
|
-
if priority and priority not in self.VALID_PRIORITIES:
|
|
145
|
-
errors.append(f"Invalid priority: {priority}")
|
|
146
|
-
|
|
147
|
-
tier = task.get("tier")
|
|
148
|
-
if tier and tier not in self.VALID_TIERS:
|
|
149
|
-
errors.append(f"Invalid tier: {tier}")
|
|
150
|
-
|
|
151
|
-
return (len(errors) == 0, errors)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
class DedupEngine:
|
|
155
|
-
"""Deduplication engine with LRU cache."""
|
|
156
|
-
|
|
157
|
-
def __init__(self, cache_size: int = 10000, threshold: float = 0.85):
|
|
158
|
-
self.cache_size = cache_size
|
|
159
|
-
self.threshold = threshold
|
|
160
|
-
self.hash_cache: Dict[str, str] = {}
|
|
161
|
-
self.access_order: List[str] = []
|
|
162
|
-
self.lock = threading.Lock()
|
|
163
|
-
|
|
164
|
-
@staticmethod
|
|
165
|
-
def normalize_for_hash(description: str) -> str:
|
|
166
|
-
"""Normalize description for exact hash comparison."""
|
|
167
|
-
text = description.lower()
|
|
168
|
-
text = re.sub(r'[^\w\s]', '', text)
|
|
169
|
-
text = re.sub(r'\s+', ' ', text).strip()
|
|
170
|
-
stopwords = {'the', 'a', 'an', 'is', 'are', 'was', 'were', 'to', 'for', 'of', 'in', 'on', 'with'}
|
|
171
|
-
words = [w for w in text.split() if w not in stopwords]
|
|
172
|
-
return ' '.join(sorted(words))
|
|
173
|
-
|
|
174
|
-
@staticmethod
|
|
175
|
-
def compute_dedup_key(description: str) -> str:
|
|
176
|
-
"""Compute SHA256 dedup key."""
|
|
177
|
-
normalized = DedupEngine.normalize_for_hash(description)
|
|
178
|
-
return hashlib.sha256(normalized.encode()).hexdigest()[:16]
|
|
179
|
-
|
|
180
|
-
def check_duplicate(
|
|
181
|
-
self,
|
|
182
|
-
description: str,
|
|
183
|
-
existing_tasks: List[Dict],
|
|
184
|
-
) -> DuplicateCheckResult:
|
|
185
|
-
"""Check if description is duplicate of existing tasks."""
|
|
186
|
-
|
|
187
|
-
new_hash = self.compute_dedup_key(description)
|
|
188
|
-
|
|
189
|
-
# Level 1: Check cache first
|
|
190
|
-
with self.lock:
|
|
191
|
-
cached = self.hash_cache.get(new_hash)
|
|
192
|
-
if cached:
|
|
193
|
-
return DuplicateCheckResult(
|
|
194
|
-
is_duplicate=True,
|
|
195
|
-
matching_task_id=cached,
|
|
196
|
-
similarity=1.0,
|
|
197
|
-
match_type="exact_cached"
|
|
198
|
-
)
|
|
199
|
-
|
|
200
|
-
# Level 2: Check against existing tasks
|
|
201
|
-
for task in existing_tasks:
|
|
202
|
-
source = task.get("ingestion_source", {})
|
|
203
|
-
task_hash = source.get("dedup_key")
|
|
204
|
-
|
|
205
|
-
if task_hash == new_hash:
|
|
206
|
-
self._cache_put(new_hash, task["id"])
|
|
207
|
-
return DuplicateCheckResult(
|
|
208
|
-
is_duplicate=True,
|
|
209
|
-
matching_task_id=task["id"],
|
|
210
|
-
similarity=1.0,
|
|
211
|
-
match_type="exact"
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
return DuplicateCheckResult(is_duplicate=False)
|
|
215
|
-
|
|
216
|
-
def _cache_put(self, dedup_key: str, task_id: str) -> None:
|
|
217
|
-
"""Add to cache with LRU eviction."""
|
|
218
|
-
with self.lock:
|
|
219
|
-
if dedup_key in self.hash_cache:
|
|
220
|
-
self.access_order.remove(dedup_key)
|
|
221
|
-
elif len(self.hash_cache) >= self.cache_size:
|
|
222
|
-
oldest = self.access_order.pop(0)
|
|
223
|
-
del self.hash_cache[oldest]
|
|
224
|
-
|
|
225
|
-
self.hash_cache[dedup_key] = task_id
|
|
226
|
-
self.access_order.append(dedup_key)
|
|
227
|
-
|
|
228
|
-
def warm_up(self, tasks: List[Dict]) -> None:
|
|
229
|
-
"""Pre-populate cache from existing tasks."""
|
|
230
|
-
for task in tasks[-self.cache_size:]:
|
|
231
|
-
source = task.get("ingestion_source", {})
|
|
232
|
-
dedup_key = source.get("dedup_key")
|
|
233
|
-
if dedup_key:
|
|
234
|
-
self._cache_put(dedup_key, task["id"])
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
class PlanningParser:
|
|
238
|
-
"""Parser for markdown planning documents."""
|
|
239
|
-
|
|
240
|
-
PRIORITY_PATTERNS = {
|
|
241
|
-
"HIGH": [r'\bHIGH\b', r'\bP0\b', r'\bURGENT\b', r'\bCRITICAL\b'],
|
|
242
|
-
"LOW": [r'\bLOW\b', r'\bP2\b', r'\bNICE\s*TO\s*HAVE\b'],
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
TIER_PATTERNS = {
|
|
246
|
-
"T1_PLANNING": [r'\bT1\b', r'\bPLANNING\b', r'\bDESIGN\b', r'\bARCHITECT\b'],
|
|
247
|
-
"T2_CODE": [r'\bT2\b', r'\bCODE\b', r'\bIMPLEMENT\b', r'\bBUILD\b'],
|
|
248
|
-
"T3_REVIEW": [r'\bT3\b', r'\bREVIEW\b', r'\bTEST\b', r'\bVERIFY\b'],
|
|
249
|
-
"T4_DEPLOY": [r'\bT4\b', r'\bDEPLOY\b', r'\bRELEASE\b', r'\bSHIP\b'],
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
def parse(
|
|
253
|
-
self,
|
|
254
|
-
content: str,
|
|
255
|
-
file_path: str = None,
|
|
256
|
-
skip_completed: bool = True,
|
|
257
|
-
) -> List[Dict]:
|
|
258
|
-
"""Parse planning document for tasks."""
|
|
259
|
-
tasks = []
|
|
260
|
-
lines = content.split('\n')
|
|
261
|
-
|
|
262
|
-
for line_num, line in enumerate(lines, 1):
|
|
263
|
-
# Match unchecked checkboxes
|
|
264
|
-
match = re.match(r'^\s*-\s*\[\s*\]\s*(.+)$', line)
|
|
265
|
-
if match:
|
|
266
|
-
description = match.group(1).strip()
|
|
267
|
-
task = self._create_task(
|
|
268
|
-
description, line_num, line, file_path
|
|
269
|
-
)
|
|
270
|
-
tasks.append(task)
|
|
271
|
-
|
|
272
|
-
# Match checked checkboxes (if not skipping)
|
|
273
|
-
if not skip_completed:
|
|
274
|
-
match = re.match(r'^\s*-\s*\[x\]\s*(.+)$', line, re.I)
|
|
275
|
-
if match:
|
|
276
|
-
description = match.group(1).strip()
|
|
277
|
-
task = self._create_task(
|
|
278
|
-
description, line_num, line, file_path
|
|
279
|
-
)
|
|
280
|
-
task["status"] = "DONE"
|
|
281
|
-
tasks.append(task)
|
|
282
|
-
|
|
283
|
-
return tasks
|
|
284
|
-
|
|
285
|
-
def _create_task(
|
|
286
|
-
self,
|
|
287
|
-
description: str,
|
|
288
|
-
line_num: int,
|
|
289
|
-
original_line: str,
|
|
290
|
-
file_path: str,
|
|
291
|
-
) -> Dict:
|
|
292
|
-
"""Create task dict from parsed line."""
|
|
293
|
-
priority = self._infer_priority(description)
|
|
294
|
-
tier = self._infer_tier(description)
|
|
295
|
-
|
|
296
|
-
return {
|
|
297
|
-
"description": self._clean_description(description),
|
|
298
|
-
"priority": priority,
|
|
299
|
-
"tier": tier,
|
|
300
|
-
"status": "PENDING",
|
|
301
|
-
"ingestion_source": {
|
|
302
|
-
"type": SourceType.PLANNING.value,
|
|
303
|
-
"file": file_path,
|
|
304
|
-
"line_number": line_num,
|
|
305
|
-
"original_text": original_line.strip()[:500],
|
|
306
|
-
},
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
def _infer_priority(self, text: str) -> str:
|
|
310
|
-
"""Infer priority from text."""
|
|
311
|
-
text_upper = text.upper()
|
|
312
|
-
for priority, patterns in self.PRIORITY_PATTERNS.items():
|
|
313
|
-
for pattern in patterns:
|
|
314
|
-
if re.search(pattern, text_upper):
|
|
315
|
-
return priority
|
|
316
|
-
return "MEDIUM"
|
|
317
|
-
|
|
318
|
-
def _infer_tier(self, text: str) -> str:
|
|
319
|
-
"""Infer tier from text."""
|
|
320
|
-
text_upper = text.upper()
|
|
321
|
-
for tier, patterns in self.TIER_PATTERNS.items():
|
|
322
|
-
for pattern in patterns:
|
|
323
|
-
if re.search(pattern, text_upper):
|
|
324
|
-
return tier
|
|
325
|
-
return "T2_CODE"
|
|
326
|
-
|
|
327
|
-
def _clean_description(self, description: str) -> str:
|
|
328
|
-
"""Remove priority/tier annotations from description."""
|
|
329
|
-
text = description
|
|
330
|
-
for patterns in self.PRIORITY_PATTERNS.values():
|
|
331
|
-
for pattern in patterns:
|
|
332
|
-
text = re.sub(pattern, '', text, flags=re.I)
|
|
333
|
-
for patterns in self.TIER_PATTERNS.values():
|
|
334
|
-
for pattern in patterns:
|
|
335
|
-
text = re.sub(pattern, '', text, flags=re.I)
|
|
336
|
-
text = re.sub(r'\s*-\s*$', '', text)
|
|
337
|
-
text = re.sub(r'\s+', ' ', text).strip()
|
|
338
|
-
return text
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
class TodoParser:
|
|
342
|
-
"""Parser for TODO/FIXME/HACK comments in code."""
|
|
343
|
-
|
|
344
|
-
PATTERNS = [
|
|
345
|
-
(r'#\s*(TODO|FIXME|HACK)[\s:]*(.+?)(?:\n|$)', 'python'),
|
|
346
|
-
(r'//\s*(TODO|FIXME|HACK)[\s:]*(.+?)(?:\n|$)', 'js'),
|
|
347
|
-
(r'/\*\s*(TODO|FIXME|HACK)[\s:]*(.+?)\*/', 'c'),
|
|
348
|
-
]
|
|
349
|
-
|
|
350
|
-
PRIORITY_MAP = {
|
|
351
|
-
"TODO": "MEDIUM",
|
|
352
|
-
"FIXME": "HIGH",
|
|
353
|
-
"HACK": "MEDIUM",
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
def parse(
|
|
357
|
-
self,
|
|
358
|
-
content: str,
|
|
359
|
-
file_path: str = None,
|
|
360
|
-
) -> List[Dict]:
|
|
361
|
-
"""Parse code for TODO comments."""
|
|
362
|
-
tasks = []
|
|
363
|
-
|
|
364
|
-
for pattern, lang in self.PATTERNS:
|
|
365
|
-
for match in re.finditer(pattern, content, re.IGNORECASE):
|
|
366
|
-
tag = match.group(1).upper()
|
|
367
|
-
description = match.group(2).strip()
|
|
368
|
-
|
|
369
|
-
# Find line number
|
|
370
|
-
line_num = content[:match.start()].count('\n') + 1
|
|
371
|
-
|
|
372
|
-
task = {
|
|
373
|
-
"description": f"[{tag}] {description}",
|
|
374
|
-
"priority": self.PRIORITY_MAP.get(tag, "MEDIUM"),
|
|
375
|
-
"tier": "T2_CODE",
|
|
376
|
-
"status": "PENDING",
|
|
377
|
-
"ingestion_source": {
|
|
378
|
-
"type": SourceType.TODOS.value,
|
|
379
|
-
"file": file_path,
|
|
380
|
-
"line_number": line_num,
|
|
381
|
-
"original_text": match.group(0).strip()[:500],
|
|
382
|
-
},
|
|
383
|
-
}
|
|
384
|
-
tasks.append(task)
|
|
385
|
-
|
|
386
|
-
return tasks
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
class HandoffParser:
|
|
390
|
-
"""Parser for agent handoff JSON."""
|
|
391
|
-
|
|
392
|
-
def parse(
|
|
393
|
-
self,
|
|
394
|
-
content: str,
|
|
395
|
-
session_id: str = None,
|
|
396
|
-
) -> List[Dict]:
|
|
397
|
-
"""Parse handoff JSON for tasks."""
|
|
398
|
-
try:
|
|
399
|
-
data = json.loads(content)
|
|
400
|
-
except json.JSONDecodeError as e:
|
|
401
|
-
raise ValueError(f"Invalid handoff JSON: {e}")
|
|
402
|
-
|
|
403
|
-
tasks = []
|
|
404
|
-
from_session = data.get("from_session", "unknown")
|
|
405
|
-
to_session = data.get("to_session")
|
|
406
|
-
context = data.get("context", "")
|
|
407
|
-
|
|
408
|
-
for task_data in data.get("tasks", []):
|
|
409
|
-
description = task_data.get("description", "")
|
|
410
|
-
if not description:
|
|
411
|
-
continue
|
|
412
|
-
|
|
413
|
-
task = {
|
|
414
|
-
"description": description,
|
|
415
|
-
"priority": task_data.get("priority", "MEDIUM"),
|
|
416
|
-
"tier": task_data.get("tier", "T2_CODE"),
|
|
417
|
-
"status": "PENDING",
|
|
418
|
-
"context_summary": {
|
|
419
|
-
"generated_at": self._now_iso(),
|
|
420
|
-
"generated_by": from_session,
|
|
421
|
-
"summary": context,
|
|
422
|
-
"handoff_notes": task_data.get("notes", ""),
|
|
423
|
-
},
|
|
424
|
-
"ingestion_source": {
|
|
425
|
-
"type": SourceType.HANDOFFS.value,
|
|
426
|
-
"ingested_by": session_id or from_session,
|
|
427
|
-
"target_session": to_session,
|
|
428
|
-
"original_text": json.dumps(task_data)[:500],
|
|
429
|
-
},
|
|
430
|
-
}
|
|
431
|
-
tasks.append(task)
|
|
432
|
-
|
|
433
|
-
return tasks
|
|
434
|
-
|
|
435
|
-
def _now_iso(self) -> str:
|
|
436
|
-
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
class MeetingParser:
|
|
440
|
-
"""Parser for meeting notes / action items."""
|
|
441
|
-
|
|
442
|
-
ACTION_PATTERNS = [
|
|
443
|
-
r'@(\w+)[\s:]+(.+?)(?:\n|$)', # @person: action
|
|
444
|
-
r'-\s*\[?\s*\]?\s*@(\w+)[\s:]+(.+?)(?:\n|$)', # - [ ] @person: action
|
|
445
|
-
r'ACTION[\s:]+@?(\w+)[\s:]+(.+?)(?:\n|$)', # ACTION: person: desc
|
|
446
|
-
]
|
|
447
|
-
|
|
448
|
-
def parse(
|
|
449
|
-
self,
|
|
450
|
-
content: str,
|
|
451
|
-
file_path: str = None,
|
|
452
|
-
) -> List[Dict]:
|
|
453
|
-
"""Parse meeting notes for action items."""
|
|
454
|
-
tasks = []
|
|
455
|
-
|
|
456
|
-
for pattern in self.ACTION_PATTERNS:
|
|
457
|
-
for match in re.finditer(pattern, content, re.IGNORECASE):
|
|
458
|
-
assignee = match.group(1)
|
|
459
|
-
description = match.group(2).strip()
|
|
460
|
-
line_num = content[:match.start()].count('\n') + 1
|
|
461
|
-
|
|
462
|
-
task = {
|
|
463
|
-
"description": description,
|
|
464
|
-
"priority": "MEDIUM",
|
|
465
|
-
"tier": "T1_PLANNING",
|
|
466
|
-
"status": "PENDING",
|
|
467
|
-
"claimed_by": None, # Don't auto-assign from @mention
|
|
468
|
-
"ingestion_source": {
|
|
469
|
-
"type": SourceType.MEETINGS.value,
|
|
470
|
-
"file": file_path,
|
|
471
|
-
"line_number": line_num,
|
|
472
|
-
"original_text": match.group(0).strip()[:500],
|
|
473
|
-
"mentioned_assignee": assignee,
|
|
474
|
-
},
|
|
475
|
-
}
|
|
476
|
-
tasks.append(task)
|
|
477
|
-
|
|
478
|
-
return tasks
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
class ApiParser:
|
|
482
|
-
"""Parser for external API payloads (Jira, Linear, GitHub)."""
|
|
483
|
-
|
|
484
|
-
def parse(
|
|
485
|
-
self,
|
|
486
|
-
payload: Dict,
|
|
487
|
-
source_name: str = "api",
|
|
488
|
-
) -> List[Dict]:
|
|
489
|
-
"""Parse API payload for tasks."""
|
|
490
|
-
tasks = []
|
|
491
|
-
|
|
492
|
-
# Handle different API formats
|
|
493
|
-
items = payload.get("issues") or payload.get("tickets") or payload.get("items") or []
|
|
494
|
-
|
|
495
|
-
for item in items:
|
|
496
|
-
# Normalize field names
|
|
497
|
-
description = (
|
|
498
|
-
item.get("summary") or
|
|
499
|
-
item.get("title") or
|
|
500
|
-
item.get("description") or
|
|
501
|
-
item.get("name") or ""
|
|
502
|
-
)
|
|
503
|
-
|
|
504
|
-
if not description:
|
|
505
|
-
continue
|
|
506
|
-
|
|
507
|
-
priority = self._normalize_priority(
|
|
508
|
-
item.get("priority") or item.get("severity") or "Medium"
|
|
509
|
-
)
|
|
510
|
-
|
|
511
|
-
external_id = (
|
|
512
|
-
item.get("key") or
|
|
513
|
-
item.get("id") or
|
|
514
|
-
item.get("number") or ""
|
|
515
|
-
)
|
|
516
|
-
|
|
517
|
-
task = {
|
|
518
|
-
"description": description,
|
|
519
|
-
"priority": priority,
|
|
520
|
-
"tier": "T2_CODE",
|
|
521
|
-
"status": "PENDING",
|
|
522
|
-
"ingestion_source": {
|
|
523
|
-
"type": SourceType.API.value,
|
|
524
|
-
"external_id": str(external_id),
|
|
525
|
-
"api_source": source_name,
|
|
526
|
-
"original_text": json.dumps(item)[:500],
|
|
527
|
-
},
|
|
528
|
-
}
|
|
529
|
-
tasks.append(task)
|
|
530
|
-
|
|
531
|
-
return tasks
|
|
532
|
-
|
|
533
|
-
def _normalize_priority(self, priority: str) -> str:
|
|
534
|
-
"""Normalize priority from various API formats."""
|
|
535
|
-
priority_lower = str(priority).lower()
|
|
536
|
-
if priority_lower in ["highest", "critical", "blocker", "p0", "urgent"]:
|
|
537
|
-
return "HIGH"
|
|
538
|
-
if priority_lower in ["lowest", "trivial", "minor", "p2", "low"]:
|
|
539
|
-
return "LOW"
|
|
540
|
-
return "MEDIUM"
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
class IngestionBatch:
|
|
544
|
-
"""Track operations in an ingestion batch."""
|
|
545
|
-
|
|
546
|
-
def __init__(self, batch_id: str, source_type: str, session_id: str):
|
|
547
|
-
self.batch_id = batch_id
|
|
548
|
-
self.source_type = source_type
|
|
549
|
-
self.session_id = session_id
|
|
550
|
-
self.created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
551
|
-
self.created_task_ids: List[str] = []
|
|
552
|
-
self.updated_tasks: List[Dict] = []
|
|
553
|
-
self.skipped_task_ids: List[str] = []
|
|
554
|
-
self.failed_tasks: List[Dict] = []
|
|
555
|
-
self.source_file: str = None
|
|
556
|
-
self.completed_at: str = None
|
|
557
|
-
self.rolled_back_at: str = None
|
|
558
|
-
|
|
559
|
-
def record_create(self, task_id: str) -> None:
|
|
560
|
-
self.created_task_ids.append(task_id)
|
|
561
|
-
|
|
562
|
-
def record_update(self, task_id: str, field: str, old_val: Any, new_val: Any) -> None:
|
|
563
|
-
self.updated_tasks.append({
|
|
564
|
-
"task_id": task_id, "field": field,
|
|
565
|
-
"old_value": old_val, "new_value": new_val,
|
|
566
|
-
})
|
|
567
|
-
|
|
568
|
-
def record_skip(self, task_id: str) -> None:
|
|
569
|
-
self.skipped_task_ids.append(task_id)
|
|
570
|
-
|
|
571
|
-
def record_failure(self, description: str, error: str) -> None:
|
|
572
|
-
self.failed_tasks.append({"description": description[:200], "error": error})
|
|
573
|
-
|
|
574
|
-
def to_dict(self) -> Dict:
|
|
575
|
-
return {
|
|
576
|
-
"batch_id": self.batch_id,
|
|
577
|
-
"source_type": self.source_type,
|
|
578
|
-
"session_id": self.session_id,
|
|
579
|
-
"source_file": self.source_file,
|
|
580
|
-
"created_at": self.created_at,
|
|
581
|
-
"completed_at": self.completed_at,
|
|
582
|
-
"rolled_back_at": self.rolled_back_at,
|
|
583
|
-
"stats": {
|
|
584
|
-
"created": len(self.created_task_ids),
|
|
585
|
-
"updated": len(self.updated_tasks),
|
|
586
|
-
"skipped": len(self.skipped_task_ids),
|
|
587
|
-
"failed": len(self.failed_tasks),
|
|
588
|
-
},
|
|
589
|
-
"created_task_ids": self.created_task_ids,
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
class TaskIngestionEngine:
|
|
594
|
-
"""
|
|
595
|
-
Enterprise-grade multi-source task ingestion engine.
|
|
596
|
-
|
|
597
|
-
Scales: 1 → 1000 sources, 10K tasks/sec
|
|
598
|
-
"""
|
|
599
|
-
|
|
600
|
-
def __init__(
|
|
601
|
-
self,
|
|
602
|
-
task_store: Any = None,
|
|
603
|
-
agent_pool: Any = None,
|
|
604
|
-
brain_path: Path = None,
|
|
605
|
-
enable_embeddings: bool = False,
|
|
606
|
-
dedup_threshold: float = 0.85,
|
|
607
|
-
):
|
|
608
|
-
self.task_store = task_store
|
|
609
|
-
self.agent_pool = agent_pool
|
|
610
|
-
self.brain_path = brain_path
|
|
611
|
-
self.enable_embeddings = enable_embeddings
|
|
612
|
-
|
|
613
|
-
self.sanitizer = InputSanitizer()
|
|
614
|
-
self.validator = TaskValidator()
|
|
615
|
-
self.dedup_engine = DedupEngine(threshold=dedup_threshold)
|
|
616
|
-
|
|
617
|
-
self.parsers = {
|
|
618
|
-
SourceType.PLANNING.value: PlanningParser(),
|
|
619
|
-
SourceType.TODOS.value: TodoParser(),
|
|
620
|
-
SourceType.HANDOFFS.value: HandoffParser(),
|
|
621
|
-
SourceType.MEETINGS.value: MeetingParser(),
|
|
622
|
-
SourceType.API.value: ApiParser(),
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
self.batches: Dict[str, IngestionBatch] = {}
|
|
626
|
-
self.lock = threading.RLock()
|
|
627
|
-
|
|
628
|
-
self.stats = {
|
|
629
|
-
"total_ingested": 0,
|
|
630
|
-
"total_skipped": 0,
|
|
631
|
-
"total_failed": 0,
|
|
632
|
-
"by_source": defaultdict(int),
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
def ingest_from_file(
|
|
636
|
-
self,
|
|
637
|
-
file_path: str,
|
|
638
|
-
source_type: str = "auto",
|
|
639
|
-
session_id: str = None,
|
|
640
|
-
auto_assign: bool = False,
|
|
641
|
-
skip_dedup: bool = False,
|
|
642
|
-
dry_run: bool = False,
|
|
643
|
-
) -> IngestionResult:
|
|
644
|
-
"""Ingest tasks from a file."""
|
|
645
|
-
file_path = self.sanitizer.sanitize_file_path(file_path)
|
|
646
|
-
|
|
647
|
-
if not os.path.exists(file_path):
|
|
648
|
-
return IngestionResult(
|
|
649
|
-
success=False,
|
|
650
|
-
batch_id="",
|
|
651
|
-
errors=[f"File not found: {file_path}"]
|
|
652
|
-
)
|
|
653
|
-
|
|
654
|
-
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
655
|
-
content = f.read()
|
|
656
|
-
|
|
657
|
-
if source_type == "auto":
|
|
658
|
-
source_type = self._detect_source_type(file_path, content)
|
|
659
|
-
|
|
660
|
-
return self.ingest_from_text(
|
|
661
|
-
text=content,
|
|
662
|
-
source_type=source_type,
|
|
663
|
-
session_id=session_id,
|
|
664
|
-
metadata={"file": file_path},
|
|
665
|
-
auto_assign=auto_assign,
|
|
666
|
-
skip_dedup=skip_dedup,
|
|
667
|
-
dry_run=dry_run,
|
|
668
|
-
)
|
|
669
|
-
|
|
670
|
-
def ingest_from_text(
|
|
671
|
-
self,
|
|
672
|
-
text: str,
|
|
673
|
-
source_type: str,
|
|
674
|
-
session_id: str = None,
|
|
675
|
-
metadata: Dict = None,
|
|
676
|
-
auto_assign: bool = False,
|
|
677
|
-
skip_dedup: bool = False,
|
|
678
|
-
dry_run: bool = False,
|
|
679
|
-
) -> IngestionResult:
|
|
680
|
-
"""Ingest tasks from raw text."""
|
|
681
|
-
batch_id = self._generate_batch_id()
|
|
682
|
-
batch = IngestionBatch(batch_id, source_type, session_id or "unknown")
|
|
683
|
-
|
|
684
|
-
if metadata and metadata.get("file"):
|
|
685
|
-
batch.source_file = metadata["file"]
|
|
686
|
-
|
|
687
|
-
try:
|
|
688
|
-
# Parse content
|
|
689
|
-
parser = self.parsers.get(source_type)
|
|
690
|
-
if not parser:
|
|
691
|
-
return IngestionResult(
|
|
692
|
-
success=False,
|
|
693
|
-
batch_id=batch_id,
|
|
694
|
-
errors=[f"Unknown source type: {source_type}"]
|
|
695
|
-
)
|
|
696
|
-
|
|
697
|
-
if source_type == SourceType.HANDOFFS.value:
|
|
698
|
-
tasks = parser.parse(text, session_id)
|
|
699
|
-
elif metadata and metadata.get("file"):
|
|
700
|
-
tasks = parser.parse(text, file_path=metadata["file"])
|
|
701
|
-
else:
|
|
702
|
-
tasks = parser.parse(text)
|
|
703
|
-
|
|
704
|
-
return self._process_tasks(
|
|
705
|
-
tasks, batch, auto_assign, skip_dedup, dry_run
|
|
706
|
-
)
|
|
707
|
-
|
|
708
|
-
except Exception as e:
|
|
709
|
-
return IngestionResult(
|
|
710
|
-
success=False,
|
|
711
|
-
batch_id=batch_id,
|
|
712
|
-
errors=[f"Parse error: {str(e)}"]
|
|
713
|
-
)
|
|
714
|
-
|
|
715
|
-
def ingest_from_api(
|
|
716
|
-
self,
|
|
717
|
-
source_name: str,
|
|
718
|
-
payload: Dict,
|
|
719
|
-
session_id: str = None,
|
|
720
|
-
auto_assign: bool = False,
|
|
721
|
-
) -> IngestionResult:
|
|
722
|
-
"""Ingest tasks from external API payload."""
|
|
723
|
-
batch_id = self._generate_batch_id()
|
|
724
|
-
batch = IngestionBatch(batch_id, SourceType.API.value, session_id or "unknown")
|
|
725
|
-
|
|
726
|
-
try:
|
|
727
|
-
parser = self.parsers[SourceType.API.value]
|
|
728
|
-
tasks = parser.parse(payload, source_name)
|
|
729
|
-
|
|
730
|
-
return self._process_tasks(tasks, batch, auto_assign)
|
|
731
|
-
|
|
732
|
-
except Exception as e:
|
|
733
|
-
return IngestionResult(
|
|
734
|
-
success=False,
|
|
735
|
-
batch_id=batch_id,
|
|
736
|
-
errors=[f"API parse error: {str(e)}"]
|
|
737
|
-
)
|
|
738
|
-
|
|
739
|
-
def ingest_batch(
|
|
740
|
-
self,
|
|
741
|
-
tasks: List[Dict],
|
|
742
|
-
source_type: str = "manual",
|
|
743
|
-
session_id: str = None,
|
|
744
|
-
skip_dedup: bool = False,
|
|
745
|
-
dry_run: bool = False,
|
|
746
|
-
) -> IngestionResult:
|
|
747
|
-
"""Ingest a batch of pre-parsed tasks."""
|
|
748
|
-
batch_id = self._generate_batch_id()
|
|
749
|
-
batch = IngestionBatch(batch_id, source_type, session_id or "unknown")
|
|
750
|
-
|
|
751
|
-
return self._process_tasks(tasks, batch, False, skip_dedup, dry_run)
|
|
752
|
-
|
|
753
|
-
def _process_tasks(
|
|
754
|
-
self,
|
|
755
|
-
tasks: List[Dict],
|
|
756
|
-
batch: IngestionBatch,
|
|
757
|
-
auto_assign: bool = False,
|
|
758
|
-
skip_dedup: bool = False,
|
|
759
|
-
dry_run: bool = False,
|
|
760
|
-
) -> IngestionResult:
|
|
761
|
-
"""Process parsed tasks through validation, dedup, and storage."""
|
|
762
|
-
result = IngestionResult(success=True, batch_id=batch.batch_id)
|
|
763
|
-
|
|
764
|
-
# Track seen descriptions within this batch for intra-batch dedup
|
|
765
|
-
seen_in_batch: Dict[str, str] = {} # dedup_key -> task_id
|
|
766
|
-
|
|
767
|
-
with self.lock:
|
|
768
|
-
existing_tasks = self._get_existing_tasks()
|
|
769
|
-
|
|
770
|
-
for task in tasks:
|
|
771
|
-
try:
|
|
772
|
-
# Sanitize
|
|
773
|
-
task["description"] = self.sanitizer.sanitize_description(
|
|
774
|
-
task.get("description", "")
|
|
775
|
-
)
|
|
776
|
-
|
|
777
|
-
# Validate
|
|
778
|
-
is_valid, errors = self.validator.validate(task)
|
|
779
|
-
if not is_valid:
|
|
780
|
-
batch.record_failure(task.get("description", "")[:100], "; ".join(errors))
|
|
781
|
-
result.tasks_failed += 1
|
|
782
|
-
result.errors.extend(errors)
|
|
783
|
-
continue
|
|
784
|
-
|
|
785
|
-
# Dedup check
|
|
786
|
-
if not skip_dedup:
|
|
787
|
-
# First check intra-batch duplicates
|
|
788
|
-
dedup_key = DedupEngine.compute_dedup_key(task["description"])
|
|
789
|
-
if dedup_key in seen_in_batch:
|
|
790
|
-
batch.record_skip(seen_in_batch[dedup_key])
|
|
791
|
-
result.tasks_skipped += 1
|
|
792
|
-
continue
|
|
793
|
-
|
|
794
|
-
# Then check against existing tasks
|
|
795
|
-
dup_result = self.dedup_engine.check_duplicate(
|
|
796
|
-
task["description"], existing_tasks
|
|
797
|
-
)
|
|
798
|
-
if dup_result.is_duplicate:
|
|
799
|
-
batch.record_skip(dup_result.matching_task_id)
|
|
800
|
-
result.tasks_skipped += 1
|
|
801
|
-
continue
|
|
802
|
-
|
|
803
|
-
if dry_run:
|
|
804
|
-
result.tasks_created += 1
|
|
805
|
-
if not skip_dedup:
|
|
806
|
-
seen_in_batch[dedup_key] = f"dry_run_{len(seen_in_batch)}"
|
|
807
|
-
continue
|
|
808
|
-
|
|
809
|
-
# Create task
|
|
810
|
-
task_id = self._create_task(task, batch)
|
|
811
|
-
|
|
812
|
-
# Track for intra-batch dedup
|
|
813
|
-
if not skip_dedup:
|
|
814
|
-
seen_in_batch[dedup_key] = task_id
|
|
815
|
-
batch.record_create(task_id)
|
|
816
|
-
result.tasks_created += 1
|
|
817
|
-
result.created_task_ids.append(task_id)
|
|
818
|
-
|
|
819
|
-
# Auto-assign if enabled
|
|
820
|
-
if auto_assign and self.agent_pool:
|
|
821
|
-
assign_result = self._auto_assign(task_id, task)
|
|
822
|
-
if assign_result.get("assigned"):
|
|
823
|
-
result.auto_assigned.append({
|
|
824
|
-
"task_id": task_id,
|
|
825
|
-
"agent_id": assign_result.get("agent_id"),
|
|
826
|
-
})
|
|
827
|
-
|
|
828
|
-
except Exception as e:
|
|
829
|
-
batch.record_failure(task.get("description", "")[:100], str(e))
|
|
830
|
-
result.tasks_failed += 1
|
|
831
|
-
result.errors.append(str(e))
|
|
832
|
-
|
|
833
|
-
# Finalize batch
|
|
834
|
-
batch.completed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
835
|
-
self.batches[batch.batch_id] = batch
|
|
836
|
-
result.rollback_id = batch.batch_id
|
|
837
|
-
|
|
838
|
-
# Update stats
|
|
839
|
-
self.stats["total_ingested"] += result.tasks_created
|
|
840
|
-
self.stats["total_skipped"] += result.tasks_skipped
|
|
841
|
-
self.stats["total_failed"] += result.tasks_failed
|
|
842
|
-
self.stats["by_source"][batch.source_type] += result.tasks_created
|
|
843
|
-
|
|
844
|
-
result.success = result.tasks_failed == 0
|
|
845
|
-
return result
|
|
846
|
-
|
|
847
|
-
def _create_task(self, task: Dict, batch: IngestionBatch) -> str:
|
|
848
|
-
"""Create task in store."""
|
|
849
|
-
task_id = f"task-{uuid.uuid4().hex[:8]}"
|
|
850
|
-
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
851
|
-
|
|
852
|
-
# Ensure ingestion_source
|
|
853
|
-
if "ingestion_source" not in task:
|
|
854
|
-
task["ingestion_source"] = {}
|
|
855
|
-
|
|
856
|
-
source = task["ingestion_source"]
|
|
857
|
-
source["type"] = source.get("type", batch.source_type)
|
|
858
|
-
source["ingested_at"] = now
|
|
859
|
-
source["ingested_by"] = batch.session_id
|
|
860
|
-
source["dedup_key"] = DedupEngine.compute_dedup_key(task["description"])
|
|
861
|
-
source["batch_id"] = batch.batch_id
|
|
862
|
-
|
|
863
|
-
# Full task structure
|
|
864
|
-
full_task = {
|
|
865
|
-
"id": task_id,
|
|
866
|
-
"description": task["description"],
|
|
867
|
-
"status": task.get("status", "PENDING"),
|
|
868
|
-
"priority": task.get("priority", "MEDIUM"),
|
|
869
|
-
"tier": task.get("tier", "T2_CODE"),
|
|
870
|
-
"blocked_by": task.get("blocked_by", []),
|
|
871
|
-
"required_skills": task.get("required_skills", []),
|
|
872
|
-
"claimed_by": task.get("claimed_by"),
|
|
873
|
-
"source": batch.source_type,
|
|
874
|
-
"created_at": now,
|
|
875
|
-
"updated_at": now,
|
|
876
|
-
"ingestion_source": source,
|
|
877
|
-
"context_summary": task.get("context_summary"),
|
|
878
|
-
"checkpoint": None,
|
|
879
|
-
"dependency_metadata": {"depth": 0, "blocks": []},
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
# Store task
|
|
883
|
-
if self.task_store:
|
|
884
|
-
self.task_store.put(task_id, full_task)
|
|
885
|
-
|
|
886
|
-
# Add to dedup cache
|
|
887
|
-
self.dedup_engine._cache_put(source["dedup_key"], task_id)
|
|
888
|
-
|
|
889
|
-
return task_id
|
|
890
|
-
|
|
891
|
-
def _auto_assign(self, task_id: str, task: Dict) -> Dict:
|
|
892
|
-
"""Auto-assign task to available agent."""
|
|
893
|
-
if not self.agent_pool:
|
|
894
|
-
return {"assigned": False, "reason": "no_pool"}
|
|
895
|
-
|
|
896
|
-
tier = task.get("tier", "T2_CODE")
|
|
897
|
-
return self.agent_pool.assign_task(task_id, tier=tier)
|
|
898
|
-
|
|
899
|
-
def check_duplicate(self, description: str) -> DuplicateCheckResult:
|
|
900
|
-
"""Check if description would be duplicate."""
|
|
901
|
-
existing = self._get_existing_tasks()
|
|
902
|
-
return self.dedup_engine.check_duplicate(description, existing)
|
|
903
|
-
|
|
904
|
-
def rollback(self, batch_id: str, reason: str = None) -> Dict:
|
|
905
|
-
"""Rollback an ingestion batch."""
|
|
906
|
-
with self.lock:
|
|
907
|
-
batch = self.batches.get(batch_id)
|
|
908
|
-
if not batch:
|
|
909
|
-
return {"success": False, "error": f"Batch {batch_id} not found"}
|
|
910
|
-
|
|
911
|
-
if batch.rolled_back_at:
|
|
912
|
-
return {"success": False, "error": "Batch already rolled back"}
|
|
913
|
-
|
|
914
|
-
removed = 0
|
|
915
|
-
for task_id in batch.created_task_ids:
|
|
916
|
-
if self.task_store:
|
|
917
|
-
self.task_store.delete(task_id)
|
|
918
|
-
removed += 1
|
|
919
|
-
|
|
920
|
-
batch.rolled_back_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
921
|
-
|
|
922
|
-
return {
|
|
923
|
-
"success": True,
|
|
924
|
-
"batch_id": batch_id,
|
|
925
|
-
"tasks_removed": removed,
|
|
926
|
-
"reason": reason,
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
def get_ingestion_stats(self) -> Dict:
|
|
930
|
-
"""Get overall ingestion statistics."""
|
|
931
|
-
return {
|
|
932
|
-
"total_ingested": self.stats["total_ingested"],
|
|
933
|
-
"total_skipped": self.stats["total_skipped"],
|
|
934
|
-
"total_failed": self.stats["total_failed"],
|
|
935
|
-
"by_source": dict(self.stats["by_source"]),
|
|
936
|
-
"batches_count": len(self.batches),
|
|
937
|
-
"dedup_cache_size": len(self.dedup_engine.hash_cache),
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
def list_batches(self, limit: int = 10) -> List[Dict]:
|
|
941
|
-
"""List recent ingestion batches."""
|
|
942
|
-
batches = sorted(
|
|
943
|
-
self.batches.values(),
|
|
944
|
-
key=lambda b: b.created_at,
|
|
945
|
-
reverse=True
|
|
946
|
-
)[:limit]
|
|
947
|
-
return [b.to_dict() for b in batches]
|
|
948
|
-
|
|
949
|
-
def _detect_source_type(self, file_path: str, content: str) -> str:
|
|
950
|
-
"""Auto-detect source type from file/content."""
|
|
951
|
-
path = Path(file_path)
|
|
952
|
-
filename = path.name.lower()
|
|
953
|
-
ext = path.suffix.lower()
|
|
954
|
-
|
|
955
|
-
if "handoff" in filename:
|
|
956
|
-
return SourceType.HANDOFFS.value
|
|
957
|
-
if "meeting" in filename or "standup" in filename:
|
|
958
|
-
return SourceType.MEETINGS.value
|
|
959
|
-
if "planning" in filename or "sprint" in filename or "roadmap" in filename:
|
|
960
|
-
return SourceType.PLANNING.value
|
|
961
|
-
|
|
962
|
-
if ext in [".py", ".js", ".ts", ".go", ".rs", ".java", ".c", ".cpp"]:
|
|
963
|
-
return SourceType.TODOS.value
|
|
964
|
-
if ext == ".json":
|
|
965
|
-
return SourceType.API.value
|
|
966
|
-
if ext == ".md":
|
|
967
|
-
if "- [ ]" in content or "- [x]" in content:
|
|
968
|
-
return SourceType.PLANNING.value
|
|
969
|
-
if "@" in content and ("action" in content.lower() or "item" in content.lower()):
|
|
970
|
-
return SourceType.MEETINGS.value
|
|
971
|
-
return SourceType.PLANNING.value
|
|
972
|
-
|
|
973
|
-
return SourceType.MANUAL.value
|
|
974
|
-
|
|
975
|
-
def _generate_batch_id(self) -> str:
|
|
976
|
-
"""Generate unique batch ID."""
|
|
977
|
-
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
978
|
-
suffix = uuid.uuid4().hex[:6]
|
|
979
|
-
return f"batch_{timestamp}_{suffix}"
|
|
980
|
-
|
|
981
|
-
def _get_existing_tasks(self) -> List[Dict]:
|
|
982
|
-
"""Get existing tasks for dedup check."""
|
|
983
|
-
if self.task_store:
|
|
984
|
-
return list(self.task_store.get_all().values())
|
|
985
|
-
return []
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
def format_ingestion_result(result: IngestionResult) -> str:
|
|
989
|
-
"""Format ingestion result for MCP tool output."""
|
|
990
|
-
lines = []
|
|
991
|
-
|
|
992
|
-
if result.success:
|
|
993
|
-
lines.append(f"✅ Ingestion Complete - Batch: {result.batch_id}")
|
|
994
|
-
else:
|
|
995
|
-
lines.append(f"⚠️ Ingestion Partial - Batch: {result.batch_id}")
|
|
996
|
-
|
|
997
|
-
lines.append("=" * 50)
|
|
998
|
-
lines.append("\n📊 **Results:**")
|
|
999
|
-
lines.append(f" Created: {result.tasks_created}")
|
|
1000
|
-
lines.append(f" Updated: {result.tasks_updated}")
|
|
1001
|
-
lines.append(f" Skipped: {result.tasks_skipped} (duplicates)")
|
|
1002
|
-
|
|
1003
|
-
if result.tasks_failed > 0:
|
|
1004
|
-
lines.append(f" ❌ Failed: {result.tasks_failed}")
|
|
1005
|
-
|
|
1006
|
-
if result.errors:
|
|
1007
|
-
lines.append("\n⚠️ **Errors:**")
|
|
1008
|
-
for error in result.errors[:5]:
|
|
1009
|
-
lines.append(f" - {error}")
|
|
1010
|
-
if len(result.errors) > 5:
|
|
1011
|
-
lines.append(f" ... and {len(result.errors) - 5} more")
|
|
1012
|
-
|
|
1013
|
-
if result.auto_assigned:
|
|
1014
|
-
lines.append("\n🤖 **Auto-Assigned:**")
|
|
1015
|
-
for assignment in result.auto_assigned[:10]:
|
|
1016
|
-
lines.append(f" - {assignment['task_id']} → {assignment['agent_id']}")
|
|
1017
|
-
|
|
1018
|
-
lines.append(f"\n💡 To undo: brain_rollback_ingestion('{result.batch_id}')")
|
|
1019
|
-
|
|
1020
|
-
return "\n".join(lines)
|