loki-mode 6.60.0 → 6.62.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 (64) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/app-runner.sh +34 -8
  4. package/autonomy/completion-council.sh +70 -32
  5. package/autonomy/issue-parser.sh +4 -7
  6. package/autonomy/loki +238 -119
  7. package/autonomy/notification-checker.py +49 -23
  8. package/autonomy/run.sh +162 -79
  9. package/autonomy/sandbox.sh +91 -24
  10. package/bin/loki-mode.js +1 -2
  11. package/bin/postinstall.js +10 -4
  12. package/dashboard/__init__.py +1 -1
  13. package/dashboard/control.py +46 -36
  14. package/dashboard/database.py +21 -4
  15. package/dashboard/server.py +107 -78
  16. package/docs/BUG-AUDIT-v6.61.0.md +957 -0
  17. package/docs/INSTALLATION.md +2 -2
  18. package/events/bus.py +129 -28
  19. package/events/bus.ts +41 -27
  20. package/events/emit.sh +1 -1
  21. package/integrations/openclaw/README.md +139 -0
  22. package/integrations/openclaw/SKILL.md +88 -0
  23. package/integrations/openclaw/bridge/__init__.py +1 -0
  24. package/integrations/openclaw/bridge/__main__.py +88 -0
  25. package/integrations/openclaw/bridge/schema_map.py +180 -0
  26. package/integrations/openclaw/bridge/watcher.py +100 -0
  27. package/integrations/openclaw/scripts/format-progress.sh +80 -0
  28. package/integrations/openclaw/scripts/poll-status.sh +74 -0
  29. package/integrations/vibe-kanban.md +289 -0
  30. package/mcp/__init__.py +1 -1
  31. package/mcp/server.py +96 -73
  32. package/memory/consolidation.py +21 -6
  33. package/memory/engine.py +53 -26
  34. package/memory/layers/index_layer.py +16 -3
  35. package/memory/layers/timeline_layer.py +16 -3
  36. package/memory/retrieval.py +4 -1
  37. package/memory/schemas.py +4 -2
  38. package/memory/storage.py +25 -4
  39. package/memory/token_economics.py +9 -2
  40. package/memory/vector_index.py +2 -2
  41. package/package.json +3 -1
  42. package/providers/cline.sh +5 -4
  43. package/providers/codex.sh +27 -5
  44. package/providers/gemini.sh +59 -23
  45. package/providers/loader.sh +3 -2
  46. package/skills/parallel-workflows.md +9 -7
  47. package/state/__init__.py +10 -0
  48. package/state/index.ts +18 -0
  49. package/state/manager.py +1801 -0
  50. package/state/manager.ts +1774 -0
  51. package/state/sqlite_backend.py +188 -0
  52. package/state/test_manager.py +703 -0
  53. package/state/test_manager.ts +366 -0
  54. package/templates/README.md +19 -4
  55. package/templates/dashboard.md +45 -0
  56. package/templates/data-pipeline.md +45 -0
  57. package/templates/game.md +48 -0
  58. package/templates/microservice.md +49 -0
  59. package/templates/npm-library.md +42 -0
  60. package/templates/rest-api.md +170 -33
  61. package/templates/slack-bot.md +48 -0
  62. package/templates/web-scraper.md +45 -0
  63. package/web-app/server.py +360 -191
  64. package/templates/saas-app.md +0 -42
package/memory/engine.py CHANGED
@@ -290,10 +290,10 @@ class MemoryEngine:
290
290
  """
291
291
  # Parse date from episode ID (format: ep-YYYY-MM-DD-XXX)
292
292
  parts = episode_id.split("-")
293
- if len(parts) >= 4:
293
+ if len(parts) >= 5 and len(parts[1]) == 4 and len(parts[2]) == 2 and len(parts[3]) == 2:
294
294
  date_str = f"{parts[1]}-{parts[2]}-{parts[3]}"
295
295
  else:
296
- # Search all directories
296
+ # Non-standard ID format; search all directories
297
297
  return self._search_episode(episode_id)
298
298
 
299
299
  data = self.storage.read_json(f"episodic/{date_str}/task-{episode_id}.json")
@@ -355,26 +355,9 @@ class MemoryEngine:
355
355
  Returns:
356
356
  Pattern ID
357
357
  """
358
- pattern_dict = pattern.to_dict() if hasattr(pattern, "to_dict") else pattern.__dict__.copy()
359
- pattern_id = pattern_dict.get("id", f"sem-{self._generate_id()}")
360
- pattern_dict["id"] = pattern_id
361
-
362
- # Load existing patterns
363
- patterns_data = self.storage.read_json("semantic/patterns.json") or {"patterns": []}
364
-
365
- # Check if pattern exists and update, otherwise append
366
- existing_idx = None
367
- for idx, p in enumerate(patterns_data["patterns"]):
368
- if p.get("id") == pattern_id:
369
- existing_idx = idx
370
- break
371
-
372
- if existing_idx is not None:
373
- patterns_data["patterns"][existing_idx] = pattern_dict
374
- else:
375
- patterns_data["patterns"].append(pattern_dict)
376
-
377
- self.storage.write_json("semantic/patterns.json", patterns_data)
358
+ # Delegate to storage.save_pattern() which performs the
359
+ # read-modify-write under a single file lock, preventing races.
360
+ pattern_id = self.storage.save_pattern(pattern)
378
361
 
379
362
  # Update index
380
363
  self._update_index_with_pattern(pattern_dict)
@@ -1191,10 +1174,54 @@ class MemoryEngine:
1191
1174
  collection: str,
1192
1175
  top_k: int,
1193
1176
  ) -> List[Dict[str, Any]]:
1194
- """Vector similarity search. Requires embeddings to be configured."""
1195
- # This is a placeholder for vector search implementation
1196
- # In production, this would use a vector database or local index
1197
- return self._keyword_search("", collection, top_k)
1177
+ """Vector similarity search using cosine similarity against stored memories."""
1178
+ try:
1179
+ import numpy as np
1180
+ except ImportError:
1181
+ # No numpy available, fall back to keyword search
1182
+ return self._keyword_search("", collection, top_k)
1183
+
1184
+ query_vec = np.asarray(embedding, dtype=np.float32)
1185
+ query_norm = np.linalg.norm(query_vec)
1186
+ if query_norm == 0:
1187
+ return self._keyword_search("", collection, top_k)
1188
+ query_vec = query_vec / query_norm
1189
+
1190
+ # Load memories from the collection using the same paths as _keyword_search
1191
+ items: List[Dict[str, Any]] = []
1192
+ if collection == "episodic":
1193
+ for ep in self.get_recent_episodes(limit=50):
1194
+ items.append(ep.to_dict() if hasattr(ep, "to_dict") else ep.__dict__.copy())
1195
+ elif collection == "semantic":
1196
+ for pattern in self.find_patterns(min_confidence=0.3):
1197
+ items.append(pattern.to_dict() if hasattr(pattern, "to_dict") else pattern.__dict__.copy())
1198
+ elif collection == "procedural":
1199
+ for skill in self.list_skills():
1200
+ items.append(skill.to_dict() if hasattr(skill, "to_dict") else skill.__dict__.copy())
1201
+
1202
+ # Score each item by cosine similarity against its stored embedding
1203
+ scored: List[tuple] = []
1204
+ for item in items:
1205
+ item_embedding = item.get("_embedding")
1206
+ if not item_embedding:
1207
+ continue
1208
+ item_vec = np.asarray(item_embedding, dtype=np.float32)
1209
+ item_norm = np.linalg.norm(item_vec)
1210
+ if item_norm == 0:
1211
+ continue
1212
+ similarity = float(np.dot(query_vec, item_vec / item_norm))
1213
+ scored.append((similarity, item))
1214
+
1215
+ if not scored:
1216
+ # No embeddings stored; fall back to keyword search
1217
+ return self._keyword_search("", collection, top_k)
1218
+
1219
+ scored.sort(key=lambda x: x[0], reverse=True)
1220
+ results = []
1221
+ for score, item in scored[:top_k]:
1222
+ item["_score"] = score
1223
+ results.append(item)
1224
+ return results
1198
1225
 
1199
1226
  def _queue_for_embedding(
1200
1227
  self,
@@ -10,6 +10,8 @@ loading full memory content.
10
10
  """
11
11
 
12
12
  import json
13
+ import os
14
+ import tempfile
13
15
  from dataclasses import dataclass, field, asdict
14
16
  from datetime import datetime, timezone
15
17
  from pathlib import Path
@@ -109,7 +111,7 @@ class IndexLayer:
109
111
 
110
112
  def _save(self, index: Dict[str, Any]) -> None:
111
113
  """
112
- Save index to disk.
114
+ Save index to disk atomically via temp file + os.replace().
113
115
 
114
116
  Args:
115
117
  index: Index dictionary to save
@@ -117,8 +119,19 @@ class IndexLayer:
117
119
  self.base_path.mkdir(parents=True, exist_ok=True)
118
120
  index["last_updated"] = datetime.now(timezone.utc).isoformat()
119
121
 
120
- with open(self.index_path, "w") as f:
121
- json.dump(index, f, indent=2)
122
+ fd, tmp_path = tempfile.mkstemp(
123
+ dir=str(self.base_path), prefix=".tmp_index_", suffix=".json"
124
+ )
125
+ try:
126
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
127
+ json.dump(index, f, indent=2)
128
+ os.replace(tmp_path, str(self.index_path))
129
+ except Exception:
130
+ try:
131
+ os.unlink(tmp_path)
132
+ except OSError:
133
+ pass
134
+ raise
122
135
 
123
136
  self._cache = index
124
137
 
@@ -9,6 +9,8 @@ providing temporal context before loading full memories.
9
9
  """
10
10
 
11
11
  import json
12
+ import os
13
+ import tempfile
12
14
  from datetime import datetime, timezone
13
15
  from pathlib import Path
14
16
  from typing import List, Dict, Any, Optional
@@ -70,7 +72,7 @@ class TimelineLayer:
70
72
 
71
73
  def _save(self, timeline: Dict[str, Any]) -> None:
72
74
  """
73
- Save timeline to disk.
75
+ Save timeline to disk atomically via temp file + os.replace().
74
76
 
75
77
  Args:
76
78
  timeline: Timeline dictionary to save
@@ -78,8 +80,19 @@ class TimelineLayer:
78
80
  self.base_path.mkdir(parents=True, exist_ok=True)
79
81
  timeline["last_updated"] = datetime.now(timezone.utc).isoformat()
80
82
 
81
- with open(self.timeline_path, "w") as f:
82
- json.dump(timeline, f, indent=2)
83
+ fd, tmp_path = tempfile.mkstemp(
84
+ dir=str(self.base_path), prefix=".tmp_timeline_", suffix=".json"
85
+ )
86
+ try:
87
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
88
+ json.dump(timeline, f, indent=2)
89
+ os.replace(tmp_path, str(self.timeline_path))
90
+ except Exception:
91
+ try:
92
+ os.unlink(tmp_path)
93
+ except OSError:
94
+ pass
95
+ raise
83
96
 
84
97
  self._cache = timeline
85
98
 
@@ -1082,7 +1082,10 @@ class MemoryRetrieval:
1082
1082
 
1083
1083
  # Layer 3: Full details for highest priority items
1084
1084
  if budget_remaining > 100: # At least 100 tokens remaining
1085
- full_details = self.retrieve_task_aware(context, top_k=10)
1085
+ # Cap top_k based on remaining budget to avoid loading unbounded data.
1086
+ # Estimate ~50 tokens per result as a rough lower bound.
1087
+ max_results = max(1, min(10, budget_remaining // 50))
1088
+ full_details = self.retrieve_task_aware(context, top_k=max_results)
1086
1089
  for detail in full_details:
1087
1090
  detail["_layer"] = 3
1088
1091
 
package/memory/schemas.py CHANGED
@@ -23,8 +23,10 @@ def _to_utc_isoformat(dt: datetime) -> str:
23
23
  Handles both timezone-aware and timezone-naive datetimes.
24
24
  If dt has a non-UTC timezone, converts to UTC first.
25
25
  """
26
- # If timezone-aware and not UTC, convert to UTC
27
- if dt.tzinfo is not None and dt.utcoffset() != timezone.utc.utcoffset(None):
26
+ # If timezone-aware and not UTC, convert to UTC.
27
+ # Compare as timedelta values (not by identity) for reliable cross-tz checks.
28
+ from datetime import timedelta as _td
29
+ if dt.tzinfo is not None and (dt.utcoffset() or _td(0)) != _td(0):
28
30
  dt = dt.astimezone(timezone.utc)
29
31
 
30
32
  iso = dt.isoformat()
package/memory/storage.py CHANGED
@@ -13,6 +13,7 @@ import os
13
13
  import tempfile
14
14
  import shutil
15
15
  import fcntl
16
+ import threading
16
17
  from datetime import datetime, timezone, timedelta
17
18
  from pathlib import Path
18
19
  from typing import Optional, List, Dict, Any, Union
@@ -81,6 +82,10 @@ class MemoryStorage:
81
82
  else:
82
83
  self.base_path = self._root_path
83
84
 
85
+ # Reentrant lock tracking: prevents deadlock when _file_lock is
86
+ # called on the same path from nested operations in the same thread.
87
+ self._held_locks: threading.local = threading.local()
88
+
84
89
  self._ensure_directories()
85
90
  self._ensure_index()
86
91
  self._ensure_timeline()
@@ -193,7 +198,10 @@ class MemoryStorage:
193
198
  @contextmanager
194
199
  def _file_lock(self, path: Path, exclusive: bool = True):
195
200
  """
196
- Context manager for file locking.
201
+ Context manager for reentrant file locking.
202
+
203
+ If the current thread already holds the lock for this path,
204
+ the call is a no-op (avoids deadlock from nested lock acquisition).
197
205
 
198
206
  Args:
199
207
  path: Path to the file to lock
@@ -203,6 +211,17 @@ class MemoryStorage:
203
211
  File handle with lock held
204
212
  """
205
213
  lock_path = path.with_suffix(path.suffix + ".lock")
214
+ lock_key = str(lock_path)
215
+
216
+ # Check if this thread already holds the lock (reentrant case)
217
+ if not hasattr(self._held_locks, "paths"):
218
+ self._held_locks.paths = set()
219
+
220
+ if lock_key in self._held_locks.paths:
221
+ # Already held by this thread -- skip to avoid deadlock
222
+ yield
223
+ return
224
+
206
225
  lock_path.parent.mkdir(parents=True, exist_ok=True)
207
226
 
208
227
  lock_file = None
@@ -211,8 +230,10 @@ class MemoryStorage:
211
230
  lock_file = open(lock_path, "w")
212
231
  lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
213
232
  fcntl.flock(lock_file.fileno(), lock_type)
233
+ self._held_locks.paths.add(lock_key)
214
234
  yield
215
235
  finally:
236
+ self._held_locks.paths.discard(lock_key)
216
237
  if lock_file is not None:
217
238
  fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
218
239
  lock_file.close()
@@ -1196,7 +1217,7 @@ class MemoryStorage:
1196
1217
  if data:
1197
1218
  original_importance = data.get("importance", 0.5)
1198
1219
  memories = self.apply_decay([data], decay_rate, half_life_days)
1199
- if memories[0].get("importance") != original_importance:
1220
+ if abs(memories[0].get("importance", 0.5) - original_importance) > 0.001:
1200
1221
  self._atomic_write(file_path, memories[0])
1201
1222
  updated += 1
1202
1223
 
@@ -1220,7 +1241,7 @@ class MemoryStorage:
1220
1241
  for pattern in patterns:
1221
1242
  original = pattern.get("importance", 0.5)
1222
1243
  self.apply_decay([pattern], decay_rate, half_life_days)
1223
- if pattern.get("importance") != original:
1244
+ if abs(pattern.get("importance", 0.5) - original) > 0.001:
1224
1245
  updated += 1
1225
1246
 
1226
1247
  if updated > 0:
@@ -1241,7 +1262,7 @@ class MemoryStorage:
1241
1262
  if data:
1242
1263
  original = data.get("importance", 0.5)
1243
1264
  self.apply_decay([data], decay_rate, half_life_days)
1244
- if data.get("importance") != original:
1265
+ if abs(data.get("importance", 0.5) - original) > 0.001:
1245
1266
  self._atomic_write(file_path, data)
1246
1267
  updated += 1
1247
1268
 
@@ -557,15 +557,22 @@ class TokenEconomics:
557
557
  """
558
558
  Get a summary of token economics for this session.
559
559
 
560
+ Caches computed values to avoid redundant filesystem scans
561
+ when check_thresholds() re-calls get_ratio()/get_savings_percent().
562
+
560
563
  Returns:
561
564
  Dictionary with session info, metrics, and computed values
562
565
  """
566
+ # Pre-compute once; check_thresholds reuses the cached baseline.
567
+ ratio = self.get_ratio()
568
+ savings = self.get_savings_percent()
569
+
563
570
  return {
564
571
  "session_id": self.session_id,
565
572
  "started_at": self.started_at.isoformat(),
566
573
  "metrics": dict(self.metrics),
567
- "ratio": self.get_ratio(),
568
- "savings_percent": self.get_savings_percent(),
574
+ "ratio": ratio,
575
+ "savings_percent": savings,
569
576
  "thresholds_triggered": [a.to_dict() for a in self.check_thresholds()],
570
577
  }
571
578
 
@@ -290,7 +290,7 @@ class VectorIndex:
290
290
  "dimension": self.dimension
291
291
  }
292
292
 
293
- with open(f"{path}.json", "w") as f:
293
+ with open(f"{path}.json", "w", encoding="utf-8") as f:
294
294
  json.dump(sidecar_data, f, indent=2)
295
295
 
296
296
  def load(self, path: str) -> None:
@@ -318,7 +318,7 @@ class VectorIndex:
318
318
  self.dimension = int(data["dimension"][0])
319
319
 
320
320
  # Load metadata
321
- with open(json_path, "r") as f:
321
+ with open(json_path, "r", encoding="utf-8") as f:
322
322
  sidecar_data = json.load(f)
323
323
 
324
324
  self.ids = sidecar_data["ids"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.60.0",
3
+ "version": "6.62.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",
@@ -80,6 +80,8 @@
80
80
  "dashboard/static/",
81
81
  "dashboard/requirements.txt",
82
82
  "mcp/",
83
+ "state/",
84
+ "integrations/",
83
85
  "completions/",
84
86
  "src/",
85
87
  "web-app/dist/",
@@ -90,14 +90,15 @@ provider_version() {
90
90
 
91
91
  # Invocation function
92
92
  # Uses -y (YOLO) for autonomous mode, positional prompt
93
+ # BUG-PROV-009 fix: build model flag as array to prevent word-splitting on model
94
+ # names that contain spaces or special characters
93
95
  provider_invoke() {
94
96
  local prompt="$1"
95
97
  shift
96
98
  local model="${LOKI_CLINE_MODEL:-}"
97
- local model_flag=""
98
- [[ -n "$model" ]] && model_flag="-m $model"
99
- # shellcheck disable=SC2086
100
- cline -y $model_flag "$prompt" "$@" 2>&1
99
+ local model_args=()
100
+ [[ -n "$model" ]] && model_args=("-m" "$model")
101
+ cline -y "${model_args[@]}" "$prompt" "$@" 2>&1
101
102
  }
102
103
 
103
104
  # Model tier to parameter (Cline uses single model, returns model name)
@@ -51,9 +51,28 @@ PROVIDER_MAX_PARALLEL=1
51
51
  # Codex uses single model with effort parameter
52
52
  # NOTE: gpt-5.3-codex is the official model name for Codex CLI v0.98+
53
53
  CODEX_DEFAULT_MODEL="gpt-5.3-codex"
54
- PROVIDER_MODEL_PLANNING="${LOKI_CODEX_MODEL:-${LOKI_MODEL_PLANNING:-$CODEX_DEFAULT_MODEL}}"
55
- PROVIDER_MODEL_DEVELOPMENT="${LOKI_CODEX_MODEL:-${LOKI_MODEL_DEVELOPMENT:-$CODEX_DEFAULT_MODEL}}"
56
- PROVIDER_MODEL_FAST="${LOKI_CODEX_MODEL:-${LOKI_MODEL_FAST:-$CODEX_DEFAULT_MODEL}}"
54
+
55
+ # Known valid Codex model prefixes for validation (BUG-PROV-002 fix)
56
+ # Generic LOKI_MODEL_* may contain Claude/Gemini model names (e.g. "opus", "sonnet",
57
+ # "gemini-3-pro-preview") which are invalid for Codex. Validate before accepting.
58
+ CODEX_KNOWN_MODELS=("gpt-" "o1-" "o3-" "o4-" "codex-" "ft:gpt-")
59
+
60
+ _codex_validate_model() {
61
+ local model="$1"
62
+ for prefix in "${CODEX_KNOWN_MODELS[@]}"; do
63
+ if [[ "$model" == ${prefix}* ]]; then
64
+ echo "$model"
65
+ return 0
66
+ fi
67
+ done
68
+ # Not a valid Codex model name -- fall back to default
69
+ echo "$CODEX_DEFAULT_MODEL"
70
+ }
71
+
72
+ # Provider-specific env (LOKI_CODEX_MODEL) is trusted; generic LOKI_MODEL_* is validated
73
+ PROVIDER_MODEL_PLANNING="$(_codex_validate_model "${LOKI_CODEX_MODEL:-${LOKI_MODEL_PLANNING:-$CODEX_DEFAULT_MODEL}}")"
74
+ PROVIDER_MODEL_DEVELOPMENT="$(_codex_validate_model "${LOKI_CODEX_MODEL:-${LOKI_MODEL_DEVELOPMENT:-$CODEX_DEFAULT_MODEL}}")"
75
+ PROVIDER_MODEL_FAST="$(_codex_validate_model "${LOKI_CODEX_MODEL:-${LOKI_MODEL_FAST:-$CODEX_DEFAULT_MODEL}}")"
57
76
 
58
77
  # Effort levels (Codex-specific: maps to reasoning time, not model capability)
59
78
  PROVIDER_EFFORT_PLANNING="xhigh"
@@ -115,8 +134,11 @@ provider_get_tier_param() {
115
134
  }
116
135
 
117
136
  # Dynamic model resolution (v6.0.0)
118
- # Resolves a capability tier to a concrete effort level at runtime.
119
- # Codex uses a single model with effort parameter, so maxTier maps to effort cap.
137
+ # NOTE (BUG-PROV-012): Unlike other providers, Codex resolve_model_for_tier returns
138
+ # an EFFORT LEVEL (xhigh/high/low), not a model name. Codex uses a single model
139
+ # (gpt-5.3-codex) with varying effort. Callers that need the model name should use
140
+ # PROVIDER_MODEL_DEVELOPMENT (or CODEX_DEFAULT_MODEL) directly.
141
+ # The effort value is passed via CODEX_MODEL_REASONING_EFFORT env var at invocation.
120
142
  resolve_model_for_tier() {
121
143
  local tier="$1"
122
144
 
@@ -54,12 +54,39 @@ PROVIDER_MAX_PARALLEL=1
54
54
  GEMINI_DEFAULT_PRO="gemini-3-pro-preview"
55
55
  GEMINI_DEFAULT_FLASH="gemini-3-flash-preview"
56
56
 
57
- PROVIDER_MODEL_PLANNING="${LOKI_GEMINI_MODEL_PLANNING:-${LOKI_MODEL_PLANNING:-$GEMINI_DEFAULT_PRO}}"
58
- PROVIDER_MODEL_DEVELOPMENT="${LOKI_GEMINI_MODEL_DEVELOPMENT:-${LOKI_MODEL_DEVELOPMENT:-$GEMINI_DEFAULT_PRO}}"
59
- PROVIDER_MODEL_FAST="${LOKI_GEMINI_MODEL_FAST:-${LOKI_MODEL_FAST:-$GEMINI_DEFAULT_FLASH}}"
60
- PROVIDER_MODEL="${PROVIDER_MODEL_PLANNING}"
57
+ # Known valid Gemini model prefixes for validation
58
+ GEMINI_KNOWN_MODELS=("gemini-" "models/gemini-")
59
+
60
+ # Validate that a model name looks like a Gemini model
61
+ _gemini_validate_model() {
62
+ local model="$1"
63
+ local fallback="$2"
64
+ for prefix in "${GEMINI_KNOWN_MODELS[@]}"; do
65
+ if [[ "$model" == ${prefix}* ]]; then
66
+ echo "$model"
67
+ return 0
68
+ fi
69
+ done
70
+ # Not a valid Gemini model name -- fall back
71
+ echo "$fallback"
72
+ }
73
+
74
+ PROVIDER_MODEL_PLANNING="$(_gemini_validate_model "${LOKI_GEMINI_MODEL_PLANNING:-${LOKI_MODEL_PLANNING:-$GEMINI_DEFAULT_PRO}}" "$GEMINI_DEFAULT_PRO")"
75
+ PROVIDER_MODEL_DEVELOPMENT="$(_gemini_validate_model "${LOKI_GEMINI_MODEL_DEVELOPMENT:-${LOKI_MODEL_DEVELOPMENT:-$GEMINI_DEFAULT_PRO}}" "$GEMINI_DEFAULT_PRO")"
76
+ PROVIDER_MODEL_FAST="$(_gemini_validate_model "${LOKI_GEMINI_MODEL_FAST:-${LOKI_MODEL_FAST:-$GEMINI_DEFAULT_FLASH}}" "$GEMINI_DEFAULT_FLASH")"
61
77
  PROVIDER_MODEL_FALLBACK="${LOKI_GEMINI_MODEL_FALLBACK:-$GEMINI_DEFAULT_FLASH}"
62
78
 
79
+ # BUG-PROV-006 fix: PROVIDER_MODEL is now a function, not a frozen variable.
80
+ # For backward compatibility, set the variable to planning model at load time,
81
+ # but callers should use provider_get_current_model() for runtime resolution.
82
+ PROVIDER_MODEL="${PROVIDER_MODEL_PLANNING}"
83
+
84
+ # Return the model for the current tier at runtime (not frozen at load time)
85
+ provider_get_current_model() {
86
+ local tier="${LOKI_CURRENT_TIER:-planning}"
87
+ resolve_model_for_tier "$tier"
88
+ }
89
+
63
90
  # Thinking levels (Gemini-specific: maps to reasoning depth)
64
91
  PROVIDER_THINKING_PLANNING="high"
65
92
  PROVIDER_THINKING_DEVELOPMENT="medium"
@@ -106,27 +133,37 @@ provider_version() {
106
133
  # Invocation function with rate limit fallback
107
134
  # Uses --model flag to specify model, --approval-mode=yolo for autonomous mode
108
135
  # Falls back to flash model if pro hits rate limit
136
+ # Accepts optional --model <name> as first args to override default model
137
+ # BUG-PROV-010 fix: uses tee to stream output while still capturing for rate-limit check
109
138
  # Note: < /dev/null prevents Gemini from pausing on stdin
110
139
  provider_invoke() {
140
+ local model
141
+ model=$(provider_get_current_model)
142
+
143
+ # Allow callers to pass --model <name> to override
144
+ if [[ "${1:-}" == "--model" ]] && [[ -n "${2:-}" ]]; then
145
+ model="$2"
146
+ shift 2
147
+ fi
148
+
111
149
  local prompt="$1"
112
150
  shift
113
- local output
114
151
  local exit_code
115
152
 
116
- # Try primary model first - capture stderr separately to avoid polluting output
117
- local stderr_file
153
+ # Stream output via tee while capturing for rate-limit check
154
+ local output_file stderr_file
155
+ output_file=$(mktemp)
118
156
  stderr_file=$(mktemp)
119
- output=$(gemini --approval-mode=yolo --model "$PROVIDER_MODEL" "$prompt" "$@" < /dev/null 2>"$stderr_file")
120
- exit_code=$?
157
+ gemini --approval-mode=yolo --model "$model" "$prompt" "$@" < /dev/null 2>"$stderr_file" | tee "$output_file"
158
+ exit_code=${PIPESTATUS[0]}
121
159
 
122
160
  # Check for rate limit (429) or quota exceeded (check stderr for error indicators)
123
161
  if [[ $exit_code -ne 0 ]] && grep -qiE "(rate.?limit|429|quota|resource.?exhausted)" "$stderr_file" 2>/dev/null; then
124
- rm -f "$stderr_file"
125
- echo "[loki] Rate limit hit on $PROVIDER_MODEL, falling back to $PROVIDER_MODEL_FALLBACK" >&2
162
+ rm -f "$stderr_file" "$output_file"
163
+ echo "[loki] Rate limit hit on $model, falling back to $PROVIDER_MODEL_FALLBACK" >&2
126
164
  gemini --approval-mode=yolo --model "$PROVIDER_MODEL_FALLBACK" "$prompt" "$@" < /dev/null
127
165
  else
128
- echo "$output"
129
- rm -f "$stderr_file"
166
+ rm -f "$stderr_file" "$output_file"
130
167
  return $exit_code
131
168
  fi
132
169
  }
@@ -185,8 +222,8 @@ resolve_model_for_tier() {
185
222
  }
186
223
 
187
224
  # Tier-aware invocation with rate limit fallback
188
- # Uses --model flag to specify model
189
- # Falls back to flash model if pro hits rate limit
225
+ # BUG-PROV-001 fix: uses resolve_model_for_tier to select actual model for the tier
226
+ # BUG-PROV-010 fix: uses tee to stream output while capturing for rate-limit check
190
227
  # Note: < /dev/null prevents Gemini from pausing on stdin
191
228
  provider_invoke_with_tier() {
192
229
  local tier="$1"
@@ -198,23 +235,22 @@ provider_invoke_with_tier() {
198
235
 
199
236
  echo "[loki] Using tier: $tier, model: $model" >&2
200
237
 
201
- local output
202
238
  local exit_code
203
239
 
204
- # Try selected model first - capture stderr separately to avoid polluting output
205
- local stderr_file
240
+ # Stream output via tee while capturing for rate-limit check
241
+ local output_file stderr_file
242
+ output_file=$(mktemp)
206
243
  stderr_file=$(mktemp)
207
- output=$(gemini --approval-mode=yolo --model "$model" "$prompt" "$@" < /dev/null 2>"$stderr_file")
208
- exit_code=$?
244
+ gemini --approval-mode=yolo --model "$model" "$prompt" "$@" < /dev/null 2>"$stderr_file" | tee "$output_file"
245
+ exit_code=${PIPESTATUS[0]}
209
246
 
210
247
  # Check for rate limit (429) or quota exceeded - fallback to flash
211
248
  if [[ $exit_code -ne 0 ]] && grep -qiE "(rate.?limit|429|quota|resource.?exhausted)" "$stderr_file" 2>/dev/null; then
212
- rm -f "$stderr_file"
249
+ rm -f "$stderr_file" "$output_file"
213
250
  echo "[loki] Rate limit hit on $model, falling back to $PROVIDER_MODEL_FALLBACK" >&2
214
251
  gemini --approval-mode=yolo --model "$PROVIDER_MODEL_FALLBACK" "$prompt" "$@" < /dev/null
215
252
  else
216
- echo "$output"
217
- rm -f "$stderr_file"
253
+ rm -f "$stderr_file" "$output_file"
218
254
  return $exit_code
219
255
  fi
220
256
  }
@@ -171,9 +171,10 @@ print_capability_matrix() {
171
171
  }
172
172
 
173
173
  # Auto-detect best available provider
174
+ # BUG-PROV-007 fix: includes all 5 supported providers in priority order
175
+ # Priority: Claude (Tier 1, full) > Cline (Tier 2, near-full) > Codex/Gemini/Aider (Tier 3, degraded)
174
176
  auto_detect_provider() {
175
- # Prefer Claude (full features), then Codex, then Gemini
176
- for p in claude codex gemini; do
177
+ for p in claude cline codex gemini aider; do
177
178
  if check_provider_installed "$p"; then
178
179
  echo "$p"
179
180
  return 0
@@ -55,11 +55,13 @@ cd ../project-feature-auth
55
55
  npm install # or pip install, cargo build, etc.
56
56
  ```
57
57
 
58
- ### Create Testing Worktree (tracks main)
58
+ ### Create Testing Worktree (based on main)
59
59
 
60
60
  ```bash
61
- # Testing always runs against latest main
62
- git worktree add ../project-testing main
61
+ # Testing always runs against latest main via a dedicated branch
62
+ # NOTE: git worktree cannot checkout the same branch in two worktrees,
63
+ # so we create a parallel-testing branch based on main
64
+ git worktree add ../project-testing -b parallel-testing main
63
65
 
64
66
  # Pull latest before each test run
65
67
  cd ../project-testing
@@ -369,16 +371,16 @@ for feature in "${features[@]}"; do
369
371
  echo "Spawned: ${feature} (PID: $!)"
370
372
  done
371
373
 
372
- # Create testing worktree
374
+ # Create testing worktree (dedicated branch based on main)
373
375
  testing_path="../${PROJECT_NAME}-testing"
374
376
  if [ ! -d "$testing_path" ]; then
375
- git worktree add "$testing_path" main
377
+ git worktree add "$testing_path" -b parallel-testing main
376
378
  fi
377
379
 
378
- # Create docs worktree
380
+ # Create docs worktree (dedicated branch based on main)
379
381
  docs_path="../${PROJECT_NAME}-docs"
380
382
  if [ ! -d "$docs_path" ]; then
381
- git worktree add "$docs_path" main
383
+ git worktree add "$docs_path" -b parallel-docs main
382
384
  fi
383
385
 
384
386
  echo "Parallel streams initialized"
@@ -0,0 +1,10 @@
1
+ """
2
+ Loki Mode State Manager
3
+
4
+ Centralized state management with file-based caching,
5
+ file watching, and event bus integration.
6
+ """
7
+
8
+ from .manager import StateManager, StateChange, ManagedFile
9
+
10
+ __all__ = ["StateManager", "StateChange", "ManagedFile"]
package/state/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Loki Mode State Manager - TypeScript Entry Point
3
+ *
4
+ * Centralized state management with file-based caching,
5
+ * file watching, and event bus integration.
6
+ */
7
+
8
+ export {
9
+ StateManager,
10
+ StateChange,
11
+ ManagedFile,
12
+ ManagedFileType,
13
+ StateCallback,
14
+ Disposable,
15
+ getStateDiff,
16
+ getStateManager,
17
+ resetStateManager,
18
+ } from "./manager";