loki-mode 6.62.0 → 6.63.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.
@@ -904,7 +904,9 @@ except: pass
904
904
  local container_path="${parts[1]}"
905
905
  local mode="${parts[2]:-ro}"
906
906
 
907
- # Safe tilde expansion (no eval)
907
+ # Safe tilde expansion (no eval) - SC2088 disabled: tilde is intentionally
908
+ # treated as a literal string here for safe expansion without eval
909
+ # shellcheck disable=SC2088
908
910
  if [[ "$host_path" == "~/"* ]]; then
909
911
  host_path="$HOME/${host_path#\~/}"
910
912
  elif [[ "$host_path" == "~" ]]; then
@@ -1121,7 +1123,8 @@ start_sandbox() {
1121
1123
  local c_container="${mount_parts[1]:-}"
1122
1124
  local c_mode="${mount_parts[2]:-ro}"
1123
1125
  if [[ -n "$c_host" ]] && [[ -n "$c_container" ]]; then
1124
- # Safe tilde expansion (no eval)
1126
+ # Safe tilde expansion (no eval) - SC2088 disabled: intentional literal match
1127
+ # shellcheck disable=SC2088
1125
1128
  if [[ "$c_host" == "~/"* ]]; then
1126
1129
  c_host="$HOME/${c_host#\~/}"
1127
1130
  elif [[ "$c_host" == "~" ]]; then
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.62.0"
10
+ __version__ = "6.63.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -1085,7 +1085,7 @@ async def list_tasks(
1085
1085
  # Skip if already in all_tasks
1086
1086
  if any(t["id"] == tid for t in all_tasks):
1087
1087
  continue
1088
- all_tasks.append({
1088
+ task_entry = {
1089
1089
  "id": tid,
1090
1090
  "title": item.get("title", item.get("action", "Task")),
1091
1091
  "description": item.get("description", ""),
@@ -1093,7 +1093,16 @@ async def list_tasks(
1093
1093
  "priority": item.get("priority", "medium"),
1094
1094
  "type": item.get("type", "task"),
1095
1095
  "position": i,
1096
- })
1096
+ }
1097
+ if item.get("acceptance_criteria"):
1098
+ task_entry["acceptance_criteria"] = item["acceptance_criteria"]
1099
+ if item.get("user_story"):
1100
+ task_entry["user_story"] = item["user_story"]
1101
+ if item.get("project"):
1102
+ task_entry["project"] = item["project"]
1103
+ if item.get("source"):
1104
+ task_entry["source"] = item["source"]
1105
+ all_tasks.append(task_entry)
1097
1106
  except (json.JSONDecodeError, KeyError):
1098
1107
  pass
1099
1108
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v6.62.0
5
+ **Version:** v6.63.0
6
6
 
7
7
  ---
8
8
 
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '6.62.0'
60
+ __version__ = '6.63.0'
package/memory/engine.py CHANGED
@@ -360,6 +360,7 @@ class MemoryEngine:
360
360
  pattern_id = self.storage.save_pattern(pattern)
361
361
 
362
362
  # Update index
363
+ pattern_dict = pattern.model_dump() if hasattr(pattern, "model_dump") else pattern.__dict__
363
364
  self._update_index_with_pattern(pattern_dict)
364
365
 
365
366
  return pattern_id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.62.0",
3
+ "version": "6.63.0",
4
4
  "description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "agent",
package/state/manager.py CHANGED
@@ -22,6 +22,7 @@ from pathlib import Path
22
22
  from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
23
23
  from enum import Enum
24
24
  from contextlib import contextmanager
25
+ import copy
25
26
  import uuid
26
27
  import glob as glob_module
27
28
 
@@ -102,11 +103,11 @@ class VersionVector:
102
103
  def concurrent_with(self, other: "VersionVector") -> bool:
103
104
  """Check if two vectors are concurrent (neither dominates).
104
105
 
105
- Per causality rules, identical vectors are concurrent (happened
106
- independently with the same knowledge).
106
+ Identical vectors represent the same causal point, NOT
107
+ concurrent operations, so they return False (BUG-ST-006).
107
108
  """
108
109
  if self.versions == other.versions:
109
- return True
110
+ return False
110
111
  return not self.dominates(other) and not other.dominates(self)
111
112
 
112
113
  def to_dict(self) -> Dict[str, int]:
@@ -796,6 +797,9 @@ class StateManager:
796
797
  """
797
798
  Merge updates into existing state.
798
799
 
800
+ Holds a file lock across the entire read-modify-write to prevent
801
+ lost updates from concurrent callers (BUG-ST-002).
802
+
799
803
  Args:
800
804
  file_ref: File reference
801
805
  updates: Dictionary of updates to merge
@@ -804,9 +808,57 @@ class StateManager:
804
808
  Returns:
805
809
  StateChange object
806
810
  """
807
- current = self.get_state(file_ref, default={})
808
- merged = {**current, **updates}
809
- return self.set_state(file_ref, merged, source)
811
+ path = self._resolve_path(file_ref)
812
+
813
+ with self._file_lock(path, exclusive=True):
814
+ # Read current state under the lock (bypass cache to get
815
+ # the true on-disk value while we hold the exclusive lock)
816
+ old_value = None
817
+ if path.exists():
818
+ try:
819
+ with open(path, "r") as f:
820
+ old_value = json.load(f)
821
+ except (json.JSONDecodeError, IOError):
822
+ old_value = None
823
+
824
+ current = old_value if old_value is not None else {}
825
+ merged = {**current, **updates}
826
+ change_type = "create" if old_value is None else "update"
827
+
828
+ # Save version before writing new data (SYN-015)
829
+ if self.enable_versioning and old_value is not None:
830
+ self._save_version(file_ref, old_value, source, change_type)
831
+
832
+ # Write atomically (temp file + rename) while still under lock
833
+ path.parent.mkdir(parents=True, exist_ok=True)
834
+ fd, temp_path = tempfile.mkstemp(
835
+ dir=path.parent, prefix=".tmp_", suffix=".json"
836
+ )
837
+ try:
838
+ with os.fdopen(fd, "w") as f:
839
+ json.dump(merged, f, indent=2, default=str)
840
+ shutil.move(temp_path, path)
841
+ except Exception:
842
+ if os.path.exists(temp_path):
843
+ os.unlink(temp_path)
844
+ raise
845
+
846
+ # Update cache
847
+ self._put_in_cache(path, merged)
848
+
849
+ # Create change object
850
+ change = StateChange(
851
+ file_path=str(path.relative_to(self.loki_dir)),
852
+ old_value=old_value,
853
+ new_value=merged,
854
+ change_type=change_type,
855
+ source=source
856
+ )
857
+
858
+ # Broadcast change
859
+ self._broadcast(change)
860
+
861
+ return change
810
862
 
811
863
  def delete_state(
812
864
  self,
@@ -1147,16 +1199,38 @@ class StateManager:
1147
1199
  return states
1148
1200
 
1149
1201
  def refresh_cache(self) -> None:
1150
- """Refresh all cached entries from disk."""
1202
+ """Refresh all cached entries from disk.
1203
+
1204
+ Collects paths under _cache_lock, releases it, reads files
1205
+ (which acquire file locks), then re-acquires _cache_lock to
1206
+ update entries. This avoids an ABBA deadlock between the
1207
+ cache lock and file locks (BUG-ST-001).
1208
+ """
1209
+ # Step 1: collect paths under the cache lock
1210
+ with self._cache_lock:
1211
+ paths_to_refresh = list(self._cache.keys())
1212
+
1213
+ # Step 2: read files WITHOUT holding _cache_lock
1214
+ refreshed: dict = {}
1215
+ gone: list = []
1216
+ for path_str in paths_to_refresh:
1217
+ path = Path(path_str)
1218
+ if path.exists():
1219
+ data = self._read_file(path)
1220
+ if data:
1221
+ refreshed[path_str] = data
1222
+ else:
1223
+ gone.append(path_str)
1224
+
1225
+ # Step 3: re-acquire _cache_lock and update entries
1151
1226
  with self._cache_lock:
1152
- for path_str in list(self._cache.keys()):
1227
+ for path_str, data in refreshed.items():
1153
1228
  path = Path(path_str)
1154
- if path.exists():
1155
- data = self._read_file(path)
1156
- if data:
1157
- self._put_in_cache(path, data)
1158
- else:
1159
- del self._cache[path_str]
1229
+ data_hash = self._compute_hash(data)
1230
+ mtime = path.stat().st_mtime if path.exists() else 0
1231
+ self._cache[path_str] = (data, data_hash, mtime)
1232
+ for path_str in gone:
1233
+ self._cache.pop(path_str, None)
1160
1234
 
1161
1235
  # -------------------------------------------------------------------------
1162
1236
  # Optimistic Updates (SYN-014)
@@ -1231,8 +1305,9 @@ class StateManager:
1231
1305
  self._pending_updates[path_str] = []
1232
1306
  self._pending_updates[path_str].append(pending)
1233
1307
 
1234
- # Apply optimistically to local state
1235
- current_state = self.get_state(file_ref, default={})
1308
+ # Apply optimistically to local state -- deepcopy to avoid
1309
+ # mutating the cached dict in-place (BUG-ST-011)
1310
+ current_state = copy.deepcopy(self.get_state(file_ref, default={}))
1236
1311
  current_state[key] = value
1237
1312
  current_state["_version_vector"] = version_vector.to_dict()
1238
1313
  current_state["_last_source"] = source
@@ -1394,14 +1469,23 @@ class StateManager:
1394
1469
  return merged
1395
1470
 
1396
1471
  elif isinstance(local, list) and isinstance(remote, list):
1397
- # Concatenate and deduplicate (preserving order)
1472
+ # Concatenate and deduplicate (preserving order).
1473
+ # Use try/except to handle unhashable types gracefully
1474
+ # by falling back to JSON serialization (BUG-ST-013).
1398
1475
  seen = set()
1399
1476
  merged = []
1400
1477
  for item in local + remote:
1401
- item_key = json.dumps(item, sort_keys=True, default=str) if isinstance(item, (dict, list)) else item
1402
- if item_key not in seen:
1403
- seen.add(item_key)
1404
- merged.append(item)
1478
+ try:
1479
+ item_key = json.dumps(item, sort_keys=True, default=str) if isinstance(item, (dict, list)) else item
1480
+ if item_key not in seen:
1481
+ seen.add(item_key)
1482
+ merged.append(item)
1483
+ except TypeError:
1484
+ # Unhashable type -- fall back to JSON string as key
1485
+ item_key = json.dumps(item, sort_keys=True, default=str)
1486
+ if item_key not in seen:
1487
+ seen.add(item_key)
1488
+ merged.append(item)
1405
1489
  return merged
1406
1490
 
1407
1491
  else:
@@ -1598,23 +1682,27 @@ class StateManager:
1598
1682
  return version
1599
1683
 
1600
1684
  def _cleanup_old_versions(self, file_ref: Union[str, ManagedFile]) -> None:
1601
- """Remove versions beyond the retention limit."""
1685
+ """Remove versions beyond the retention limit.
1686
+
1687
+ Only considers files with purely numeric stems so that orphan
1688
+ temp files (e.g. .tmp_xxx.json) are not counted toward the
1689
+ retention limit (BUG-ST-010).
1690
+ """
1602
1691
  history_dir = self._get_history_dir(file_ref)
1603
1692
  if not history_dir.exists():
1604
1693
  return
1605
1694
 
1606
1695
  version_files = glob_module.glob(str(history_dir / "*.json"))
1607
- if len(version_files) <= self.version_retention:
1608
- return
1609
1696
 
1610
- # Sort by version number and remove oldest
1697
+ # Filter to numeric stems only (skip temp/orphan files)
1611
1698
  version_nums = []
1612
1699
  for vf in version_files:
1613
- try:
1614
- version_num = int(Path(vf).stem)
1615
- version_nums.append((version_num, vf))
1616
- except ValueError:
1617
- pass
1700
+ stem = Path(vf).stem
1701
+ if stem.isdigit():
1702
+ version_nums.append((int(stem), vf))
1703
+
1704
+ if len(version_nums) <= self.version_retention:
1705
+ return
1618
1706
 
1619
1707
  version_nums.sort(key=lambda x: x[0])
1620
1708
  to_remove = version_nums[:-self.version_retention]
@@ -1777,17 +1865,24 @@ class StateManager:
1777
1865
 
1778
1866
  # Singleton instance for convenience
1779
1867
  _default_manager: Optional[StateManager] = None
1868
+ _default_manager_lock = threading.Lock()
1780
1869
 
1781
1870
 
1782
1871
  def get_state_manager(
1783
1872
  loki_dir: Optional[Union[str, Path]] = None,
1784
1873
  **kwargs
1785
1874
  ) -> StateManager:
1786
- """Get the default state manager instance."""
1875
+ """Get the default state manager instance.
1876
+
1877
+ Uses double-checked locking to avoid a race where two threads
1878
+ could both create a StateManager simultaneously (BUG-ST-007).
1879
+ """
1787
1880
  global _default_manager
1788
1881
 
1789
1882
  if _default_manager is None:
1790
- _default_manager = StateManager(loki_dir, **kwargs)
1883
+ with _default_manager_lock:
1884
+ if _default_manager is None:
1885
+ _default_manager = StateManager(loki_dir, **kwargs)
1791
1886
 
1792
1887
  return _default_manager
1793
1888