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.
Files changed (135) hide show
  1. package/package.json +2 -4
  2. package/.github/workflows/ci.yml +0 -72
  3. package/.smithery/shttp/module.js +0 -3
  4. package/.smithery/shttp/module.js.map +0 -7
  5. package/.smithery/stdio/index.cjs +0 -74
  6. package/.smithery/stdio/index.cjs.map +0 -7
  7. package/CHANGELOG.md +0 -65
  8. package/CODE_OF_CONDUCT.md +0 -34
  9. package/COMPARISON.md +0 -20
  10. package/CONTRIBUTING.md +0 -137
  11. package/DEVELOPMENT.md +0 -109
  12. package/LICENSE +0 -21
  13. package/README.md +0 -319
  14. package/ROADMAP.md +0 -24
  15. package/SECURITY.md +0 -88
  16. package/docs/COMMUNITY_FAQ.md +0 -48
  17. package/docs/COMPETITIVE_ANALYSIS.md +0 -90
  18. package/docs/DEMO_SCRIPT.md +0 -86
  19. package/docs/ENTERPRISE.md +0 -169
  20. package/docs/FAQ.md +0 -228
  21. package/docs/QUICK_START.md +0 -155
  22. package/docs/README.md +0 -67
  23. package/docs/RECURSIVE_SNAP_DEMO.gif +0 -0
  24. package/docs/social-preview-hq.png +0 -0
  25. package/docs/social-preview.png +0 -0
  26. package/examples/basic_usage.py +0 -84
  27. package/package.json.real +0 -24
  28. package/pyproject.toml +0 -50
  29. package/registry-server.json +0 -20
  30. package/scripts/demo_governance.py +0 -109
  31. package/scripts/sse_bridge.py +0 -87
  32. package/scripts/verify_windows_paths.py +0 -48
  33. package/server.json +0 -20
  34. package/src/mcp_server_nucleus/__init__.py +0 -8583
  35. package/src/mcp_server_nucleus/__main__.py +0 -9
  36. package/src/mcp_server_nucleus/cli.py +0 -1225
  37. package/src/mcp_server_nucleus/commitment_core.py +0 -146
  38. package/src/mcp_server_nucleus/commitment_ledger.py +0 -841
  39. package/src/mcp_server_nucleus/core/__init__.py +0 -6
  40. package/src/mcp_server_nucleus/core/orchestrator.py +0 -9
  41. package/src/mcp_server_nucleus/core/tool_registration_impl.py +0 -99
  42. package/src/mcp_server_nucleus/daemon/safety/lock.py +0 -121
  43. package/src/mcp_server_nucleus/deployment/render_integration.py +0 -4
  44. package/src/mcp_server_nucleus/hypervisor/__init__.py +0 -0
  45. package/src/mcp_server_nucleus/hypervisor/injector.py +0 -83
  46. package/src/mcp_server_nucleus/hypervisor/locker.py +0 -130
  47. package/src/mcp_server_nucleus/hypervisor/watchdog.py +0 -71
  48. package/src/mcp_server_nucleus/runtime/__init__.py +0 -58
  49. package/src/mcp_server_nucleus/runtime/agent.py +0 -517
  50. package/src/mcp_server_nucleus/runtime/agent_pool.py +0 -797
  51. package/src/mcp_server_nucleus/runtime/agents/base.py +0 -34
  52. package/src/mcp_server_nucleus/runtime/agents/oracle/manifest.json +0 -28
  53. package/src/mcp_server_nucleus/runtime/auth.py +0 -98
  54. package/src/mcp_server_nucleus/runtime/autopilot.py +0 -1057
  55. package/src/mcp_server_nucleus/runtime/broker.py +0 -126
  56. package/src/mcp_server_nucleus/runtime/budget.py +0 -153
  57. package/src/mcp_server_nucleus/runtime/capabilities/base.py +0 -20
  58. package/src/mcp_server_nucleus/runtime/capabilities/brain_ops.py +0 -253
  59. package/src/mcp_server_nucleus/runtime/capabilities/code_ops.py +0 -192
  60. package/src/mcp_server_nucleus/runtime/capabilities/depth_tracker.py +0 -81
  61. package/src/mcp_server_nucleus/runtime/capabilities/feature_map.py +0 -274
  62. package/src/mcp_server_nucleus/runtime/capabilities/marketing_engine.py +0 -205
  63. package/src/mcp_server_nucleus/runtime/capabilities/memory_ops.py +0 -79
  64. package/src/mcp_server_nucleus/runtime/capabilities/proof_system.py +0 -127
  65. package/src/mcp_server_nucleus/runtime/capabilities/render_ops.py +0 -80
  66. package/src/mcp_server_nucleus/runtime/capabilities/render_poller_cap.py +0 -266
  67. package/src/mcp_server_nucleus/runtime/capabilities/self_healing.py +0 -110
  68. package/src/mcp_server_nucleus/runtime/capabilities/strategy.py +0 -138
  69. package/src/mcp_server_nucleus/runtime/capabilities/synthesizer.py +0 -134
  70. package/src/mcp_server_nucleus/runtime/capabilities/web_ops.py +0 -94
  71. package/src/mcp_server_nucleus/runtime/common.py +0 -134
  72. package/src/mcp_server_nucleus/runtime/context_manager.py +0 -342
  73. package/src/mcp_server_nucleus/runtime/context_rules.json +0 -86
  74. package/src/mcp_server_nucleus/runtime/crdt_task_store.py +0 -349
  75. package/src/mcp_server_nucleus/runtime/critic.py +0 -150
  76. package/src/mcp_server_nucleus/runtime/daemon.py +0 -142
  77. package/src/mcp_server_nucleus/runtime/dashboard.py +0 -1087
  78. package/src/mcp_server_nucleus/runtime/depth_ops.py +0 -339
  79. package/src/mcp_server_nucleus/runtime/event_ops.py +0 -103
  80. package/src/mcp_server_nucleus/runtime/event_stream.py +0 -213
  81. package/src/mcp_server_nucleus/runtime/export.py +0 -64
  82. package/src/mcp_server_nucleus/runtime/factory.py +0 -594
  83. package/src/mcp_server_nucleus/runtime/federation.py +0 -1040
  84. package/src/mcp_server_nucleus/runtime/fetcher.py +0 -92
  85. package/src/mcp_server_nucleus/runtime/file_monitor.py +0 -208
  86. package/src/mcp_server_nucleus/runtime/firestore_bridge.py +0 -91
  87. package/src/mcp_server_nucleus/runtime/gcloud_ops.py +0 -186
  88. package/src/mcp_server_nucleus/runtime/hooks.py +0 -246
  89. package/src/mcp_server_nucleus/runtime/identity/gatekeeper.py +0 -106
  90. package/src/mcp_server_nucleus/runtime/identity/keygen.py +0 -147
  91. package/src/mcp_server_nucleus/runtime/identity/manifest.py +0 -62
  92. package/src/mcp_server_nucleus/runtime/identity/trust.py +0 -27
  93. package/src/mcp_server_nucleus/runtime/inspector.py +0 -74
  94. package/src/mcp_server_nucleus/runtime/installer.py +0 -81
  95. package/src/mcp_server_nucleus/runtime/ipc_auth.py +0 -377
  96. package/src/mcp_server_nucleus/runtime/lifecycle.py +0 -129
  97. package/src/mcp_server_nucleus/runtime/llm_client.py +0 -408
  98. package/src/mcp_server_nucleus/runtime/llm_tiers.json +0 -88
  99. package/src/mcp_server_nucleus/runtime/locking.py +0 -228
  100. package/src/mcp_server_nucleus/runtime/loops/fixer.py +0 -110
  101. package/src/mcp_server_nucleus/runtime/memory.py +0 -158
  102. package/src/mcp_server_nucleus/runtime/mounter.py +0 -210
  103. package/src/mcp_server_nucleus/runtime/mounter_ops.py +0 -250
  104. package/src/mcp_server_nucleus/runtime/nuke_protocol.py +0 -147
  105. package/src/mcp_server_nucleus/runtime/orchestrator.py +0 -370
  106. package/src/mcp_server_nucleus/runtime/orchestrator_unified.py +0 -263
  107. package/src/mcp_server_nucleus/runtime/orchestrator_v3.py +0 -617
  108. package/src/mcp_server_nucleus/runtime/plugin_loader.py +0 -84
  109. package/src/mcp_server_nucleus/runtime/policy.py +0 -117
  110. package/src/mcp_server_nucleus/runtime/profiling.py +0 -219
  111. package/src/mcp_server_nucleus/runtime/prometheus.py +0 -286
  112. package/src/mcp_server_nucleus/runtime/proposals.py +0 -101
  113. package/src/mcp_server_nucleus/runtime/publisher.py +0 -109
  114. package/src/mcp_server_nucleus/runtime/pulse.py +0 -516
  115. package/src/mcp_server_nucleus/runtime/registry.py +0 -82
  116. package/src/mcp_server_nucleus/runtime/render_ops.py +0 -83
  117. package/src/mcp_server_nucleus/runtime/schema_gen.py +0 -140
  118. package/src/mcp_server_nucleus/runtime/session_ops.py +0 -224
  119. package/src/mcp_server_nucleus/runtime/stdio_server.py +0 -633
  120. package/src/mcp_server_nucleus/runtime/storage.py +0 -83
  121. package/src/mcp_server_nucleus/runtime/strategy.py +0 -91
  122. package/src/mcp_server_nucleus/runtime/swarm.py +0 -170
  123. package/src/mcp_server_nucleus/runtime/sync_ops.py +0 -878
  124. package/src/mcp_server_nucleus/runtime/task_ingestion.py +0 -1020
  125. package/src/mcp_server_nucleus/runtime/task_ops.py +0 -449
  126. package/src/mcp_server_nucleus/runtime/task_scheduler.py +0 -488
  127. package/src/mcp_server_nucleus/runtime/team.py +0 -59
  128. package/src/mcp_server_nucleus/runtime/triggers.py +0 -237
  129. package/src/mcp_server_nucleus/runtime/vector_store.py +0 -125
  130. package/src/mcp_server_nucleus/runtime/watcher.py +0 -112
  131. package/src/mcp_server_nucleus/tool_tiers.py +0 -243
  132. package/tests/__init__.py +0 -1
  133. package/tests/conftest.py +0 -42
  134. package/tests/test_core.py +0 -235
  135. package/tests/test_search.py +0 -69
@@ -1,878 +0,0 @@
1
- """
2
- Nucleus Multi-Agent Sync Operations - Production Ready
3
- Version: 0.7.0
4
-
5
- Tier 1: Project-Level Multi-Agent Sync
6
- - Zero-config by default (works without setup)
7
- - Progressive enhancement with .brain/config/nucleus.yaml
8
- - File locking for concurrent access
9
- - Auto-sync with watchdog file watcher
10
- - Conflict detection and resolution
11
-
12
- Author: Boss Opus (CORE_SYN)
13
- Date: 2026-02-08
14
- """
15
-
16
- import os
17
- import json
18
- import fcntl
19
- import time
20
- import hashlib
21
- import logging
22
- import threading
23
- import queue
24
- from pathlib import Path
25
- from datetime import datetime
26
- from typing import Dict, Any, Optional, List
27
- from contextlib import contextmanager
28
-
29
- logger = logging.getLogger("nucleus.sync")
30
-
31
- # =============================================================================
32
- # CONFIGURATION LOADER
33
- # =============================================================================
34
-
35
- def _load_sync_config(brain_path: Optional[Path] = None) -> Dict[str, Any]:
36
- """
37
- Load .brain/config/nucleus.yaml if it exists.
38
- Returns empty config if file doesn't exist (Tier 0 behavior).
39
- """
40
- if brain_path is None:
41
- from .common import get_brain_path
42
- brain_path = get_brain_path()
43
-
44
- config_file = brain_path / "config" / "nucleus.yaml"
45
-
46
- if not config_file.exists():
47
- return {"sync": {"enabled": False}}
48
-
49
- try:
50
- import yaml
51
- config = yaml.safe_load(config_file.read_text())
52
- return config or {"sync": {"enabled": False}}
53
- except ImportError:
54
- # Fallback to JSON-style YAML parsing if PyYAML not installed
55
- try:
56
- content = config_file.read_text()
57
- # Simple YAML to dict for basic configs
58
- config = _parse_simple_yaml(content)
59
- return config
60
- except Exception as e:
61
- logger.warning(f"Could not parse nucleus.yaml: {e}")
62
- return {"sync": {"enabled": False}}
63
- except Exception as e:
64
- logger.warning(f"Error loading sync config: {e}")
65
- return {"sync": {"enabled": False}}
66
-
67
-
68
- def _parse_simple_yaml(content: str) -> Dict[str, Any]:
69
- """Simple YAML parser for basic nucleus.yaml configs."""
70
- result = {}
71
- current_section = result
72
- section_stack = [result]
73
- indent_stack = [0]
74
-
75
- for line in content.split("\n"):
76
- # Skip comments and empty lines
77
- stripped = line.strip()
78
- if not stripped or stripped.startswith("#"):
79
- continue
80
-
81
- # Calculate indentation
82
- indent = len(line) - len(line.lstrip())
83
-
84
- # Handle key-value pairs
85
- if ":" in stripped:
86
- key, _, value = stripped.partition(":")
87
- key = key.strip()
88
- value = value.strip()
89
-
90
- # Pop sections if we're at a lower indent level
91
- while indent_stack and indent <= indent_stack[-1] and len(section_stack) > 1:
92
- section_stack.pop()
93
- indent_stack.pop()
94
- current_section = section_stack[-1]
95
-
96
- if value:
97
- # Simple value
98
- if value.startswith('"') and value.endswith('"'):
99
- value = value[1:-1]
100
- elif value.startswith("'") and value.endswith("'"):
101
- value = value[1:-1]
102
- elif value.lower() == "true":
103
- value = True
104
- elif value.lower() == "false":
105
- value = False
106
- elif value.isdigit():
107
- value = int(value)
108
- current_section[key] = value
109
- else:
110
- # New section
111
- current_section[key] = {}
112
- section_stack.append(current_section[key])
113
- indent_stack.append(indent)
114
- current_section = current_section[key]
115
-
116
- # Handle list items
117
- elif stripped.startswith("- "):
118
- item = stripped[2:].strip()
119
- if item.startswith('"') and item.endswith('"'):
120
- item = item[1:-1]
121
- # Find the parent key for this list
122
- for key in reversed(list(current_section.keys())):
123
- if isinstance(current_section[key], dict) and not current_section[key]:
124
- current_section[key] = [item]
125
- break
126
- elif isinstance(current_section[key], list):
127
- current_section[key].append(item)
128
- break
129
-
130
- return result
131
-
132
-
133
- def is_sync_enabled(brain_path: Optional[Path] = None) -> bool:
134
- """Check if sync is enabled for this project."""
135
- config = _load_sync_config(brain_path)
136
- return config.get("sync", {}).get("enabled", False)
137
-
138
-
139
- def get_sync_mode(brain_path: Optional[Path] = None) -> str:
140
- """Get sync mode: 'auto' or 'manual'."""
141
- config = _load_sync_config(brain_path)
142
- return config.get("sync", {}).get("mode", "manual")
143
-
144
-
145
- def get_watch_files(brain_path: Optional[Path] = None) -> List[str]:
146
- """Get list of files to watch for sync."""
147
- config = _load_sync_config(brain_path)
148
- return config.get("sync", {}).get("watch_files", [
149
- "ledger/state.json",
150
- "ledger/decisions.md",
151
- "task.md"
152
- ])
153
-
154
-
155
- def get_sync_interval(brain_path: Optional[Path] = None) -> int:
156
- """Get sync interval in seconds."""
157
- config = _load_sync_config(brain_path)
158
- return config.get("sync", {}).get("interval", 5)
159
-
160
-
161
- # =============================================================================
162
- # AGENT IDENTIFICATION
163
- # =============================================================================
164
-
165
- # Process-local identity cache (Concurrency Hotfix)
166
- _current_identity: Optional[str] = None
167
-
168
-
169
- def get_current_agent(brain_path: Optional[Path] = None) -> str:
170
- """
171
- Get current agent ID, with auto-detection fallback.
172
-
173
- Priority:
174
- 1. Process-Local Memory (Sticky & Safe)
175
- 2. Environment variable detection
176
- 3. .brain/.nucleus_agent file (Last Resort / Logging only)
177
- 4. "unknown_agent" fallback
178
- """
179
- global _current_identity
180
-
181
- # Priority 1: Process-Local Memory (The Golden Source)
182
- if _current_identity:
183
- return _current_identity
184
-
185
- if brain_path is None:
186
- from .common import get_brain_path
187
- brain_path = get_brain_path()
188
-
189
- # Priority 2: Environment detection
190
- # Check for IDE-specific environment variables
191
- if os.environ.get("WINDSURF_SESSION"):
192
- return "windsurf_auto"
193
- elif os.environ.get("CURSOR_SESSION"):
194
- return "cursor_auto"
195
- elif os.environ.get("CLAUDE_DESKTOP"):
196
- return "claude_desktop_auto"
197
- elif os.environ.get("VSCODE_PID"):
198
- return "vscode_auto"
199
-
200
- # Check for custom agent ID
201
- custom_id = os.environ.get("NUCLEUS_AGENT_ID")
202
- if custom_id:
203
- return custom_id
204
-
205
- # Priority 3: Explicit registration file (Last Resort / Cold Start)
206
- agent_file = brain_path / ".nucleus_agent"
207
- if agent_file.exists():
208
- try:
209
- agent_info = json.loads(agent_file.read_text())
210
- # Only trust file if it matches our PID (Session Recovery)
211
- # Otherwise we risk reading another agent's identity
212
- if agent_info.get("pid") == os.getpid():
213
- return agent_info.get("agent_id", "unknown_agent")
214
- except Exception:
215
- pass
216
-
217
- # Priority 4: Fallback
218
- return "unknown_agent"
219
-
220
-
221
- def set_current_agent(agent_id: str, environment: str, role: str = "",
222
- brain_path: Optional[Path] = None) -> Dict[str, Any]:
223
- """
224
- Register current agent identity.
225
- Updates process-local memory FIRST, then persists to file.
226
- """
227
- global _current_identity
228
-
229
- # Update memory immediately (Atomic for this process)
230
- _current_identity = agent_id
231
-
232
- if brain_path is None:
233
- from .common import get_brain_path
234
- brain_path = get_brain_path()
235
-
236
- agent_file = brain_path / ".nucleus_agent"
237
-
238
- agent_info = {
239
- "agent_id": agent_id,
240
- "environment": environment,
241
- "role": role,
242
- "registered_at": datetime.now().isoformat(),
243
- "pid": os.getpid()
244
- }
245
-
246
- agent_file.write_text(json.dumps(agent_info, indent=2))
247
-
248
- return {
249
- **agent_info,
250
- "stored_in": str(agent_file)
251
- }
252
-
253
-
254
- def get_agent_info(brain_path: Optional[Path] = None) -> Dict[str, Any]:
255
- """Get full agent info if registered."""
256
- if brain_path is None:
257
- from .common import get_brain_path
258
- brain_path = get_brain_path()
259
-
260
- agent_file = brain_path / ".nucleus_agent"
261
-
262
- if agent_file.exists():
263
- try:
264
- return json.loads(agent_file.read_text())
265
- except Exception:
266
- pass
267
-
268
- return {
269
- "agent_id": get_current_agent(brain_path),
270
- "environment": "unknown",
271
- "role": "",
272
- "registered_at": None,
273
- "auto_detected": True
274
- }
275
-
276
-
277
- # =============================================================================
278
- # FILE LOCKING
279
- # =============================================================================
280
-
281
- @contextmanager
282
- def sync_lock(brain_path: Optional[Path] = None, timeout: int = 5):
283
- """
284
- Acquire exclusive lock for syncing.
285
- Uses fcntl.flock for cross-process synchronization.
286
-
287
- Args:
288
- brain_path: Path to .brain directory
289
- timeout: Maximum seconds to wait for lock
290
-
291
- Raises:
292
- Exception if lock cannot be acquired within timeout
293
- """
294
- if brain_path is None:
295
- from .common import get_brain_path
296
- brain_path = get_brain_path()
297
-
298
- lock_file_path = brain_path / ".sync.lock"
299
- lock_file_path.parent.mkdir(parents=True, exist_ok=True)
300
-
301
- # Open lock file
302
- lock_fd = open(lock_file_path, 'w')
303
-
304
- # Try to acquire lock with timeout
305
- start_time = time.time()
306
-
307
- while True:
308
- try:
309
- fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
310
- break
311
- except IOError:
312
- if time.time() - start_time > timeout:
313
- lock_fd.close()
314
- raise Exception(f"Could not acquire sync lock after {timeout}s - another agent may be syncing")
315
- time.sleep(0.1)
316
-
317
- try:
318
- # Write lock holder info
319
- lock_info = {
320
- "agent": get_current_agent(brain_path),
321
- "acquired_at": datetime.now().isoformat(),
322
- "pid": os.getpid()
323
- }
324
- lock_fd.write(json.dumps(lock_info))
325
- lock_fd.flush()
326
-
327
- yield
328
- finally:
329
- # Release lock
330
- fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
331
- lock_fd.close()
332
- try:
333
- lock_file_path.unlink(missing_ok=True)
334
- except Exception:
335
- pass
336
-
337
-
338
- def _verify_brain_path_safety(brain_path: Path):
339
- """
340
- Ensure the brain path is technically safe/allowed.
341
- Requirement from Turning 23/29 Resilience Audit.
342
- """
343
- if os.environ.get("NUCLEUS_UNSAFE_SYNC") == "true":
344
- return
345
-
346
- try:
347
- from .common import get_brain_path
348
- configured_brain = get_brain_path().resolve()
349
-
350
- # Must be exactly the configured brain path or a descendant
351
- if not str(brain_path.resolve()).startswith(str(configured_brain)):
352
- raise PermissionError(f"Access denied: Path {brain_path} is outside the configured Nucleus project scope.")
353
- except Exception as e:
354
- if isinstance(e, PermissionError):
355
- raise
356
- # Fallback for transient resolution errors
357
- pass
358
-
359
-
360
- def _calculate_file_hash(file_path: Path) -> str:
361
- """
362
- Calculate SHA256 hash in chunks to prevent memory spikes.
363
- Requirement from Finding 2 (Performance).
364
- """
365
- if not file_path.exists():
366
- return ""
367
-
368
- h = hashlib.sha256()
369
- try:
370
- with open(file_path, "rb") as f:
371
- while True:
372
- chunk = f.read(8192)
373
- if not chunk:
374
- break
375
- h.update(chunk)
376
- return h.hexdigest()[:16]
377
- except Exception as e:
378
- logger.warning(f"Error hashing {file_path}: {e}")
379
- return ""
380
- # =============================================================================
381
- # SYNC METADATA TRACKING
382
- # =============================================================================
383
-
384
- def _get_meta_file(file_path: Path) -> Path:
385
- """Get the metadata file path for a given file."""
386
- return file_path.parent / f".{file_path.name}.meta"
387
-
388
-
389
- def get_last_modifier(file_path: Path) -> str:
390
- """Get agent that last modified this file."""
391
- meta_file = _get_meta_file(file_path)
392
-
393
- if meta_file.exists():
394
- try:
395
- meta = json.loads(meta_file.read_text())
396
- return meta.get("last_agent", "unknown")
397
- except Exception:
398
- pass
399
-
400
- return "unknown"
401
-
402
-
403
- def set_last_modifier(file_path: Path, agent_id: str):
404
- """Record which agent last modified this file with stat optimization."""
405
- meta_file = _get_meta_file(file_path)
406
-
407
- # Calculate file hash using the new chunked method
408
- file_hash = _calculate_file_hash(file_path)
409
-
410
- # Stat capture for high-speed conflict bypass
411
- stat = file_path.stat() if file_path.exists() else None
412
-
413
- meta = {
414
- "last_agent": agent_id,
415
- "last_modified": datetime.now().isoformat(),
416
- "file": str(file_path.name),
417
- "expected_hash": file_hash,
418
- "st_mtime_ns": stat.st_mtime_ns if stat else 0,
419
- "st_size": stat.st_size if stat else 0
420
- }
421
-
422
- # Atomic write pattern (tmp + replace)
423
- tmp_meta = meta_file.with_suffix(".tmp")
424
- try:
425
- tmp_meta.write_text(json.dumps(meta, indent=2))
426
- os.replace(tmp_meta, meta_file)
427
- except Exception as e:
428
- logger.error(f"Failed to write metadata for {file_path}: {e}")
429
-
430
-
431
- def _cleanup_stale_conflicts(brain_path: Path):
432
- """Automatic cleanup of resolved .conflict files (Hygiene)."""
433
- try:
434
- from .sync_ops import get_watch_files
435
- for rel_path in get_watch_files(brain_path):
436
- file_path = brain_path / rel_path
437
- conflict_file = file_path.parent / f".{file_path.name}.conflict"
438
- if conflict_file.exists():
439
- # If no active conflict detected, remove the stale marker
440
- if not detect_conflict(file_path):
441
- conflict_file.unlink(missing_ok=True)
442
- logger.info(f"Cleaned up stale conflict marker for {rel_path}")
443
- except Exception as e:
444
- logger.debug(f"Cleanup failed (non-critical): {e}")
445
-
446
-
447
- def get_file_meta(file_path: Path) -> Dict[str, Any]:
448
- """Get full metadata for a file."""
449
- meta_file = _get_meta_file(file_path)
450
-
451
- if meta_file.exists():
452
- try:
453
- return json.loads(meta_file.read_text())
454
- except Exception:
455
- pass
456
-
457
- return {}
458
-
459
-
460
- # =============================================================================
461
- # CONFLICT DETECTION
462
- # =============================================================================
463
-
464
- def detect_conflict(file_path: Path) -> Optional[Dict[str, Any]]:
465
- """
466
- Detect if file has conflicting changes with stat optimization.
467
-
468
- Returns conflict info if detected, None otherwise.
469
- """
470
- meta_file = _get_meta_file(file_path)
471
-
472
- if not meta_file.exists() or not file_path.exists():
473
- return None
474
-
475
- try:
476
- meta = json.loads(meta_file.read_text())
477
-
478
- # Fast Check Bypass (Stat Capture)
479
- if os.environ.get("NUCLEUS_SYNC_FORCE_HASH") != "true":
480
- stat = file_path.stat()
481
- if "st_mtime_ns" in meta and meta["st_mtime_ns"] == stat.st_mtime_ns:
482
- if "st_size" in meta and meta["st_size"] == stat.st_size:
483
- # Skip expensive hashing
484
- return None
485
-
486
- # Fallback to absolute content hash verification
487
- current_hash = _calculate_file_hash(file_path)
488
-
489
- if "expected_hash" in meta and meta["expected_hash"] != current_hash:
490
- return {
491
- "file": str(file_path),
492
- "expected_agent": meta.get("last_agent"),
493
- "expected_hash": meta["expected_hash"],
494
- "actual_hash": current_hash,
495
- "last_modified": meta.get("last_modified"),
496
- "conflict_type": "unexpected_modification"
497
- }
498
- except Exception as e:
499
- logger.warning(f"Error detecting conflict for {file_path}: {e}")
500
-
501
- return None
502
-
503
-
504
- def resolve_conflict(conflict: Dict[str, Any], strategy: str,
505
- brain_path: Optional[Path] = None) -> str:
506
- """
507
- Resolve a detected conflict.
508
-
509
- Strategies:
510
- - "last_write_wins": Accept current state
511
- - "manual": Create conflict marker file
512
- """
513
- if brain_path is None:
514
- from .common import get_brain_path
515
- brain_path = get_brain_path()
516
-
517
- file_path = Path(conflict["file"])
518
-
519
- if strategy == "last_write_wins":
520
- # Accept current state, update metadata
521
- set_last_modifier(file_path, get_current_agent(brain_path))
522
- return "resolved_accept_current"
523
-
524
- elif strategy == "manual":
525
- # Create conflict marker file for manual resolution
526
- conflict_file = file_path.parent / f".{file_path.name}.conflict"
527
- conflict["detected_at"] = datetime.now().isoformat()
528
- conflict["detected_by"] = get_current_agent(brain_path)
529
- conflict_file.write_text(json.dumps(conflict, indent=2))
530
- return "requires_manual_resolution"
531
-
532
- else:
533
- raise ValueError(f"Unknown conflict strategy: {strategy}")
534
-
535
-
536
- # =============================================================================
537
- # CORE SYNC LOGIC
538
- # =============================================================================
539
-
540
- def perform_sync(force: bool = False, brain_path: Optional[Path] = None) -> Dict[str, Any]:
541
- """
542
- Core sync logic with hardened v0.7.1 refinements.
543
- """
544
- if brain_path is None:
545
- from .common import get_brain_path
546
- brain_path = get_brain_path()
547
-
548
- # Pillar 1: Path Security Verify
549
- _verify_brain_path_safety(brain_path)
550
-
551
- config = _load_sync_config(brain_path)
552
- current_agent = get_current_agent(brain_path)
553
-
554
- result = {
555
- "timestamp": datetime.now().isoformat(),
556
- "agent": current_agent,
557
- "files_synced": [],
558
- "conflicts": [],
559
- "sync_duration_ms": 0
560
- }
561
-
562
- start_time = time.time()
563
-
564
- # Get files to check
565
- watch_files = get_watch_files(brain_path)
566
- conflict_strategy = config.get("sync", {}).get("conflict_resolution", "last_write_wins")
567
-
568
- for watch_file in watch_files:
569
- file_path = brain_path / watch_file
570
-
571
- if not file_path.exists():
572
- continue
573
-
574
- # Check for conflicts
575
- conflict = detect_conflict(file_path)
576
- if conflict:
577
- resolution = resolve_conflict(conflict, conflict_strategy, brain_path)
578
- result["conflicts"].append({
579
- **conflict,
580
- "resolution": resolution
581
- })
582
-
583
- # Check if file was modified by another agent
584
- last_modifier = get_last_modifier(file_path)
585
-
586
- if last_modifier != current_agent or force:
587
- # File was changed by another agent or force sync
588
- result["files_synced"].append({
589
- "file": watch_file,
590
- "action": "reloaded" if last_modifier != current_agent else "refreshed",
591
- "previous_agent": last_modifier,
592
- "current_agent": current_agent
593
- })
594
-
595
- # Update metadata to mark current agent
596
- set_last_modifier(file_path, current_agent)
597
-
598
- # Pillar 4: Garbage Collection (Marker Cleanup)
599
- _cleanup_stale_conflicts(brain_path)
600
-
601
- result["sync_duration_ms"] = int((time.time() - start_time) * 1000)
602
-
603
- return result
604
-
605
-
606
- def get_sync_status(brain_path: Optional[Path] = None) -> Dict[str, Any]:
607
- """
608
- Get comprehensive sync status.
609
-
610
- Returns current state including:
611
- - Sync enabled/mode
612
- - Current agent
613
- - Detected agents from metadata
614
- - Pending conflicts
615
- - Auto-sync status
616
- """
617
- if brain_path is None:
618
- from .common import get_brain_path
619
- brain_path = get_brain_path()
620
-
621
- config = _load_sync_config(brain_path)
622
- sync_config = config.get("sync", {})
623
-
624
- # Detect all agents that have modified files
625
- detected_agents = set()
626
- pending_conflicts = []
627
-
628
- for watch_file in get_watch_files(brain_path):
629
- file_path = brain_path / watch_file
630
- if file_path.exists():
631
- meta = get_file_meta(file_path)
632
- if meta.get("last_agent"):
633
- detected_agents.add(meta["last_agent"])
634
-
635
- conflict = detect_conflict(file_path)
636
- if conflict:
637
- pending_conflicts.append(conflict)
638
-
639
- # Get last sync time
640
- last_sync = None
641
- sync_log = brain_path / ".sync_last"
642
- if sync_log.exists():
643
- try:
644
- last_sync = sync_log.read_text().strip()
645
- except Exception:
646
- pass
647
-
648
- return {
649
- "sync_enabled": sync_config.get("enabled", False),
650
- "mode": sync_config.get("mode", "manual"),
651
- "last_sync": last_sync,
652
- "current_agent": get_current_agent(brain_path),
653
- "agent_info": get_agent_info(brain_path),
654
- "detected_agents": list(detected_agents),
655
- "files_watched": get_watch_files(brain_path),
656
- "pending_conflicts": pending_conflicts,
657
- "auto_sync_running": _is_watcher_running(),
658
- "config_file": str(brain_path / "config" / "nucleus.yaml"),
659
- "config_exists": (brain_path / "config" / "nucleus.yaml").exists()
660
- }
661
-
662
-
663
- def record_sync_time(brain_path: Optional[Path] = None):
664
- """Record the last sync timestamp."""
665
- if brain_path is None:
666
- from .common import get_brain_path
667
- brain_path = get_brain_path()
668
-
669
- sync_log = brain_path / ".sync_last"
670
- sync_log.write_text(datetime.now().isoformat())
671
-
672
-
673
- # =============================================================================
674
- # AUTO-SYNC FILE WATCHER
675
- # =============================================================================
676
-
677
- # Global observer instance
678
- _observer = None
679
- _observer_lock = threading.Lock()
680
-
681
-
682
- def _is_watcher_running() -> bool:
683
- """Check if file watcher is running."""
684
- global _observer
685
- return _observer is not None and _observer.is_alive()
686
-
687
-
688
- class BrainSyncHandler:
689
- """File system event handler for auto-sync with serialized worker (v0.7.1)."""
690
-
691
- def __init__(self, brain_path: Path, config: Dict[str, Any]):
692
- self.brain_path = brain_path
693
- self.config = config
694
- self.watch_files = set(config.get("sync", {}).get("watch_files", []))
695
- self.last_sync: Dict[str, float] = {}
696
- self.min_interval = config.get("sync", {}).get("interval", 5)
697
-
698
- # Serialized Work Queue
699
- self.work_queue = queue.Queue()
700
- self.worker = threading.Thread(target=self._worker_loop, daemon=True)
701
- self.worker.start()
702
-
703
- def _worker_loop(self):
704
- """Single worker thread to process sync requests one at a time."""
705
- while True:
706
- try:
707
- rel_path = self.work_queue.get()
708
- if rel_path is None:
709
- break
710
- self._sync_file(rel_path)
711
- self.work_queue.task_done()
712
- except Exception as e:
713
- logger.error(f"Worker loop error: {e}")
714
- time.sleep(1)
715
-
716
- def on_modified(self, event):
717
- """Handle file modification events by queuing them."""
718
- if event.is_directory:
719
- return
720
-
721
- try:
722
- # Check if this is a watched file
723
- file_path = Path(event.src_path)
724
- rel_path = file_path.relative_to(self.brain_path)
725
-
726
- if str(rel_path) not in self.watch_files:
727
- return
728
-
729
- # Debounce - don't queue too frequently for the same file
730
- now = time.time()
731
- key = str(rel_path)
732
- if key in self.last_sync:
733
- if now - self.last_sync[key] < self.min_interval:
734
- return
735
-
736
- self.last_sync[key] = now
737
-
738
- # Queue the work instead of spawning a thread (Finding 3)
739
- self.work_queue.put(rel_path)
740
- except Exception as e:
741
- logger.error(f"Error in file watcher: {e}")
742
-
743
- def _sync_file(self, rel_path: Path):
744
- """Sync a single file."""
745
- try:
746
- with sync_lock(self.brain_path, timeout=2):
747
- logger.info(f"Auto-syncing due to change in {rel_path}")
748
- result = perform_sync(force=False, brain_path=self.brain_path)
749
- record_sync_time(self.brain_path)
750
-
751
- # Emit event ONLY if something changed or conflict occurred (Finding 3: Noise)
752
- if result.get("files_synced") or result.get("conflicts"):
753
- from .event_ops import _emit_event
754
- _emit_event(
755
- event_type="SYNC_AUTO",
756
- emitter=get_current_agent(self.brain_path),
757
- data=result,
758
- description=f"Auto-sync detected changes triggered by {rel_path}"
759
- )
760
- except Exception as e:
761
- logger.error(f"Auto-sync failed: {e}")
762
-
763
-
764
- def start_file_watcher(brain_path: Optional[Path] = None) -> Dict[str, Any]:
765
- """
766
- Start watching files for changes.
767
-
768
- Uses watchdog library for cross-platform file watching.
769
- """
770
- global _observer
771
-
772
- if brain_path is None:
773
- from .common import get_brain_path
774
- brain_path = get_brain_path()
775
-
776
- config = _load_sync_config(brain_path)
777
-
778
- if not config.get("sync", {}).get("enabled"):
779
- return {
780
- "error": "Sync not enabled",
781
- "hint": "Add sync.enabled: true to .brain/config/nucleus.yaml"
782
- }
783
-
784
- with _observer_lock:
785
- # Stop existing watcher if running
786
- if _observer is not None:
787
- stop_file_watcher()
788
-
789
- try:
790
- from watchdog.observers import Observer
791
- from watchdog.events import FileSystemEventHandler
792
-
793
- # Create handler
794
- handler = BrainSyncHandler(brain_path, config)
795
-
796
- # Create a watchdog-compatible wrapper
797
- class WatchdogHandler(FileSystemEventHandler):
798
- def on_modified(self, event):
799
- handler.on_modified(event)
800
-
801
- _observer = Observer()
802
- _observer.schedule(WatchdogHandler(), str(brain_path), recursive=True)
803
- _observer.start()
804
-
805
- logger.info(f"File watcher started for {brain_path}")
806
-
807
- return {
808
- "auto_sync_enabled": True,
809
- "watching_files": list(handler.watch_files),
810
- "check_interval": handler.min_interval,
811
- "status": "watcher_started",
812
- "brain_path": str(brain_path)
813
- }
814
- except ImportError:
815
- return {
816
- "error": "watchdog library not installed",
817
- "hint": "pip install watchdog",
818
- "status": "watcher_not_available"
819
- }
820
- except Exception as e:
821
- logger.error(f"Failed to start file watcher: {e}")
822
- return {
823
- "error": str(e),
824
- "status": "watcher_failed"
825
- }
826
-
827
-
828
- def stop_file_watcher() -> Dict[str, Any]:
829
- """Stop file watcher."""
830
- global _observer
831
-
832
- with _observer_lock:
833
- if _observer is not None:
834
- try:
835
- _observer.stop()
836
- _observer.join(timeout=5)
837
- _observer = None
838
- logger.info("File watcher stopped")
839
- return {
840
- "auto_sync_enabled": False,
841
- "status": "watcher_stopped"
842
- }
843
- except Exception as e:
844
- logger.error(f"Error stopping watcher: {e}")
845
- _observer = None
846
- return {
847
- "auto_sync_enabled": False,
848
- "status": "watcher_stopped_with_error",
849
- "error": str(e)
850
- }
851
- else:
852
- return {
853
- "auto_sync_enabled": False,
854
- "status": "watcher_not_running"
855
- }
856
-
857
-
858
- # =============================================================================
859
- # AUTO-START HOOK
860
- # =============================================================================
861
-
862
- def auto_start_sync_if_configured(brain_path: Optional[Path] = None):
863
- """
864
- Auto-start file watcher if configured for auto mode.
865
- Called during MCP server initialization.
866
- """
867
- if brain_path is None:
868
- try:
869
- from .common import get_brain_path
870
- brain_path = get_brain_path()
871
- except Exception:
872
- return
873
-
874
- config = _load_sync_config(brain_path)
875
-
876
- if config.get("sync", {}).get("enabled") and config.get("sync", {}).get("mode") == "auto":
877
- logger.info("Auto-starting sync watcher (mode=auto)")
878
- start_file_watcher(brain_path)