loki-mode 6.37.3 → 6.37.5

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/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v6.37.3
6
+ # Loki Mode v6.37.5
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -267,4 +267,4 @@ The following features are documented in skill modules but not yet fully automat
267
267
  | Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
268
268
  | Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
269
269
 
270
- **v6.37.3 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
270
+ **v6.37.5 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.37.3
1
+ 6.37.5
package/autonomy/loki CHANGED
@@ -1365,6 +1365,15 @@ cmd_pause() {
1365
1365
  fi
1366
1366
 
1367
1367
  if is_session_running; then
1368
+ # Warn if running in perpetual mode where PAUSE is auto-cleared (#84)
1369
+ local current_mode="${LOKI_AUTONOMY_MODE:-perpetual}"
1370
+ local perpetual_flag="${LOKI_PERPETUAL_MODE:-false}"
1371
+ if [ "$current_mode" = "perpetual" ] || [ "$perpetual_flag" = "true" ]; then
1372
+ echo -e "${YELLOW}Warning: Session is running in perpetual mode.${NC}"
1373
+ echo -e "${YELLOW}PAUSE signals are auto-cleared in perpetual mode and will be ignored.${NC}"
1374
+ echo -e "Use ${CYAN}loki stop${NC} to halt a perpetual session, or switch autonomy mode first:"
1375
+ echo -e " ${DIM}loki config set autonomy_mode checkpoint${NC}"
1376
+ fi
1368
1377
  touch "$LOKI_DIR/PAUSE"
1369
1378
  # Emit session pause event
1370
1379
  emit_event session cli pause "reason=user_requested"
@@ -4476,6 +4485,13 @@ cmd_watch() {
4476
4485
  return 1
4477
4486
  fi
4478
4487
 
4488
+ # Verify the PRD file is readable (#67)
4489
+ if [ ! -r "$prd_path" ]; then
4490
+ echo -e "${RED}PRD file is not readable: $prd_path${NC}"
4491
+ echo "Check file permissions: ls -la $prd_path"
4492
+ return 1
4493
+ fi
4494
+
4479
4495
  # Resolve to absolute path
4480
4496
  prd_path="$(cd "$(dirname "$prd_path")" && pwd)/$(basename "$prd_path")"
4481
4497
  local prd_basename
@@ -5001,7 +5017,11 @@ cmd_config_set() {
5001
5017
  ;;
5002
5018
  budget)
5003
5019
  if ! echo "$value" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
5004
- echo -e "${RED}Invalid budget: $value (expected: numeric USD amount)${NC}"; return 1
5020
+ echo -e "${RED}Invalid budget: $value (expected: positive numeric USD amount)${NC}"; return 1
5021
+ fi
5022
+ # Reject zero and negative values (#68)
5023
+ if echo "$value" | grep -qE '^0+(\.0+)?$'; then
5024
+ echo -e "${RED}Invalid budget: $value (must be greater than 0)${NC}"; return 1
5005
5025
  fi
5006
5026
  ;;
5007
5027
  model.planning|model.development|model.fast)
@@ -7788,6 +7808,36 @@ print(json.dumps({'id': manifest.id, 'dir': str(pipeline.migration_dir)}))
7788
7808
  phases_to_run=("understand" "guardrail" "migrate" "verify")
7789
7809
  fi
7790
7810
 
7811
+ # Validate prerequisite artifacts exist before running phases (#81)
7812
+ for p in "${phases_to_run[@]}"; do
7813
+ case "$p" in
7814
+ guardrail)
7815
+ if [ ! -f "${migration_dir}/docs/analysis.md" ] || [ ! -f "${migration_dir}/seams.json" ]; then
7816
+ echo -e "${RED}Error: Phase 'guardrail' requires artifacts from 'understand' phase${NC}"
7817
+ echo -e "${DIM} Missing: analysis.md or seams.json in ${migration_dir}${NC}"
7818
+ echo "Run: loki migrate ${codebase_path} --target ${target} --phase understand"
7819
+ return 1
7820
+ fi
7821
+ ;;
7822
+ migrate)
7823
+ if [ ! -f "${migration_dir}/features.json" ]; then
7824
+ echo -e "${RED}Error: Phase 'migrate' requires artifacts from 'guardrail' phase${NC}"
7825
+ echo -e "${DIM} Missing: features.json in ${migration_dir}${NC}"
7826
+ echo "Run: loki migrate ${codebase_path} --target ${target} --phase guardrail"
7827
+ return 1
7828
+ fi
7829
+ ;;
7830
+ verify)
7831
+ if [ ! -f "${migration_dir}/migration-plan.json" ]; then
7832
+ echo -e "${RED}Error: Phase 'verify' requires migration-plan.json from 'migrate' phase${NC}"
7833
+ echo -e "${DIM} Missing: migration-plan.json in ${migration_dir}${NC}"
7834
+ echo "Run: loki migrate ${codebase_path} --target ${target} --phase migrate"
7835
+ return 1
7836
+ fi
7837
+ ;;
7838
+ esac
7839
+ done
7840
+
7791
7841
  # Execute phases
7792
7842
  for p in "${phases_to_run[@]}"; do
7793
7843
  echo -e "${CYAN}[Phase: ${p}]${NC} Starting..."
@@ -12595,24 +12645,32 @@ cmd_council() {
12595
12645
 
12596
12646
  if [ -f "$council_dir/state.json" ]; then
12597
12647
  LOKI_COUNCIL_STATE="$council_dir/state.json" python3 -c "
12598
- import json, os
12599
- with open(os.environ['LOKI_COUNCIL_STATE']) as f:
12600
- state = json.load(f)
12601
- print(f\"Enabled: {state.get('initialized', False)}\")
12602
- print(f\"Total votes: {state.get('total_votes', 0)}\")
12603
- print(f\"Approve votes: {state.get('approve_votes', 0)}\")
12604
- print(f\"Reject votes: {state.get('reject_votes', 0)}\")
12605
- print(f\"Stagnation streak: {state.get('consecutive_no_change', 0)}\")
12606
- print(f\"Done signals: {state.get('done_signals', 0)}\")
12607
- print(f\"Last check: iteration {state.get('last_check_iteration', 'none')}\")
12608
- verdicts = state.get('verdicts', [])
12609
- if verdicts:
12610
- print(f\"\nRecent verdicts:\")
12611
- for v in verdicts[-5:]:
12612
- print(f\" Iteration {v['iteration']}: {v['result']} ({v['approve']} approve / {v['reject']} reject)\")
12613
- else:
12614
- print(f\"\nNo verdicts yet\")
12615
- " 2>/dev/null
12648
+ import json, os, sys
12649
+ try:
12650
+ with open(os.environ['LOKI_COUNCIL_STATE']) as f:
12651
+ state = json.load(f)
12652
+ print(f\"Enabled: {state.get('initialized', False)}\")
12653
+ print(f\"Total votes: {state.get('total_votes', 0)}\")
12654
+ print(f\"Approve votes: {state.get('approve_votes', 0)}\")
12655
+ print(f\"Reject votes: {state.get('reject_votes', 0)}\")
12656
+ print(f\"Stagnation streak: {state.get('consecutive_no_change', 0)}\")
12657
+ print(f\"Done signals: {state.get('done_signals', 0)}\")
12658
+ print(f\"Last check: iteration {state.get('last_check_iteration', 'none')}\")
12659
+ verdicts = state.get('verdicts', [])
12660
+ if verdicts:
12661
+ print(f\"\nRecent verdicts:\")
12662
+ for v in verdicts[-5:]:
12663
+ print(f\" Iteration {v['iteration']}: {v['result']} ({v['approve']} approve / {v['reject']} reject)\")
12664
+ else:
12665
+ print(f\"\nNo verdicts yet\")
12666
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
12667
+ print(f'Error: Council state file is corrupted or invalid: {e}', file=sys.stderr)
12668
+ print('Delete .loki/council/state.json and restart the session to reset.')
12669
+ sys.exit(1)
12670
+ except Exception as e:
12671
+ print(f'Error reading council state: {e}', file=sys.stderr)
12672
+ sys.exit(1)
12673
+ " 2>&1
12616
12674
  else
12617
12675
  echo "Council state file not found"
12618
12676
  fi
@@ -12623,9 +12681,14 @@ else:
12623
12681
 
12624
12682
  if [ -f "$council_dir/state.json" ]; then
12625
12683
  LOKI_COUNCIL_STATE="$council_dir/state.json" python3 -c "
12626
- import json, os
12627
- with open(os.environ['LOKI_COUNCIL_STATE']) as f:
12628
- state = json.load(f)
12684
+ import json, os, sys
12685
+ try:
12686
+ with open(os.environ['LOKI_COUNCIL_STATE']) as f:
12687
+ state = json.load(f)
12688
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
12689
+ print(f'Error: Council state file is corrupted: {e}', file=sys.stderr)
12690
+ print('Delete .loki/council/state.json and restart the session to reset.')
12691
+ sys.exit(1)
12629
12692
  verdicts = state.get('verdicts', [])
12630
12693
  if not verdicts:
12631
12694
  print('No decisions recorded yet')
@@ -14674,33 +14737,40 @@ for a in agents:
14674
14737
  echo -e "${DIM}Persona: ${persona:0:80}...${NC}"
14675
14738
  echo ""
14676
14739
 
14677
- # Invoke current provider
14740
+ # Invoke current provider and capture exit code (#72)
14678
14741
  local provider="${LOKI_PROVIDER:-claude}"
14679
14742
  if [ -f ".loki/state/provider" ]; then
14680
14743
  provider=$(cat ".loki/state/provider" 2>/dev/null)
14681
14744
  fi
14682
14745
 
14746
+ local agent_exit=0
14683
14747
  case "$provider" in
14684
14748
  claude)
14685
- claude -p "$full_prompt" 2>&1
14749
+ claude -p "$full_prompt" 2>&1 || agent_exit=$?
14686
14750
  ;;
14687
14751
  codex)
14688
- codex exec --full-auto "$full_prompt" 2>&1
14752
+ codex exec --full-auto "$full_prompt" 2>&1 || agent_exit=$?
14689
14753
  ;;
14690
14754
  gemini)
14691
- gemini --approval-mode=yolo "$full_prompt" 2>&1
14755
+ gemini --approval-mode=yolo "$full_prompt" 2>&1 || agent_exit=$?
14692
14756
  ;;
14693
14757
  cline)
14694
- cline -y "$full_prompt" 2>&1
14758
+ cline -y "$full_prompt" 2>&1 || agent_exit=$?
14695
14759
  ;;
14696
14760
  aider)
14697
- aider --message "$full_prompt" --yes-always --no-auto-commits < /dev/null 2>&1
14761
+ aider --message "$full_prompt" --yes-always --no-auto-commits < /dev/null 2>&1 || agent_exit=$?
14698
14762
  ;;
14699
14763
  *)
14700
14764
  echo -e "${RED}Unknown provider: $provider${NC}"
14701
14765
  return 1
14702
14766
  ;;
14703
14767
  esac
14768
+
14769
+ if [ "$agent_exit" -ne 0 ]; then
14770
+ echo ""
14771
+ echo -e "${RED}Agent exited with code $agent_exit${NC}"
14772
+ return "$agent_exit"
14773
+ fi
14704
14774
  ;;
14705
14775
 
14706
14776
  start)
package/autonomy/run.sh CHANGED
@@ -1243,6 +1243,7 @@ detect_complexity() {
1243
1243
  if [ -n "$prd_path" ] && [ -f "$prd_path" ]; then
1244
1244
  local prd_words=$(wc -w < "$prd_path" | tr -d ' ')
1245
1245
  local feature_count=0
1246
+ local prd_lines=$(wc -l < "$prd_path" | tr -d ' ')
1246
1247
 
1247
1248
  # Detect PRD format and count features accordingly
1248
1249
  if [[ "$prd_path" == *.json ]]; then
@@ -1262,14 +1263,23 @@ detect_complexity() {
1262
1263
  feature_count=$(grep -c "^##\|^- \[" "$prd_path" 2>/dev/null || echo "0")
1263
1264
  fi
1264
1265
 
1265
- if [ "$prd_words" -lt 200 ] && [ "$feature_count" -lt 5 ]; then
1266
+ # Count distinct sections (h2/h3 headers) for structural complexity (#74)
1267
+ local section_count=0
1268
+ if [[ "$prd_path" != *.json ]]; then
1269
+ section_count=$(grep -c "^##\|^###" "$prd_path" 2>/dev/null || echo "0")
1270
+ fi
1271
+
1272
+ # PRD complexity uses content length, feature count, AND structural depth (#74)
1273
+ # A PRD with multiple sections or substantial content is not "simple" even with few project files
1274
+ if [ "$prd_words" -lt 200 ] && [ "$feature_count" -lt 5 ] && [ "$section_count" -lt 3 ]; then
1266
1275
  prd_complexity="simple"
1267
- elif [ "$prd_words" -gt 1000 ] || [ "$feature_count" -gt 15 ]; then
1276
+ elif [ "$prd_words" -gt 1000 ] || [ "$feature_count" -gt 15 ] || [ "$section_count" -gt 10 ]; then
1268
1277
  prd_complexity="complex"
1269
1278
  fi
1270
1279
  fi
1271
1280
 
1272
1281
  # Determine final complexity
1282
+ # A non-simple PRD always prevents "simple" classification regardless of file count (#74)
1273
1283
  if [ "$file_count" -le 5 ] && [ "$prd_complexity" = "simple" ] && \
1274
1284
  [ "$has_external" = "false" ] && [ "$has_microservices" = "false" ]; then
1275
1285
  DETECTED_COMPLEXITY="simple"
@@ -1280,7 +1290,7 @@ detect_complexity() {
1280
1290
  DETECTED_COMPLEXITY="standard"
1281
1291
  fi
1282
1292
 
1283
- log_info "Detected complexity: $DETECTED_COMPLEXITY (files: $file_count, external: $has_external, microservices: $has_microservices)"
1293
+ log_info "Detected complexity: $DETECTED_COMPLEXITY (files: $file_count, prd: $prd_complexity, external: $has_external, microservices: $has_microservices)"
1284
1294
  }
1285
1295
 
1286
1296
  # Get phases based on complexity tier
@@ -2824,6 +2834,9 @@ init_loki_dir() {
2824
2834
  mkdir -p .loki/artifacts/{releases,reports,backups}
2825
2835
  mkdir -p .loki/memory/{ledgers,handoffs,learnings,episodic,semantic,skills}
2826
2836
  mkdir -p .loki/metrics/{efficiency,rewards}
2837
+ # Clear stale metrics from previous sessions so loki metrics shows current run data (#75)
2838
+ rm -f .loki/metrics/efficiency/iteration-*.json 2>/dev/null || true
2839
+ rm -f .loki/metrics/rewards/*.json 2>/dev/null || true
2827
2840
  mkdir -p .loki/rules
2828
2841
  mkdir -p .loki/signals
2829
2842
 
@@ -5502,10 +5515,11 @@ run_code_review() {
5502
5515
  log_info "Selecting 3 specialist reviewers from pool..."
5503
5516
 
5504
5517
  # Write diff/files to temp files for python to read (avoid env var size limits)
5518
+ # Use printf to prevent shell variable expansion in diff content (#78)
5505
5519
  local diff_file="$review_dir/$review_id/diff.txt"
5506
5520
  local files_file="$review_dir/$review_id/files.txt"
5507
- echo "$diff_content" > "$diff_file"
5508
- echo "$changed_files" > "$files_file"
5521
+ printf '%s\n' "$diff_content" > "$diff_file"
5522
+ printf '%s\n' "$changed_files" > "$files_file"
5509
5523
 
5510
5524
  # Select specialists via keyword scoring (python3 reads files, not env vars)
5511
5525
  # Loads from agents/types.json when available, falls back to hardcoded pool (v6.7.0)
@@ -5881,11 +5895,11 @@ run_adversarial_testing() {
5881
5895
  local changed_files
5882
5896
  changed_files=$(git -C "${TARGET_DIR:-.}" diff --name-only HEAD~1 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --name-only --cached 2>/dev/null || echo "")
5883
5897
 
5884
- # Write analysis files
5898
+ # Write analysis files -- use printf to prevent shell variable expansion (#78)
5885
5899
  local diff_file="$adversarial_dir/$test_id/diff.txt"
5886
5900
  local files_file="$adversarial_dir/$test_id/files.txt"
5887
- echo "$diff_content" > "$diff_file"
5888
- echo "$changed_files" > "$files_file"
5901
+ printf '%s\n' "$diff_content" > "$diff_file"
5902
+ printf '%s\n' "$changed_files" > "$files_file"
5889
5903
 
5890
5904
  # Build adversarial prompt -- use heredoc with quoted delimiter to prevent
5891
5905
  # shell variable expansion in diff content (fixes #78)
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.37.3"
10
+ __version__ = "6.37.5"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
package/dashboard/auth.py CHANGED
@@ -59,17 +59,29 @@ _SCOPE_HIERARCHY = {
59
59
  if OIDC_ENABLED:
60
60
  import logging as _logging
61
61
  _logger = _logging.getLogger("loki.auth")
62
+ _pyjwt_available = False
63
+ try:
64
+ import jwt as _pyjwt_check # noqa: F401
65
+ from jwt import PyJWKClient as _PyJWKClient_check # noqa: F401
66
+ _pyjwt_available = True
67
+ except ImportError:
68
+ _pyjwt_available = False
69
+
62
70
  if OIDC_SKIP_SIGNATURE_VERIFY:
63
71
  _logger.critical(
64
72
  "OIDC/SSO signature verification DISABLED (LOKI_OIDC_SKIP_SIGNATURE_VERIFY=true). "
65
73
  "This is INSECURE and allows forged JWTs. Only use for local testing. "
66
74
  "For production, install PyJWT + cryptography and remove this env var."
67
75
  )
76
+ elif _pyjwt_available:
77
+ _logger.info(
78
+ "OIDC/SSO enabled with PyJWT cryptographic signature verification (RS256/RS384/RS512)."
79
+ )
68
80
  else:
69
- _logger.warning(
70
- "OIDC/SSO enabled (EXPERIMENTAL). Claims-based validation only -- "
71
- "JWT signatures are NOT cryptographically verified. Install PyJWT + "
72
- "cryptography for production signature verification."
81
+ _logger.critical(
82
+ "OIDC/SSO enabled but PyJWT is NOT installed. Tokens will be REJECTED "
83
+ "unless LOKI_OIDC_SKIP_SIGNATURE_VERIFY=true is set. "
84
+ "Install PyJWT + cryptography: pip install PyJWT cryptography"
73
85
  )
74
86
 
75
87
  # OIDC JWKS cache (issuer URL -> (keys_dict, fetch_timestamp))
@@ -526,14 +538,18 @@ def validate_oidc_token(token_str: str) -> Optional[dict]:
526
538
  "issuer": decoded.get("iss"),
527
539
  }
528
540
  except ImportError:
529
- # PyJWT not installed -- fall through to claims-only path with loud warning
530
- _warning_msg = (
531
- "WARNING: OIDC JWT signatures are NOT cryptographically verified. "
532
- "Install PyJWT with 'pip install PyJWT cryptography' for production use."
541
+ # PyJWT not installed -- only allow claims-only path if explicitly opted in
542
+ if not OIDC_SKIP_SIGNATURE_VERIFY:
543
+ _auth_logger.error(
544
+ "OIDC token rejected: PyJWT not installed and "
545
+ "LOKI_OIDC_SKIP_SIGNATURE_VERIFY is not set. "
546
+ "Install PyJWT: pip install PyJWT cryptography"
547
+ )
548
+ return None
549
+ _auth_logger.warning(
550
+ "PyJWT not installed -- using claims-only validation "
551
+ "(LOKI_OIDC_SKIP_SIGNATURE_VERIFY=true). This is INSECURE."
533
552
  )
534
- _auth_logger.warning(_warning_msg)
535
- # Also print to stderr so operators notice even without log config
536
- print(_warning_msg, file=sys.stderr)
537
553
  except Exception as exc:
538
554
  _auth_logger.error("PyJWT signature verification failed: %s", exc)
539
555
  return None
@@ -30,7 +30,7 @@ from fastapi import (
30
30
  )
31
31
  from fastapi.middleware.cors import CORSMiddleware
32
32
  from fastapi.responses import JSONResponse, PlainTextResponse
33
- from pydantic import BaseModel, Field
33
+ from pydantic import BaseModel, Field, field_validator
34
34
  from sqlalchemy import select, update, delete
35
35
  from sqlalchemy.ext.asyncio import AsyncSession
36
36
  from sqlalchemy.orm import selectinload
@@ -75,6 +75,27 @@ def _safe_int_env(name: str, default: int) -> int:
75
75
  return default
76
76
 
77
77
 
78
+ def _safe_json_read(path: _Path, default: Any = None) -> Any:
79
+ """Read a JSON file with retry on partial/corrupt data from concurrent writes."""
80
+ for attempt in range(2):
81
+ try:
82
+ text = path.read_text(encoding="utf-8", errors="replace")
83
+ return json.loads(text)
84
+ except json.JSONDecodeError:
85
+ if attempt == 0:
86
+ time.sleep(0.1)
87
+ continue
88
+ return default
89
+ except (OSError, IOError):
90
+ return default
91
+ return default
92
+
93
+
94
+ def _safe_read_text(path: _Path) -> str:
95
+ """Read a text file with UTF-8 encoding, replacing non-UTF-8 bytes."""
96
+ return open(path, encoding="utf-8", errors="replace").read()
97
+
98
+
78
99
  # ---------------------------------------------------------------------------
79
100
  # Simple in-memory rate limiter for control endpoints
80
101
  # ---------------------------------------------------------------------------
@@ -98,12 +119,12 @@ class _RateLimiter:
98
119
  for k in empty_keys:
99
120
  del self._calls[k]
100
121
 
101
- # Evict oldest keys if max_keys exceeded
122
+ # Evict least-recently-accessed keys if max_keys exceeded
102
123
  if len(self._calls) > self._max_keys:
103
- # Sort by oldest timestamp, remove oldest keys
124
+ # Sort by last-access time (most recent timestamp), evict least recent
104
125
  sorted_keys = sorted(
105
126
  self._calls.items(),
106
- key=lambda x: min(x[1]) if x[1] else 0
127
+ key=lambda x: max(x[1]) if x[1] else 0
107
128
  )
108
129
  keys_to_remove = len(self._calls) - self._max_keys
109
130
  for k, _ in sorted_keys[:keys_to_remove]:
@@ -123,12 +144,30 @@ logger = logging.getLogger(__name__)
123
144
 
124
145
 
125
146
  # Pydantic schemas for API
147
+ def _sanitize_text_field(value: str) -> str:
148
+ """Strip/reject control characters from text fields."""
149
+ import unicodedata
150
+ # Remove control characters (except common whitespace like space)
151
+ cleaned = "".join(
152
+ ch for ch in value if unicodedata.category(ch)[0] != "C" or ch in (" ",)
153
+ )
154
+ cleaned = cleaned.strip()
155
+ if not cleaned:
156
+ raise ValueError("Field must not be empty after removing control characters")
157
+ return cleaned
158
+
159
+
126
160
  class ProjectCreate(BaseModel):
127
161
  """Schema for creating a project."""
128
162
  name: str = Field(..., min_length=1, max_length=255)
129
163
  description: Optional[str] = None
130
164
  prd_path: Optional[str] = None
131
165
 
166
+ @field_validator("name")
167
+ @classmethod
168
+ def validate_name(cls, v: str) -> str:
169
+ return _sanitize_text_field(v)
170
+
132
171
 
133
172
  class ProjectUpdate(BaseModel):
134
173
  """Schema for updating a project."""
@@ -165,6 +204,11 @@ class TaskCreate(BaseModel):
165
204
  parent_task_id: Optional[int] = None
166
205
  estimated_duration: Optional[int] = None
167
206
 
207
+ @field_validator("title")
208
+ @classmethod
209
+ def validate_title(cls, v: str) -> str:
210
+ return _sanitize_text_field(v)
211
+
168
212
 
169
213
  class TaskUpdate(BaseModel):
170
214
  """Schema for updating a task."""
@@ -315,7 +359,7 @@ async def _push_loki_state_loop() -> None:
315
359
  if mtime != last_mtime:
316
360
  last_mtime = mtime
317
361
  try:
318
- raw = json.loads(state_file.read_text())
362
+ raw = _safe_json_read(state_file, {})
319
363
  # Transform to StatusResponse-compatible format
320
364
  agents_list = raw.get("agents", [])
321
365
  running_agents = len(agents_list) if isinstance(agents_list, list) else 0
@@ -515,10 +559,10 @@ async def get_status() -> StatusResponse:
515
559
  pending_tasks = 0
516
560
  running_agents = 0
517
561
 
518
- # Read dashboard state
562
+ # Read dashboard state (with retry for concurrent writes)
519
563
  if state_file.exists():
520
564
  try:
521
- state = json.loads(state_file.read_text())
565
+ state = _safe_json_read(state_file, {})
522
566
  phase = state.get("phase", "")
523
567
  iteration = state.get("iteration", 0)
524
568
  complexity = state.get("complexity", "standard")
@@ -561,7 +605,7 @@ async def get_status() -> StatusResponse:
561
605
  # Also check session.json for skill-invoked sessions
562
606
  if not running and session_file.exists():
563
607
  try:
564
- sd = json.loads(session_file.read_text())
608
+ sd = _safe_json_read(session_file, {})
565
609
  if sd.get("status") == "running":
566
610
  running = True
567
611
  except (json.JSONDecodeError, KeyError):
@@ -673,21 +717,41 @@ async def get_status() -> StatusResponse:
673
717
  @app.get("/api/projects", response_model=list[ProjectResponse])
674
718
  async def list_projects(
675
719
  status: Optional[str] = Query(None),
720
+ limit: int = Query(default=50, ge=1, le=500),
721
+ offset: int = Query(default=0, ge=0),
676
722
  db: AsyncSession = Depends(get_db),
677
723
  ) -> list[ProjectResponse]:
678
- """List all projects."""
679
- query = select(Project).options(selectinload(Project.tasks))
724
+ """List projects with pagination. Does not eager-load tasks for efficiency."""
725
+ from sqlalchemy import func as sa_func
726
+
727
+ query = select(Project)
680
728
  if status:
681
729
  query = query.where(Project.status == status)
682
- query = query.order_by(Project.created_at.desc())
730
+ query = query.order_by(Project.created_at.desc()).offset(offset).limit(limit)
683
731
 
684
732
  result = await db.execute(query)
685
733
  projects = result.scalars().all()
686
734
 
735
+ # Batch-fetch task counts instead of N+1 eager loading
736
+ project_ids = [p.id for p in projects]
687
737
  response = []
738
+ if project_ids:
739
+ count_query = (
740
+ select(
741
+ Task.project_id,
742
+ sa_func.count().label("total"),
743
+ sa_func.count().filter(Task.status == TaskStatus.DONE).label("done"),
744
+ )
745
+ .where(Task.project_id.in_(project_ids))
746
+ .group_by(Task.project_id)
747
+ )
748
+ count_result = await db.execute(count_query)
749
+ counts = {row.project_id: (row.total, row.done) for row in count_result}
750
+ else:
751
+ counts = {}
752
+
688
753
  for project in projects:
689
- task_count = len(project.tasks)
690
- completed_count = len([t for t in project.tasks if t.status == TaskStatus.DONE])
754
+ total, done = counts.get(project.id, (0, 0))
691
755
  response.append(
692
756
  ProjectResponse(
693
757
  id=project.id,
@@ -697,8 +761,8 @@ async def list_projects(
697
761
  status=project.status,
698
762
  created_at=project.created_at,
699
763
  updated_at=project.updated_at,
700
- task_count=task_count,
701
- completed_task_count=completed_count,
764
+ task_count=total,
765
+ completed_task_count=done,
702
766
  )
703
767
  )
704
768
  return response
@@ -1066,12 +1130,29 @@ async def create_task(
1066
1130
  Task.project_id == task.project_id
1067
1131
  )
1068
1132
  )
1069
- if not result.scalar_one_or_none():
1133
+ parent = result.scalar_one_or_none()
1134
+ if not parent:
1070
1135
  raise HTTPException(
1071
1136
  status_code=400,
1072
1137
  detail="Parent task not found or belongs to different project"
1073
1138
  )
1074
1139
 
1140
+ # Detect circular reference: walk parent chain
1141
+ visited = set()
1142
+ current_parent_id = task.parent_task_id
1143
+ while current_parent_id is not None:
1144
+ if current_parent_id in visited:
1145
+ raise HTTPException(
1146
+ status_code=422,
1147
+ detail="Circular reference detected in parent task chain"
1148
+ )
1149
+ visited.add(current_parent_id)
1150
+ parent_result = await db.execute(
1151
+ select(Task.parent_task_id).where(Task.id == current_parent_id)
1152
+ )
1153
+ row = parent_result.scalar_one_or_none()
1154
+ current_parent_id = row if row else None
1155
+
1075
1156
  db_task = Task(
1076
1157
  project_id=task.project_id,
1077
1158
  title=task.title,
@@ -1192,6 +1273,15 @@ async def delete_task(
1192
1273
  })
1193
1274
 
1194
1275
 
1276
+ # Valid status transitions for task state machine
1277
+ _TASK_STATE_MACHINE: dict[TaskStatus, set[TaskStatus]] = {
1278
+ TaskStatus.BACKLOG: {TaskStatus.PENDING},
1279
+ TaskStatus.PENDING: {TaskStatus.IN_PROGRESS},
1280
+ TaskStatus.IN_PROGRESS: {TaskStatus.REVIEW, TaskStatus.DONE},
1281
+ TaskStatus.REVIEW: {TaskStatus.DONE, TaskStatus.IN_PROGRESS},
1282
+ }
1283
+
1284
+
1195
1285
  @app.post("/api/tasks/{task_id}/move", response_model=TaskResponse, dependencies=[Depends(auth.require_scope("control"))])
1196
1286
  async def move_task(
1197
1287
  task_id: int,
@@ -1208,6 +1298,18 @@ async def move_task(
1208
1298
  raise HTTPException(status_code=404, detail="Task not found")
1209
1299
 
1210
1300
  old_status = task.status
1301
+
1302
+ # Validate status transition
1303
+ if move.status != old_status:
1304
+ allowed = _TASK_STATE_MACHINE.get(old_status, set())
1305
+ if move.status not in allowed:
1306
+ raise HTTPException(
1307
+ status_code=422,
1308
+ detail=f"Invalid status transition: {old_status.value} -> {move.status.value}. "
1309
+ f"Allowed transitions from {old_status.value}: "
1310
+ f"{', '.join(s.value for s in allowed) if allowed else 'none'}",
1311
+ )
1312
+
1211
1313
  task.status = move.status
1212
1314
  task.position = move.position
1213
1315
 
@@ -1252,8 +1354,9 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
1252
1354
  # proxy access logs -- configure log sanitization for /ws in production.
1253
1355
  # FastAPI Depends() is not supported on @app.websocket() routes.
1254
1356
 
1255
- # Rate limit WebSocket connections by IP
1256
- client_ip = websocket.client.host if websocket.client else "unknown"
1357
+ # Rate limit WebSocket connections by IP (use unique key when client info unavailable)
1358
+ import uuid as _uuid
1359
+ client_ip = websocket.client.host if websocket.client else f"ws-{_uuid.uuid4().hex}"
1257
1360
  if not _read_limiter.check(f"ws_{client_ip}"):
1258
1361
  await websocket.close(code=1008) # Policy Violation
1259
1362
  return
@@ -1447,7 +1550,13 @@ async def sync_registry():
1447
1550
  if not _read_limiter.check("registry_sync"):
1448
1551
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
1449
1552
 
1450
- result = registry.sync_registry_with_discovery()
1553
+ try:
1554
+ result = await asyncio.wait_for(
1555
+ asyncio.get_event_loop().run_in_executor(None, registry.sync_registry_with_discovery),
1556
+ timeout=30.0,
1557
+ )
1558
+ except asyncio.TimeoutError:
1559
+ raise HTTPException(status_code=504, detail="Registry sync timed out after 30 seconds")
1451
1560
  return {
1452
1561
  "added": result["added"],
1453
1562
  "updated": result["updated"],
@@ -1815,11 +1924,14 @@ async def list_episodes(limit: int = Query(default=50, ge=1, le=1000)):
1815
1924
  except Exception:
1816
1925
  pass
1817
1926
 
1818
- # Fallback to JSON files
1927
+ # Fallback to JSON files -- use heapq to avoid sorting all files
1928
+ import heapq
1819
1929
  ep_dir = _get_loki_dir() / "memory" / "episodic"
1820
1930
  episodes = []
1821
1931
  if ep_dir.exists():
1822
- files = sorted(ep_dir.glob("*.json"), reverse=True)[:limit]
1932
+ all_files = ep_dir.glob("*.json")
1933
+ # nlargest by filename (timestamps sort lexicographically) avoids full sort
1934
+ files = heapq.nlargest(limit, all_files, key=lambda f: f.name)
1823
1935
  for f in files:
1824
1936
  try:
1825
1937
  episodes.append(json.loads(f.read_text()))
@@ -3525,7 +3637,7 @@ async def get_logs(lines: int = 100, token: Optional[dict] = Depends(auth.get_cu
3525
3637
  file_mtime = datetime.fromtimestamp(log_file.stat().st_mtime, tz=timezone.utc).strftime(
3526
3638
  "%Y-%m-%dT%H:%M:%S"
3527
3639
  )
3528
- content = log_file.read_text()
3640
+ content = _safe_read_text(log_file)
3529
3641
  for raw_line in content.strip().split("\n")[-lines:]:
3530
3642
  timestamp = ""
3531
3643
  level = "info"
@@ -4310,7 +4422,7 @@ async def get_app_runner_logs(lines: int = Query(default=100, ge=1, le=1000)):
4310
4422
  if not log_file.exists():
4311
4423
  return {"lines": []}
4312
4424
  try:
4313
- all_lines = log_file.read_text().splitlines()
4425
+ all_lines = _safe_read_text(log_file).splitlines()
4314
4426
  return {"lines": all_lines[-lines:]}
4315
4427
  except OSError:
4316
4428
  return {"lines": []}
@@ -4573,25 +4685,16 @@ async def serve_index():
4573
4685
  if os.path.isfile(index_path):
4574
4686
  return FileResponse(index_path, media_type="text/html")
4575
4687
 
4576
- # Return helpful error message
4577
- return HTMLResponse(
4578
- content="""
4579
- <html>
4580
- <head><title>Loki Dashboard</title></head>
4581
- <body style="font-family: system-ui; padding: 40px; max-width: 600px; margin: 0 auto;">
4582
- <h1>Dashboard Frontend Not Found</h1>
4583
- <p>The dashboard API is running, but the frontend files were not found.</p>
4584
- <p>To fix this, run:</p>
4585
- <pre style="background: #f5f5f5; padding: 15px; border-radius: 5px;">cd dashboard-ui && npm run build</pre>
4586
- <p><strong>API Endpoints:</strong></p>
4587
- <ul>
4588
- <li><a href="/health">/health</a> - Health check</li>
4589
- <li><a href="/docs">/docs</a> - API documentation</li>
4590
- </ul>
4591
- </body>
4592
- </html>
4593
- """,
4594
- status_code=200
4688
+ # Return 503 when frontend files are not found
4689
+ return JSONResponse(
4690
+ content={
4691
+ "error": "dashboard_frontend_not_found",
4692
+ "detail": "The dashboard API is running, but the frontend files were not found. "
4693
+ "Run: cd dashboard-ui && npm run build",
4694
+ "api_docs": "/docs",
4695
+ "health": "/health",
4696
+ },
4697
+ status_code=503,
4595
4698
  )
4596
4699
 
4597
4700
 
@@ -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.37.3
5
+ **Version:** v6.37.5
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.37.3'
60
+ __version__ = '6.37.5'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.37.3",
3
+ "version": "6.37.5",
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",