loki-mode 6.18.0 → 6.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.18.0
6
+ # Loki Mode v6.20.0
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.18.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
270
+ **v6.20.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.18.0
1
+ 6.20.0
package/autonomy/loki CHANGED
@@ -420,7 +420,7 @@ show_help() {
420
420
  echo " checkpoint|cp Save/restore session checkpoints"
421
421
  echo " projects Multi-project registry management"
422
422
  echo " audit [cmd] Agent audit log and quality scanning (log|scan)"
423
- echo " review [dir] Run quality gates on any project (standalone, no AI needed)"
423
+ echo " review [opts] Standalone code review with quality gates (diff, staged, PR, files)"
424
424
  echo " optimize Optimize prompts based on session history"
425
425
  echo " enterprise Enterprise feature management (tokens, OIDC)"
426
426
  echo " metrics Prometheus/OpenMetrics metrics from dashboard"
@@ -435,6 +435,7 @@ show_help() {
435
435
  echo " agent [cmd] Agent type dispatch (list|info|run|start|review)"
436
436
  echo " remote [PRD] Start remote session (connect from phone/browser, Claude Pro/Max)"
437
437
  echo " trigger Event-driven autonomous execution (schedules, webhooks)"
438
+ echo " failover [cmd] Cross-provider auto-failover (status|--enable|--test|--chain)"
438
439
  echo " plan <PRD> Dry-run PRD analysis: complexity, cost, and execution plan"
439
440
  echo " version Show version"
440
441
  echo " help Show this help"
@@ -8299,6 +8300,225 @@ for line in sys.stdin:
8299
8300
  esac
8300
8301
  }
8301
8302
 
8303
+ # Cross-provider auto-failover management (v6.19.0)
8304
+ cmd_failover() {
8305
+ local loki_dir="${TARGET_DIR:-.}/.loki"
8306
+ local failover_file="$loki_dir/state/failover.json"
8307
+ local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8308
+
8309
+ if [ $# -eq 0 ]; then
8310
+ # No args: show current failover config and health
8311
+ if [ ! -f "$failover_file" ]; then
8312
+ echo -e "${BOLD}Cross-Provider Auto-Failover${NC}"
8313
+ echo ""
8314
+ echo "Status: DISABLED"
8315
+ echo ""
8316
+ echo "Enable with: loki failover --enable"
8317
+ echo "Or set: LOKI_FAILOVER=true loki start"
8318
+ return 0
8319
+ fi
8320
+
8321
+ echo -e "${BOLD}Cross-Provider Auto-Failover${NC}"
8322
+ echo ""
8323
+ python3 << 'PYEOF'
8324
+ import json, os
8325
+ fpath = os.path.join(os.environ.get('TARGET_DIR', '.'), '.loki/state/failover.json')
8326
+ try:
8327
+ with open(fpath) as f:
8328
+ d = json.load(f)
8329
+ status = "ENABLED" if d.get("enabled") else "DISABLED"
8330
+ print(f"Status: {status}")
8331
+ print(f"Chain: {' -> '.join(d.get('chain', []))}")
8332
+ print(f"Current provider: {d.get('currentProvider', 'unknown')}")
8333
+ print(f"Primary provider: {d.get('primaryProvider', 'unknown')}")
8334
+ print(f"Failover count: {d.get('failoverCount', 0)}")
8335
+ last = d.get('lastFailover')
8336
+ print(f"Last failover: {last if last else 'never'}")
8337
+ print()
8338
+ print("Health Status:")
8339
+ health = d.get('healthCheck', {})
8340
+ for provider in d.get('chain', []):
8341
+ h = health.get(provider, 'unknown')
8342
+ indicator = '[OK]' if h == 'healthy' else '[--]' if h == 'unknown' else '[!!]'
8343
+ print(f" {indicator} {provider}: {h}")
8344
+ except FileNotFoundError:
8345
+ print("Failover not initialized. Run: loki failover --enable")
8346
+ except Exception as e:
8347
+ print(f"Error reading failover state: {e}")
8348
+ PYEOF
8349
+ return 0
8350
+ fi
8351
+
8352
+ while [[ $# -gt 0 ]]; do
8353
+ case "$1" in
8354
+ --enable)
8355
+ mkdir -p "$loki_dir/state"
8356
+ local current_provider
8357
+ current_provider=$(cat "$loki_dir/state/provider" 2>/dev/null || echo "claude")
8358
+ local chain="${LOKI_FAILOVER_CHAIN:-claude,codex,gemini}"
8359
+
8360
+ cat > "$failover_file" << FEOF
8361
+ {
8362
+ "enabled": true,
8363
+ "chain": $(printf '%s' "$chain" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip().split(",")))' 2>/dev/null || echo '["claude","codex","gemini"]'),
8364
+ "currentProvider": "$current_provider",
8365
+ "primaryProvider": "$current_provider",
8366
+ "lastFailover": null,
8367
+ "failoverCount": 0,
8368
+ "healthCheck": {}
8369
+ }
8370
+ FEOF
8371
+ echo -e "${GREEN}Failover enabled${NC}"
8372
+ echo "Chain: $chain"
8373
+ echo "Primary: $current_provider"
8374
+ shift
8375
+ ;;
8376
+ --disable)
8377
+ if [ -f "$failover_file" ]; then
8378
+ python3 -c "
8379
+ import json
8380
+ with open('$failover_file') as f: d = json.load(f)
8381
+ d['enabled'] = False
8382
+ with open('$failover_file', 'w') as f: json.dump(d, f, indent=2)
8383
+ " 2>/dev/null
8384
+ echo -e "${YELLOW}Failover disabled${NC}"
8385
+ else
8386
+ echo "Failover not initialized."
8387
+ fi
8388
+ shift
8389
+ ;;
8390
+ --chain)
8391
+ shift
8392
+ local new_chain="$1"
8393
+ if [ -z "$new_chain" ]; then
8394
+ echo -e "${RED}Error: --chain requires a comma-separated list of providers${NC}"
8395
+ return 1
8396
+ fi
8397
+ # Validate each provider in chain
8398
+ local IFS=','
8399
+ for p in $new_chain; do
8400
+ case "$p" in
8401
+ claude|codex|gemini|cline|aider) ;;
8402
+ *) echo -e "${RED}Error: invalid provider '$p' in chain${NC}"; return 1 ;;
8403
+ esac
8404
+ done
8405
+ unset IFS
8406
+
8407
+ if [ ! -f "$failover_file" ]; then
8408
+ echo -e "${RED}Error: failover not initialized. Run: loki failover --enable${NC}"
8409
+ return 1
8410
+ fi
8411
+
8412
+ python3 -c "
8413
+ import json
8414
+ with open('$failover_file') as f: d = json.load(f)
8415
+ d['chain'] = '$new_chain'.split(',')
8416
+ with open('$failover_file', 'w') as f: json.dump(d, f, indent=2)
8417
+ " 2>/dev/null
8418
+ echo "Failover chain updated: $new_chain"
8419
+ shift
8420
+ ;;
8421
+ --test)
8422
+ echo -e "${BOLD}Testing all providers in failover chain...${NC}"
8423
+ echo ""
8424
+ # Source provider loader for health checks
8425
+ if [ -f "$script_dir/../providers/loader.sh" ]; then
8426
+ source "$script_dir/../providers/loader.sh"
8427
+ fi
8428
+
8429
+ local chain_providers="claude,codex,gemini"
8430
+ if [ -f "$failover_file" ]; then
8431
+ chain_providers=$(python3 -c "import json; print(','.join(json.load(open('$failover_file')).get('chain', ['claude','codex','gemini'])))" 2>/dev/null || echo "claude,codex,gemini")
8432
+ fi
8433
+
8434
+ local IFS=','
8435
+ local all_pass=true
8436
+ for p in $chain_providers; do
8437
+ printf " %-10s " "$p:"
8438
+
8439
+ # Check CLI installed
8440
+ local cli_name="$p"
8441
+ if command -v "$cli_name" &>/dev/null; then
8442
+ local cli_version
8443
+ cli_version=$("$cli_name" --version 2>/dev/null | head -1 || echo "unknown")
8444
+ printf "CLI %-20s " "[$cli_version]"
8445
+ else
8446
+ printf "CLI %-20s " "[NOT INSTALLED]"
8447
+ echo -e "${RED}FAIL${NC}"
8448
+ all_pass=false
8449
+ continue
8450
+ fi
8451
+
8452
+ # Check API key
8453
+ local has_key=false
8454
+ case "$p" in
8455
+ claude) [ -n "${ANTHROPIC_API_KEY:-}" ] && has_key=true ;;
8456
+ codex) [ -n "${OPENAI_API_KEY:-}" ] && has_key=true ;;
8457
+ gemini) [ -n "${GOOGLE_API_KEY:-${GEMINI_API_KEY:-}}" ] && has_key=true ;;
8458
+ cline|aider) has_key=true ;; # Key check varies
8459
+ esac
8460
+
8461
+ if [ "$has_key" = "true" ]; then
8462
+ printf "Key [OK] "
8463
+ echo -e "${GREEN}PASS${NC}"
8464
+ else
8465
+ printf "Key [MISSING] "
8466
+ echo -e "${RED}FAIL${NC}"
8467
+ all_pass=false
8468
+ fi
8469
+ done
8470
+ unset IFS
8471
+
8472
+ echo ""
8473
+ if [ "$all_pass" = "true" ]; then
8474
+ echo -e "${GREEN}All providers healthy${NC}"
8475
+ else
8476
+ echo -e "${YELLOW}Some providers unavailable - failover chain may be limited${NC}"
8477
+ fi
8478
+ shift
8479
+ ;;
8480
+ --reset)
8481
+ if [ -f "$failover_file" ]; then
8482
+ rm -f "$failover_file"
8483
+ echo "Failover state reset to defaults."
8484
+ else
8485
+ echo "No failover state to reset."
8486
+ fi
8487
+ shift
8488
+ ;;
8489
+ --help|-h)
8490
+ echo -e "${BOLD}loki failover${NC} - Cross-provider auto-failover management (v6.19.0)"
8491
+ echo ""
8492
+ echo "Usage: loki failover [options]"
8493
+ echo ""
8494
+ echo "Options:"
8495
+ echo " (no args) Show failover status and health"
8496
+ echo " --enable Enable auto-failover"
8497
+ echo " --disable Disable auto-failover"
8498
+ echo " --chain X,Y,Z Set failover chain (e.g., claude,codex,gemini)"
8499
+ echo " --test Test all providers in chain"
8500
+ echo " --reset Reset failover state to defaults"
8501
+ echo " --help, -h Show this help"
8502
+ echo ""
8503
+ echo "Environment:"
8504
+ echo " LOKI_FAILOVER=true Enable failover at startup"
8505
+ echo " LOKI_FAILOVER_CHAIN=X,Y,Z Set default chain"
8506
+ echo ""
8507
+ echo "When a rate limit (429/529) is detected, Loki automatically switches"
8508
+ echo "to the next healthy provider in the chain. After each successful"
8509
+ echo "iteration on a fallback provider, the primary is health-checked and"
8510
+ echo "execution switches back when it recovers."
8511
+ return 0
8512
+ ;;
8513
+ *)
8514
+ echo -e "${RED}Unknown option: $1${NC}"
8515
+ echo "Run 'loki failover --help' for usage."
8516
+ return 1
8517
+ ;;
8518
+ esac
8519
+ done
8520
+ }
8521
+
8302
8522
  # Dry-run PRD analysis and cost estimation (v6.18.0)
8303
8523
  cmd_plan() {
8304
8524
  local prd_file=""
@@ -8966,6 +9186,9 @@ main() {
8966
9186
  plan)
8967
9187
  cmd_plan "$@"
8968
9188
  ;;
9189
+ failover)
9190
+ cmd_failover "$@"
9191
+ ;;
8969
9192
  version|--version|-v)
8970
9193
  cmd_version
8971
9194
  ;;
@@ -8982,224 +9205,477 @@ main() {
8982
9205
  ;;
8983
9206
  esac
8984
9207
  }
8985
-
8986
- # Standalone project review - run quality gates on any codebase (v6.14.0)
9208
+ # Standalone code review - diff-based quality gates (v6.20.0)
8987
9209
  cmd_review() {
8988
- local target_dir="."
8989
- local json_output=""
8990
- local verbose=""
9210
+ local review_staged=false
9211
+ local review_pr=""
9212
+ local review_since=""
9213
+ local review_target=""
9214
+ local review_format="text"
9215
+ local review_severity="all"
8991
9216
 
8992
9217
  while [[ $# -gt 0 ]]; do
8993
9218
  case "$1" in
8994
- --json) json_output="true"; shift ;;
8995
- --verbose|-v) verbose="true"; shift ;;
8996
9219
  --help|-h)
8997
- echo -e "${BOLD}loki review${NC} - Run quality gates on any project (standalone)"
9220
+ echo -e "${BOLD}loki review${NC} - Standalone code review with quality gates"
8998
9221
  echo ""
8999
- echo "Usage: loki review [directory] [options]"
9222
+ echo "Usage: loki review [options] [file-or-dir]"
9000
9223
  echo ""
9001
- echo "Arguments:"
9002
- echo " directory Path to project (default: current directory)"
9224
+ echo "Reviews code changes using static analysis, security scanning,"
9225
+ echo "style checks, and anti-pattern detection. No AI needed."
9226
+ echo ""
9227
+ echo "Sources (default: uncommitted changes via git diff):"
9228
+ echo " --staged Review staged changes only"
9229
+ echo " --pr <number> Review a GitHub PR"
9230
+ echo " --since <commit> Review changes since a commit"
9231
+ echo " <file-or-dir> Review specific files or directory"
9003
9232
  echo ""
9004
9233
  echo "Options:"
9005
- echo " --json Machine-readable JSON output"
9006
- echo " --verbose, -v Show detailed output per gate"
9007
- echo " --help, -h Show this help"
9234
+ echo " --format json Output as JSON (for CI integration)"
9235
+ echo " --severity <level> Filter: critical, high, medium, low, info (shows level+above)"
9236
+ echo " --help, -h Show this help"
9008
9237
  echo ""
9009
- echo "Gates: project-type, lint, tests, security, dependencies, structure"
9010
- echo "Exit code: 0 if all pass, 1 if any fail"
9238
+ echo "Exit codes:"
9239
+ echo " 0 No HIGH or CRITICAL findings"
9240
+ echo " 1 HIGH severity findings present"
9241
+ echo " 2 CRITICAL severity findings present"
9242
+ echo ""
9243
+ echo "Examples:"
9244
+ echo " loki review # Review uncommitted changes"
9245
+ echo " loki review --staged # Review staged changes"
9246
+ echo " loki review --pr 42 # Review GitHub PR #42"
9247
+ echo " loki review src/ # Review all files in src/"
9248
+ echo " loki review --since HEAD~5 # Changes in last 5 commits"
9249
+ echo " loki review --format json # JSON output for CI"
9250
+ echo " loki review --severity high # Only HIGH+ findings"
9011
9251
  return 0
9012
9252
  ;;
9253
+ --staged) review_staged=true; shift ;;
9254
+ --pr)
9255
+ shift
9256
+ review_pr="${1:-}"
9257
+ if [ -z "$review_pr" ]; then
9258
+ echo -e "${RED}Error: --pr requires a PR number${NC}"
9259
+ return 1
9260
+ fi
9261
+ shift
9262
+ ;;
9263
+ --since)
9264
+ shift
9265
+ review_since="${1:-}"
9266
+ if [ -z "$review_since" ]; then
9267
+ echo -e "${RED}Error: --since requires a commit reference${NC}"
9268
+ return 1
9269
+ fi
9270
+ shift
9271
+ ;;
9272
+ --format)
9273
+ shift
9274
+ review_format="${1:-text}"
9275
+ shift
9276
+ ;;
9277
+ --severity)
9278
+ shift
9279
+ review_severity="${1:-all}"
9280
+ review_severity="$(echo "$review_severity" | tr '[:upper:]' '[:lower:]')"
9281
+ shift
9282
+ ;;
9013
9283
  -*) echo -e "${RED}Unknown option: $1${NC}"; return 1 ;;
9014
- *) target_dir="$1"; shift ;;
9284
+ *) review_target="$1"; shift ;;
9015
9285
  esac
9016
9286
  done
9017
9287
 
9018
- # Resolve and validate directory
9019
- target_dir="$(cd "$target_dir" 2>/dev/null && pwd)" || {
9020
- echo -e "${RED}Error: Directory not found: $target_dir${NC}"; return 1
9021
- }
9022
-
9023
- local project_type="unknown"
9024
- local results=() # gate:status pairs
9025
- local total_pass=0 total_warn=0 total_fail=0
9026
- local gate_details=()
9027
-
9028
- # Helper: record gate result
9029
- _review_gate() {
9030
- local gate="$1" status="$2" detail="${3:-}"
9031
- results+=("$gate:$status")
9032
- gate_details+=("$gate:$detail")
9033
- case "$status" in
9034
- PASS) total_pass=$((total_pass + 1)) ;;
9035
- WARN) total_warn=$((total_warn + 1)) ;;
9036
- FAIL) total_fail=$((total_fail + 1)) ;;
9288
+ # Severity level ordering (higher number = more severe)
9289
+ _review_sev_level() {
9290
+ case "$1" in
9291
+ INFO) echo 1 ;;
9292
+ LOW) echo 2 ;;
9293
+ MEDIUM) echo 3 ;;
9294
+ HIGH) echo 4 ;;
9295
+ CRITICAL) echo 5 ;;
9296
+ *) echo 0 ;;
9037
9297
  esac
9038
9298
  }
9039
9299
 
9040
- # Gate 1: Detect project type
9041
- if [ -f "$target_dir/package.json" ]; then
9042
- project_type="node"
9043
- elif [ -f "$target_dir/pyproject.toml" ] || [ -f "$target_dir/setup.py" ] || [ -f "$target_dir/requirements.txt" ]; then
9044
- project_type="python"
9045
- elif [ -f "$target_dir/go.mod" ]; then
9046
- project_type="go"
9047
- elif [ -f "$target_dir/Cargo.toml" ]; then
9048
- project_type="rust"
9049
- fi
9050
- if [ "$project_type" != "unknown" ]; then
9051
- _review_gate "project-type" "PASS" "Detected: $project_type"
9300
+ local min_sev=0
9301
+ case "$review_severity" in
9302
+ info) min_sev=1 ;;
9303
+ low) min_sev=2 ;;
9304
+ medium) min_sev=3 ;;
9305
+ high) min_sev=4 ;;
9306
+ critical) min_sev=5 ;;
9307
+ all) min_sev=0 ;;
9308
+ esac
9309
+
9310
+ # Gather code to review
9311
+ local diff_content=""
9312
+ local review_files=""
9313
+ local source_desc=""
9314
+
9315
+ if [ -n "$review_pr" ]; then
9316
+ # PR review via gh CLI
9317
+ if ! command -v gh &>/dev/null; then
9318
+ echo -e "${RED}Error: gh CLI required for PR review. Install: https://cli.github.com${NC}"
9319
+ return 1
9320
+ fi
9321
+ diff_content=$(gh pr diff "$review_pr" 2>&1) || {
9322
+ echo -e "${RED}Error: Could not fetch PR #${review_pr}: $diff_content${NC}"
9323
+ return 1
9324
+ }
9325
+ source_desc="PR #${review_pr}"
9326
+ elif [ -n "$review_since" ]; then
9327
+ diff_content=$(git diff "$review_since" 2>&1) || {
9328
+ echo -e "${RED}Error: Invalid commit reference: $review_since${NC}"
9329
+ return 1
9330
+ }
9331
+ source_desc="changes since $review_since"
9332
+ elif [ -n "$review_target" ]; then
9333
+ # File or directory target - get full contents
9334
+ if [ -d "$review_target" ]; then
9335
+ review_files=$(find "$review_target" -type f \
9336
+ \( -name "*.sh" -o -name "*.bash" -o -name "*.py" -o -name "*.js" -o -name "*.ts" \
9337
+ -o -name "*.jsx" -o -name "*.tsx" -o -name "*.go" -o -name "*.rs" -o -name "*.rb" \
9338
+ -o -name "*.java" -o -name "*.c" -o -name "*.cpp" -o -name "*.h" \) \
9339
+ -not -path "*/node_modules/*" -not -path "*/.git/*" \
9340
+ -not -path "*/vendor/*" -not -path "*/__pycache__/*" \
9341
+ -not -path "*/.venv/*" -not -path "*/target/*" 2>/dev/null)
9342
+ source_desc="directory $review_target"
9343
+ elif [ -f "$review_target" ]; then
9344
+ review_files="$review_target"
9345
+ source_desc="file $review_target"
9346
+ else
9347
+ echo -e "${RED}Error: Not found: $review_target${NC}"
9348
+ return 1
9349
+ fi
9350
+ elif [ "$review_staged" = true ]; then
9351
+ diff_content=$(git diff --cached 2>/dev/null)
9352
+ source_desc="staged changes"
9052
9353
  else
9053
- _review_gate "project-type" "WARN" "Could not detect project type"
9354
+ diff_content=$(git diff 2>/dev/null)
9355
+ source_desc="uncommitted changes"
9054
9356
  fi
9055
9357
 
9056
- # Gate 2: Lint check
9057
- local lint_output="" lint_status="PASS"
9058
- case "$project_type" in
9059
- node)
9060
- if [ -f "$target_dir/.eslintrc.js" ] || [ -f "$target_dir/.eslintrc.json" ] || [ -f "$target_dir/.eslintrc.yml" ] || [ -f "$target_dir/eslint.config.js" ] || [ -f "$target_dir/eslint.config.mjs" ]; then
9061
- lint_output=$(cd "$target_dir" && npx eslint . --max-warnings=0 2>&1) || lint_status="FAIL"
9062
- else
9063
- lint_output="No ESLint config found"; lint_status="WARN"
9064
- fi ;;
9065
- python)
9066
- if command -v ruff &>/dev/null; then
9067
- lint_output=$(cd "$target_dir" && ruff check . 2>&1) || lint_status="FAIL"
9068
- elif command -v pylint &>/dev/null; then
9069
- lint_output=$(cd "$target_dir" && pylint --recursive=y . 2>&1) || lint_status="FAIL"
9070
- else
9071
- lint_output="No linter available (install ruff or pylint)"; lint_status="WARN"
9072
- fi ;;
9073
- go)
9074
- if command -v golangci-lint &>/dev/null; then
9075
- lint_output=$(cd "$target_dir" && golangci-lint run 2>&1) || lint_status="FAIL"
9076
- else
9077
- lint_output=$(cd "$target_dir" && go vet ./... 2>&1) || lint_status="FAIL"
9078
- fi ;;
9079
- rust)
9080
- lint_output=$(cd "$target_dir" && cargo clippy -- -D warnings 2>&1) || lint_status="FAIL"
9081
- ;;
9082
- *) lint_output="Skipped (unknown project type)"; lint_status="WARN" ;;
9083
- esac
9084
- _review_gate "lint" "$lint_status" "$lint_output"
9085
-
9086
- # Gate 3: Tests
9087
- local test_output="" test_status="PASS"
9088
- case "$project_type" in
9089
- node)
9090
- if grep -q '"test"' "$target_dir/package.json" 2>/dev/null; then
9091
- test_output=$(cd "$target_dir" && npm test 2>&1) || test_status="FAIL"
9092
- else
9093
- test_output="No test script in package.json"; test_status="WARN"
9094
- fi ;;
9095
- python)
9096
- if command -v pytest &>/dev/null; then
9097
- test_output=$(cd "$target_dir" && pytest --tb=short 2>&1) || test_status="FAIL"
9098
- else
9099
- test_output="pytest not available"; test_status="WARN"
9100
- fi ;;
9101
- go) test_output=$(cd "$target_dir" && go test ./... 2>&1) || test_status="FAIL" ;;
9102
- rust) test_output=$(cd "$target_dir" && cargo test 2>&1) || test_status="FAIL" ;;
9103
- *) test_output="Skipped (unknown project type)"; test_status="WARN" ;;
9104
- esac
9105
- _review_gate "tests" "$test_status" "$test_output"
9106
-
9107
- # Gate 4: Security - grep for hardcoded secrets
9108
- local secret_output="" secret_status="PASS"
9109
- local secret_patterns='(API_KEY|SECRET_KEY|PASSWORD|TOKEN|PRIVATE_KEY)\s*[=:]\s*["\x27][A-Za-z0-9+/=_-]{8,}'
9110
- secret_output=$(grep -rEn "$secret_patterns" "$target_dir" \
9111
- --include="*.js" --include="*.ts" --include="*.py" --include="*.go" --include="*.rs" \
9112
- --include="*.jsx" --include="*.tsx" --include="*.java" --include="*.rb" \
9113
- --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=vendor \
9114
- --exclude-dir=__pycache__ --exclude-dir=.venv --exclude-dir=target 2>/dev/null) || true
9115
- if [ -n "$secret_output" ]; then
9116
- secret_status="FAIL"
9117
- secret_output="Potential hardcoded secrets found:
9118
- $secret_output"
9119
- else
9120
- secret_output="No hardcoded secrets detected"
9358
+ # Check if there is anything to review
9359
+ if [ -z "$diff_content" ] && [ -z "$review_files" ]; then
9360
+ if [ "$review_format" = "json" ]; then
9361
+ echo '{"source":"'"$source_desc"'","findings":[],"summary":{"critical":0,"high":0,"medium":0,"low":0,"info":0,"total":0},"exit_code":0}'
9362
+ else
9363
+ echo -e "${GREEN}No changes to review ($source_desc).${NC}"
9364
+ fi
9365
+ return 0
9121
9366
  fi
9122
- _review_gate "security" "$secret_status" "$secret_output"
9123
9367
 
9124
- # Gate 5: Dependency audit
9125
- local dep_output="" dep_status="PASS"
9126
- case "$project_type" in
9127
- node)
9128
- if [ -f "$target_dir/package-lock.json" ] || [ -f "$target_dir/yarn.lock" ]; then
9129
- dep_output=$(cd "$target_dir" && npm audit --production 2>&1) || dep_status="WARN"
9130
- else
9131
- dep_output="No lockfile found"; dep_status="WARN"
9132
- fi ;;
9133
- python)
9134
- if command -v pip-audit &>/dev/null; then
9135
- dep_output=$(cd "$target_dir" && pip-audit 2>&1) || dep_status="WARN"
9136
- else
9137
- dep_output="pip-audit not available"; dep_status="WARN"
9138
- fi ;;
9139
- *) dep_output="No dependency audit available for $project_type"; dep_status="WARN" ;;
9140
- esac
9141
- _review_gate "dependencies" "$dep_status" "$dep_output"
9142
-
9143
- # Gate 6: Structure check
9144
- local struct_output="" struct_status="PASS" struct_issues=()
9145
- [ ! -f "$target_dir/README.md" ] && [ ! -f "$target_dir/README.rst" ] && [ ! -f "$target_dir/README" ] && struct_issues+=("Missing README")
9146
- local file_count
9147
- file_count=$(find "$target_dir" -type f -not -path '*/.git/*' -not -path '*/node_modules/*' -not -path '*/vendor/*' -not -path '*/__pycache__/*' -not -path '*/.venv/*' -not -path '*/target/*' 2>/dev/null | wc -l | tr -d ' ')
9148
- [ "$file_count" -gt 5000 ] && struct_issues+=("Large project: $file_count files")
9149
- local huge_files
9150
- huge_files=$(find "$target_dir" -type f -size +1M -not -path '*/.git/*' -not -path '*/node_modules/*' -not -path '*/vendor/*' -not -path '*/target/*' 2>/dev/null | head -5)
9151
- [ -n "$huge_files" ] && struct_issues+=("Files >1MB found: $(echo "$huge_files" | wc -l | tr -d ' ')")
9152
- if [ ${#struct_issues[@]} -gt 0 ]; then
9153
- struct_status="WARN"
9154
- struct_output=$(printf '%s\n' "${struct_issues[@]}")
9368
+ # Build file content for non-diff reviews
9369
+ local all_content=""
9370
+ if [ -n "$review_files" ]; then
9371
+ while IFS= read -r f; do
9372
+ [ -z "$f" ] && continue
9373
+ all_content+="=== FILE: $f ===
9374
+ $(cat "$f" 2>/dev/null)
9375
+ "
9376
+ done <<< "$review_files"
9155
9377
  else
9156
- struct_output="README present, $file_count files, no oversized files"
9157
- fi
9158
- _review_gate "structure" "$struct_status" "$struct_output"
9159
-
9160
- # Output results
9161
- if [ -n "$json_output" ]; then
9162
- local gates_json="["
9163
- local first="true"
9164
- for r in "${results[@]}"; do
9165
- local gate="${r%%:*}" status="${r#*:}"
9166
- [ "$first" = "true" ] && first="" || gates_json+=","
9167
- gates_json+="{\"gate\":\"$gate\",\"status\":\"$status\"}"
9378
+ all_content="$diff_content"
9379
+ fi
9380
+
9381
+ # Findings array: "file|line|severity|category|finding|suggestion"
9382
+ local findings=()
9383
+ local has_critical=false
9384
+ local has_high=false
9385
+
9386
+ # Helper to add a finding
9387
+ _add_finding() {
9388
+ local file="$1" line="$2" sev="$3" cat="$4" finding="$5" suggestion="$6"
9389
+ local sev_num
9390
+ sev_num=$(_review_sev_level "$sev")
9391
+ if [ "$sev_num" -ge "$min_sev" ]; then
9392
+ findings+=("${file}|${line}|${sev}|${cat}|${finding}|${suggestion}")
9393
+ [ "$sev" = "CRITICAL" ] && has_critical=true || true
9394
+ [ "$sev" = "HIGH" ] && has_high=true || true
9395
+ fi
9396
+ }
9397
+
9398
+ # --- Gate 1: Static Analysis ---
9399
+ # Run shellcheck on shell files if available
9400
+ if command -v shellcheck &>/dev/null; then
9401
+ local shell_files=""
9402
+ if [ -n "$review_files" ]; then
9403
+ shell_files=$(echo "$review_files" | grep -E '\.(sh|bash)$' || true)
9404
+ elif [ -n "$diff_content" ]; then
9405
+ shell_files=$(echo "$diff_content" | grep -E '^\+\+\+ b/' | sed 's|^+++ b/||' | grep -E '\.(sh|bash)$' || true)
9406
+ fi
9407
+ if [ -n "$shell_files" ]; then
9408
+ while IFS= read -r sf; do
9409
+ [ -z "$sf" ] && continue
9410
+ [ ! -f "$sf" ] && continue
9411
+ local sc_out
9412
+ sc_out=$(shellcheck -f gcc "$sf" 2>/dev/null || true)
9413
+ while IFS= read -r sc_line; do
9414
+ [ -z "$sc_line" ] && continue
9415
+ local sc_file sc_lineno sc_sev sc_msg
9416
+ sc_file=$(echo "$sc_line" | cut -d: -f1)
9417
+ sc_lineno=$(echo "$sc_line" | cut -d: -f2)
9418
+ sc_sev=$(echo "$sc_line" | sed -n 's/.*: \(warning\|error\|note\|info\):.*/\1/p')
9419
+ sc_msg=$(echo "$sc_line" | sed 's/.*: \(warning\|error\|note\|info\): //')
9420
+ local mapped_sev="LOW"
9421
+ case "$sc_sev" in
9422
+ error) mapped_sev="HIGH" ;;
9423
+ warning) mapped_sev="MEDIUM" ;;
9424
+ *) mapped_sev="LOW" ;;
9425
+ esac
9426
+ _add_finding "$sc_file" "$sc_lineno" "$mapped_sev" "static-analysis" "$sc_msg" "Fix shellcheck finding"
9427
+ done <<< "$sc_out"
9428
+ done <<< "$shell_files"
9429
+ fi
9430
+ fi
9431
+
9432
+ # Run eslint if available on JS/TS files
9433
+ if command -v npx &>/dev/null; then
9434
+ local js_files=""
9435
+ if [ -n "$review_files" ]; then
9436
+ js_files=$(echo "$review_files" | grep -E '\.(js|ts|jsx|tsx)$' || true)
9437
+ elif [ -n "$diff_content" ]; then
9438
+ js_files=$(echo "$diff_content" | grep -E '^\+\+\+ b/' | sed 's|^+++ b/||' | grep -E '\.(js|ts|jsx|tsx)$' || true)
9439
+ fi
9440
+ if [ -n "$js_files" ]; then
9441
+ while IFS= read -r jsf; do
9442
+ [ -z "$jsf" ] && continue
9443
+ [ ! -f "$jsf" ] && continue
9444
+ if [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ]; then
9445
+ local eslint_out
9446
+ eslint_out=$(npx eslint --format compact "$jsf" 2>/dev/null || true)
9447
+ while IFS= read -r el; do
9448
+ [ -z "$el" ] && continue
9449
+ [[ "$el" == *"problem"* ]] && continue
9450
+ local el_file el_line el_msg
9451
+ el_file=$(echo "$el" | cut -d: -f1)
9452
+ el_line=$(echo "$el" | cut -d: -f2)
9453
+ el_msg=$(echo "$el" | sed 's/^[^)]*) //')
9454
+ local el_sev="LOW"
9455
+ [[ "$el" == *"Error"* ]] && el_sev="MEDIUM"
9456
+ _add_finding "$el_file" "$el_line" "$el_sev" "static-analysis" "$el_msg" "Fix eslint finding"
9457
+ done <<< "$eslint_out"
9458
+ fi
9459
+ done <<< "$js_files"
9460
+ fi
9461
+ fi
9462
+
9463
+ # --- Gate 2: Security Scan ---
9464
+ # Helper: scan for a pattern across files or diff content
9465
+ _review_scan() {
9466
+ local pattern="$1" sev="$2" cat="$3" finding="$4" suggestion="$5"
9467
+ if [ -n "$review_files" ]; then
9468
+ # Scan actual files with grep -Hn (file:line:content)
9469
+ local scan_files_arr=()
9470
+ while IFS= read -r sf; do
9471
+ [ -z "$sf" ] && continue
9472
+ [ -f "$sf" ] && scan_files_arr+=("$sf")
9473
+ done <<< "$review_files"
9474
+ [ ${#scan_files_arr[@]} -eq 0 ] && return
9475
+ while IFS= read -r match; do
9476
+ [ -z "$match" ] && continue
9477
+ local mf ml
9478
+ mf=$(echo "$match" | cut -d: -f1)
9479
+ ml=$(echo "$match" | cut -d: -f2)
9480
+ _add_finding "$mf" "$ml" "$sev" "$cat" "$finding" "$suggestion"
9481
+ done < <(grep -HnE "$pattern" "${scan_files_arr[@]}" 2>/dev/null || true)
9482
+ else
9483
+ # Scan diff content (line numbers are from the text blob)
9484
+ while IFS= read -r match; do
9485
+ [ -z "$match" ] && continue
9486
+ local ml
9487
+ ml=$(echo "$match" | cut -d: -f1)
9488
+ _add_finding "diff" "$ml" "$sev" "$cat" "$finding" "$suggestion"
9489
+ done < <(echo "$all_content" | grep -nE "$pattern" 2>/dev/null || true)
9490
+ fi
9491
+ }
9492
+
9493
+ # Hardcoded secrets
9494
+ local secret_patterns=(
9495
+ 'API_KEY\s*[=:]\s*["\x27][A-Za-z0-9+/=_-]{8,}'
9496
+ 'SECRET_KEY\s*[=:]\s*["\x27][A-Za-z0-9+/=_-]{8,}'
9497
+ 'PASSWORD\s*[=:]\s*["\x27][^\x27"]{4,}'
9498
+ 'PRIVATE_KEY\s*[=:]\s*["\x27][A-Za-z0-9+/=_-]{8,}'
9499
+ 'AWS_ACCESS_KEY_ID\s*[=:]\s*["\x27]AK[A-Z0-9]{18}'
9500
+ 'ghp_[A-Za-z0-9]{36}'
9501
+ 'sk-[A-Za-z0-9]{32,}'
9502
+ 'Bearer\s+[A-Za-z0-9._-]{20,}'
9503
+ )
9504
+ for pattern in "${secret_patterns[@]}"; do
9505
+ _review_scan "$pattern" "CRITICAL" "security" \
9506
+ "Potential hardcoded secret detected" \
9507
+ "Use environment variables or a secrets manager instead"
9508
+ done
9509
+
9510
+ # SQL injection patterns
9511
+ _review_scan '(SELECT|INSERT|UPDATE|DELETE|DROP)\s.*\+\s*(req\.|request\.|params\.|user)' \
9512
+ "HIGH" "security" \
9513
+ "Potential SQL injection: string concatenation in query" \
9514
+ "Use parameterized queries or prepared statements"
9515
+
9516
+ # eval/exec usage
9517
+ _review_scan '(^|\s)(eval|exec)\s*\(' \
9518
+ "HIGH" "security" \
9519
+ "Dangerous eval/exec usage detected" \
9520
+ "Avoid eval/exec with dynamic input; use safer alternatives"
9521
+
9522
+ # Unsafe deserialization
9523
+ _review_scan '(pickle\.loads?|yaml\.load\s*\()' \
9524
+ "HIGH" "security" \
9525
+ "Unsafe deserialization detected (pickle/yaml.load)" \
9526
+ "Use yaml.safe_load or avoid pickle with untrusted data"
9527
+
9528
+ # --- Gate 3: Code Style ---
9529
+ # Long functions (>100 lines heuristic from diff)
9530
+ if [ -n "$review_files" ]; then
9531
+ while IFS= read -r f; do
9532
+ [ -z "$f" ] && continue
9533
+ [ ! -f "$f" ] && continue
9534
+ local line_count
9535
+ line_count=$(wc -l < "$f" | tr -d ' ')
9536
+ if [ "$line_count" -gt 500 ]; then
9537
+ _add_finding "$f" "1" "MEDIUM" "style" \
9538
+ "File has $line_count lines (>500)" \
9539
+ "Consider breaking into smaller modules"
9540
+ fi
9541
+ # Check for very long lines
9542
+ local long_lines
9543
+ long_lines=$(awk 'length > 200 {count++} END {print count+0}' "$f" 2>/dev/null)
9544
+ if [ "$long_lines" -gt 5 ]; then
9545
+ _add_finding "$f" "1" "LOW" "style" \
9546
+ "$long_lines lines exceed 200 characters" \
9547
+ "Break long lines for readability"
9548
+ fi
9549
+ done <<< "$review_files"
9550
+ fi
9551
+
9552
+ # TODO/FIXME/HACK/XXX detection
9553
+ _review_scan '(TODO|FIXME|HACK|XXX):' "INFO" "style" \
9554
+ "TODO/FIXME/HACK/XXX comment marker found" \
9555
+ "Track in issue tracker instead of code comments"
9556
+
9557
+ # --- Gate 4: Anti-pattern Detection ---
9558
+ # console.log left in code (JS/TS)
9559
+ _review_scan 'console\.(log|debug|warn)\(' "LOW" "anti-pattern" \
9560
+ "console.log statement (debug artifact?)" \
9561
+ "Remove debug logging or use a proper logger"
9562
+
9563
+ # Catch bare except in Python
9564
+ _review_scan 'except\s*:' "MEDIUM" "anti-pattern" \
9565
+ "Bare except clause catches all exceptions" \
9566
+ "Catch specific exceptions (e.g., except ValueError)"
9567
+
9568
+ # Disabled SSL verification
9569
+ _review_scan '(verify\s*=\s*False|VERIFY_SSL\s*=\s*False|NODE_TLS_REJECT_UNAUTHORIZED.*0|rejectUnauthorized.*false)' \
9570
+ "HIGH" "anti-pattern" \
9571
+ "SSL verification disabled" \
9572
+ "Enable SSL verification in production"
9573
+
9574
+ # Hardcoded IP addresses (skip localhost/0.0.0.0/version patterns)
9575
+ _review_scan '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' \
9576
+ "LOW" "anti-pattern" \
9577
+ "Hardcoded IP address detected" \
9578
+ "Use configuration or DNS names instead"
9579
+
9580
+ # --- Tally results ---
9581
+ local count_critical=0 count_high=0 count_medium=0 count_low=0 count_info=0
9582
+ for f in "${findings[@]}"; do
9583
+ local sev
9584
+ sev=$(echo "$f" | cut -d'|' -f3)
9585
+ case "$sev" in
9586
+ CRITICAL) count_critical=$((count_critical + 1)) ;;
9587
+ HIGH) count_high=$((count_high + 1)) ;;
9588
+ MEDIUM) count_medium=$((count_medium + 1)) ;;
9589
+ LOW) count_low=$((count_low + 1)) ;;
9590
+ INFO) count_info=$((count_info + 1)) ;;
9591
+ esac
9592
+ done
9593
+ local count_total=${#findings[@]}
9594
+
9595
+ # Determine exit code
9596
+ local exit_code=0
9597
+ [ "$has_high" = true ] && exit_code=1 || true
9598
+ [ "$has_critical" = true ] && exit_code=2 || true
9599
+
9600
+ # --- Output ---
9601
+ if [ "$review_format" = "json" ]; then
9602
+ local json_findings="["
9603
+ local first=true
9604
+ for f in "${findings[@]}"; do
9605
+ local f_file f_line f_sev f_cat f_finding f_suggestion
9606
+ f_file=$(echo "$f" | cut -d'|' -f1)
9607
+ f_line=$(echo "$f" | cut -d'|' -f2)
9608
+ f_sev=$(echo "$f" | cut -d'|' -f3)
9609
+ f_cat=$(echo "$f" | cut -d'|' -f4)
9610
+ f_finding=$(echo "$f" | cut -d'|' -f5)
9611
+ f_suggestion=$(echo "$f" | cut -d'|' -f6)
9612
+ # Escape quotes for JSON
9613
+ f_finding=$(echo "$f_finding" | sed 's/"/\\"/g')
9614
+ f_suggestion=$(echo "$f_suggestion" | sed 's/"/\\"/g')
9615
+ f_file=$(echo "$f_file" | sed 's/"/\\"/g')
9616
+ [ "$first" = true ] && first=false || json_findings+=","
9617
+ json_findings+="{\"file\":\"$f_file\",\"line\":$f_line,\"severity\":\"$f_sev\",\"category\":\"$f_cat\",\"finding\":\"$f_finding\",\"suggestion\":\"$f_suggestion\"}"
9168
9618
  done
9169
- gates_json+="]"
9170
- printf '{"directory":"%s","project_type":"%s","pass":%d,"warn":%d,"fail":%d,"gates":%s}\n' \
9171
- "$target_dir" "$project_type" "$total_pass" "$total_warn" "$total_fail" "$gates_json"
9619
+ json_findings+="]"
9620
+ printf '{"source":"%s","findings":%s,"summary":{"critical":%d,"high":%d,"medium":%d,"low":%d,"info":%d,"total":%d},"exit_code":%d}\n' \
9621
+ "$source_desc" "$json_findings" "$count_critical" "$count_high" "$count_medium" "$count_low" "$count_info" "$count_total" "$exit_code"
9172
9622
  else
9173
- echo -e "${BOLD}Loki Review: $target_dir${NC}"
9174
- echo -e "Project type: ${CYAN}$project_type${NC}"
9623
+ echo -e "${BOLD}Loki Code Review${NC}"
9624
+ echo -e "Source: ${CYAN}$source_desc${NC}"
9175
9625
  echo "---"
9176
- for r in "${results[@]}"; do
9177
- local gate="${r%%:*}" status="${r#*:}"
9178
- case "$status" in
9179
- PASS) echo -e " ${GREEN}[PASS]${NC} $gate" ;;
9180
- WARN) echo -e " ${YELLOW}[WARN]${NC} $gate" ;;
9181
- FAIL) echo -e " ${RED}[FAIL]${NC} $gate" ;;
9182
- esac
9183
- done
9184
- echo "---"
9185
- echo -e "Results: ${GREEN}$total_pass passed${NC}, ${YELLOW}$total_warn warnings${NC}, ${RED}$total_fail failed${NC}"
9186
9626
 
9187
- if [ -n "$verbose" ]; then
9188
- echo ""
9189
- echo -e "${BOLD}Details:${NC}"
9190
- for d in "${gate_details[@]}"; do
9191
- local gate="${d%%:*}" detail="${d#*:}"
9192
- echo -e "\n${CYAN}[$gate]${NC}"
9193
- echo "$detail" | head -30
9627
+ if [ "$count_total" -eq 0 ]; then
9628
+ echo -e "${GREEN}No findings. Code looks clean.${NC}"
9629
+ else
9630
+ # Group by severity
9631
+ for sev_name in CRITICAL HIGH MEDIUM LOW INFO; do
9632
+ local printed_header=false
9633
+ for f in "${findings[@]}"; do
9634
+ local f_sev
9635
+ f_sev=$(echo "$f" | cut -d'|' -f3)
9636
+ [ "$f_sev" != "$sev_name" ] && continue
9637
+ if [ "$printed_header" = false ]; then
9638
+ local sev_color="$NC"
9639
+ case "$sev_name" in
9640
+ CRITICAL) sev_color="$RED" ;;
9641
+ HIGH) sev_color="$RED" ;;
9642
+ MEDIUM) sev_color="$YELLOW" ;;
9643
+ LOW) sev_color="$CYAN" ;;
9644
+ INFO) sev_color="$DIM" ;;
9645
+ esac
9646
+ echo ""
9647
+ echo -e "${sev_color}${BOLD}[$sev_name]${NC}"
9648
+ printed_header=true
9649
+ fi
9650
+ local f_file f_line f_cat f_finding f_suggestion
9651
+ f_file=$(echo "$f" | cut -d'|' -f1)
9652
+ f_line=$(echo "$f" | cut -d'|' -f2)
9653
+ f_cat=$(echo "$f" | cut -d'|' -f4)
9654
+ f_finding=$(echo "$f" | cut -d'|' -f5)
9655
+ f_suggestion=$(echo "$f" | cut -d'|' -f6)
9656
+ echo -e " ${DIM}$f_file:$f_line${NC} [$f_cat] $f_finding"
9657
+ echo -e " -> $f_suggestion"
9658
+ done
9194
9659
  done
9195
9660
  fi
9661
+
9662
+ echo ""
9663
+ echo "---"
9664
+ echo -e "Summary: ${RED}$count_critical critical${NC}, ${RED}$count_high high${NC}, ${YELLOW}$count_medium medium${NC}, ${CYAN}$count_low low${NC}, ${DIM}$count_info info${NC} ($count_total total)"
9665
+
9666
+ if [ "$exit_code" -eq 2 ]; then
9667
+ echo -e "${RED}CRITICAL findings detected. Review required.${NC}"
9668
+ elif [ "$exit_code" -eq 1 ]; then
9669
+ echo -e "${RED}HIGH severity findings detected.${NC}"
9670
+ else
9671
+ echo -e "${GREEN}No HIGH or CRITICAL findings.${NC}"
9672
+ fi
9196
9673
  fi
9197
9674
 
9198
- # Exit code: 1 if any failures
9199
- [ "$total_fail" -gt 0 ] && return 1
9200
- return 0
9675
+ return "$exit_code"
9201
9676
  }
9202
9677
 
9678
+
9203
9679
  # Worktree management (v6.7.0)
9204
9680
  cmd_worktree() {
9205
9681
  local subcommand="${1:-list}"
package/autonomy/run.sh CHANGED
@@ -6525,6 +6525,278 @@ calculate_wait() {
6525
6525
  echo $wait_time
6526
6526
  }
6527
6527
 
6528
+ #===============================================================================
6529
+ # Cross-Provider Auto-Failover (v6.19.0)
6530
+ #===============================================================================
6531
+
6532
+ # Initialize failover state file on startup
6533
+ init_failover_state() {
6534
+ local failover_dir="${TARGET_DIR:-.}/.loki/state"
6535
+ local failover_file="$failover_dir/failover.json"
6536
+
6537
+ # Only create if failover is enabled via env or config
6538
+ if [ "${LOKI_FAILOVER:-false}" != "true" ]; then
6539
+ return
6540
+ fi
6541
+
6542
+ mkdir -p "$failover_dir"
6543
+
6544
+ if [ ! -f "$failover_file" ]; then
6545
+ local chain="${LOKI_FAILOVER_CHAIN:-claude,codex,gemini}"
6546
+ local primary="${PROVIDER_NAME:-claude}"
6547
+ cat > "$failover_file" << FEOF
6548
+ {
6549
+ "enabled": true,
6550
+ "chain": $(printf '%s' "$chain" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip().split(",")))' 2>/dev/null || echo '["claude","codex","gemini"]'),
6551
+ "currentProvider": "$primary",
6552
+ "primaryProvider": "$primary",
6553
+ "lastFailover": null,
6554
+ "failoverCount": 0,
6555
+ "healthCheck": {
6556
+ "$primary": "healthy"
6557
+ }
6558
+ }
6559
+ FEOF
6560
+ log_info "Failover initialized: chain=$chain, primary=$primary"
6561
+ fi
6562
+ }
6563
+
6564
+ # Read failover config from state file
6565
+ # Sets: FAILOVER_ENABLED, FAILOVER_CHAIN, FAILOVER_CURRENT, FAILOVER_PRIMARY
6566
+ read_failover_config() {
6567
+ local failover_file="${TARGET_DIR:-.}/.loki/state/failover.json"
6568
+
6569
+ if [ ! -f "$failover_file" ]; then
6570
+ FAILOVER_ENABLED="false"
6571
+ return 1
6572
+ fi
6573
+
6574
+ eval "$(python3 << 'PYEOF' 2>/dev/null || echo 'FAILOVER_ENABLED=false'
6575
+ import json, os
6576
+ try:
6577
+ with open(os.path.join(os.environ.get('TARGET_DIR', '.'), '.loki/state/failover.json')) as f:
6578
+ d = json.load(f)
6579
+ chain = ','.join(d.get('chain', ['claude','codex','gemini']))
6580
+ print(f'FAILOVER_ENABLED={str(d.get("enabled", False)).lower()}')
6581
+ print(f'FAILOVER_CHAIN="{chain}"')
6582
+ print(f'FAILOVER_CURRENT="{d.get("currentProvider", "claude")}"')
6583
+ print(f'FAILOVER_PRIMARY="{d.get("primaryProvider", "claude")}"')
6584
+ print(f'FAILOVER_COUNT={d.get("failoverCount", 0)}')
6585
+ except Exception:
6586
+ print('FAILOVER_ENABLED=false')
6587
+ PYEOF
6588
+ )"
6589
+ }
6590
+
6591
+ # Update failover state file
6592
+ update_failover_state() {
6593
+ local key="$1"
6594
+ local value="$2"
6595
+ local failover_file="${TARGET_DIR:-.}/.loki/state/failover.json"
6596
+
6597
+ [ ! -f "$failover_file" ] && return 1
6598
+
6599
+ python3 << PYEOF 2>/dev/null || true
6600
+ import json, os
6601
+ fpath = os.path.join(os.environ.get('TARGET_DIR', '.'), '.loki/state/failover.json')
6602
+ try:
6603
+ with open(fpath) as f:
6604
+ d = json.load(f)
6605
+ key = "$key"
6606
+ value = "$value"
6607
+ # Handle type conversion
6608
+ if value == "null":
6609
+ d[key] = None
6610
+ elif value == "true":
6611
+ d[key] = True
6612
+ elif value == "false":
6613
+ d[key] = False
6614
+ elif value.isdigit():
6615
+ d[key] = int(value)
6616
+ else:
6617
+ d[key] = value
6618
+ with open(fpath, 'w') as f:
6619
+ json.dump(d, f, indent=2)
6620
+ except Exception:
6621
+ pass
6622
+ PYEOF
6623
+ }
6624
+
6625
+ # Update health status for a specific provider in failover.json
6626
+ update_failover_health() {
6627
+ local provider="$1"
6628
+ local status="$2" # healthy, unhealthy, unknown
6629
+ local failover_file="${TARGET_DIR:-.}/.loki/state/failover.json"
6630
+
6631
+ [ ! -f "$failover_file" ] && return 1
6632
+
6633
+ python3 << PYEOF 2>/dev/null || true
6634
+ import json, os
6635
+ fpath = os.path.join(os.environ.get('TARGET_DIR', '.'), '.loki/state/failover.json')
6636
+ try:
6637
+ with open(fpath) as f:
6638
+ d = json.load(f)
6639
+ if 'healthCheck' not in d:
6640
+ d['healthCheck'] = {}
6641
+ d['healthCheck']["$provider"] = "$status"
6642
+ with open(fpath, 'w') as f:
6643
+ json.dump(d, f, indent=2)
6644
+ except Exception:
6645
+ pass
6646
+ PYEOF
6647
+ }
6648
+
6649
+ # Check provider health: API key exists + CLI installed
6650
+ # Returns: 0 if healthy, 1 if unhealthy
6651
+ check_provider_health() {
6652
+ local provider="$1"
6653
+
6654
+ # Check CLI is installed
6655
+ case "$provider" in
6656
+ claude)
6657
+ command -v claude &>/dev/null || return 1
6658
+ [ -n "${ANTHROPIC_API_KEY:-}" ] || return 1
6659
+ ;;
6660
+ codex)
6661
+ command -v codex &>/dev/null || return 1
6662
+ [ -n "${OPENAI_API_KEY:-}" ] || return 1
6663
+ ;;
6664
+ gemini)
6665
+ command -v gemini &>/dev/null || return 1
6666
+ [ -n "${GOOGLE_API_KEY:-${GEMINI_API_KEY:-}}" ] || return 1
6667
+ ;;
6668
+ cline)
6669
+ command -v cline &>/dev/null || return 1
6670
+ ;;
6671
+ aider)
6672
+ command -v aider &>/dev/null || return 1
6673
+ ;;
6674
+ *)
6675
+ return 1
6676
+ ;;
6677
+ esac
6678
+
6679
+ return 0
6680
+ }
6681
+
6682
+ # Attempt failover to next healthy provider in chain
6683
+ # Called when rate limit is detected on current provider
6684
+ # Returns: 0 if failover succeeded, 1 if all providers exhausted
6685
+ attempt_provider_failover() {
6686
+ read_failover_config || return 1
6687
+
6688
+ if [ "$FAILOVER_ENABLED" != "true" ]; then
6689
+ return 1
6690
+ fi
6691
+
6692
+ local current="${FAILOVER_CURRENT:-${PROVIDER_NAME:-claude}}"
6693
+ log_warn "Failover: rate limit on $current, checking chain: $FAILOVER_CHAIN"
6694
+
6695
+ # Mark current as unhealthy
6696
+ update_failover_health "$current" "unhealthy"
6697
+
6698
+ # Walk the chain looking for the next healthy provider
6699
+ local IFS=','
6700
+ local found_current=false
6701
+ local tried_wrap=false
6702
+
6703
+ # Two passes: first from current position to end, then from start to current
6704
+ for provider in $FAILOVER_CHAIN $FAILOVER_CHAIN; do
6705
+ if [ "$provider" = "$current" ]; then
6706
+ if [ "$found_current" = "true" ]; then
6707
+ # We've wrapped around, all exhausted
6708
+ break
6709
+ fi
6710
+ found_current=true
6711
+ continue
6712
+ fi
6713
+
6714
+ [ "$found_current" != "true" ] && continue
6715
+
6716
+ # Check if this provider is healthy
6717
+ if check_provider_health "$provider"; then
6718
+ log_info "Failover: switching from $current to $provider"
6719
+
6720
+ # Load the new provider config
6721
+ local provider_dir
6722
+ provider_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/providers"
6723
+ if [ -f "$provider_dir/$provider.sh" ]; then
6724
+ source "$provider_dir/$provider.sh"
6725
+ fi
6726
+
6727
+ # Update state
6728
+ update_failover_state "currentProvider" "$provider"
6729
+ update_failover_state "lastFailover" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
6730
+ update_failover_state "failoverCount" "$((FAILOVER_COUNT + 1))"
6731
+ update_failover_health "$provider" "healthy"
6732
+
6733
+ # Update runtime provider vars
6734
+ PROVIDER_NAME="$provider"
6735
+
6736
+ emit_event_json "provider_failover" \
6737
+ "from=$current" \
6738
+ "to=$provider" \
6739
+ "reason=rate_limit" \
6740
+ "iteration=$ITERATION_COUNT" 2>/dev/null || true
6741
+
6742
+ log_info "Failover: now using $provider (failover #$((FAILOVER_COUNT + 1)))"
6743
+ return 0
6744
+ else
6745
+ log_debug "Failover: $provider is unhealthy, skipping"
6746
+ update_failover_health "$provider" "unhealthy"
6747
+ fi
6748
+ done
6749
+
6750
+ log_warn "Failover: all providers in chain exhausted, falling back to retry"
6751
+ return 1
6752
+ }
6753
+
6754
+ # Check if primary provider has recovered after running on a fallback
6755
+ # Called after each successful iteration when on a non-primary provider
6756
+ # Returns: 0 if switched back to primary, 1 if still on fallback
6757
+ check_primary_recovery() {
6758
+ read_failover_config || return 1
6759
+
6760
+ if [ "$FAILOVER_ENABLED" != "true" ]; then
6761
+ return 1
6762
+ fi
6763
+
6764
+ local current="${FAILOVER_CURRENT:-${PROVIDER_NAME:-claude}}"
6765
+ local primary="${FAILOVER_PRIMARY:-claude}"
6766
+
6767
+ # Already on primary
6768
+ if [ "$current" = "$primary" ]; then
6769
+ return 1
6770
+ fi
6771
+
6772
+ # Check if primary is healthy again
6773
+ if check_provider_health "$primary"; then
6774
+ log_info "Failover: primary provider $primary appears healthy, switching back"
6775
+
6776
+ # Load primary provider config
6777
+ local provider_dir
6778
+ provider_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/providers"
6779
+ if [ -f "$provider_dir/$primary.sh" ]; then
6780
+ source "$provider_dir/$primary.sh"
6781
+ fi
6782
+
6783
+ update_failover_state "currentProvider" "$primary"
6784
+ update_failover_health "$primary" "healthy"
6785
+
6786
+ PROVIDER_NAME="$primary"
6787
+
6788
+ emit_event_json "provider_recovery" \
6789
+ "from=$current" \
6790
+ "to=$primary" \
6791
+ "iteration=$ITERATION_COUNT" 2>/dev/null || true
6792
+
6793
+ log_info "Failover: recovered to primary provider $primary"
6794
+ return 0
6795
+ fi
6796
+
6797
+ return 1
6798
+ }
6799
+
6528
6800
  #===============================================================================
6529
6801
  # Rate Limit Detection
6530
6802
  #===============================================================================
@@ -8145,6 +8417,9 @@ run_autonomous() {
8145
8417
  load_state
8146
8418
  local retry=$RETRY_COUNT
8147
8419
 
8420
+ # Initialize Cross-Provider Failover (v6.19.0)
8421
+ init_failover_state
8422
+
8148
8423
  # Initialize Completion Council (v5.25.0)
8149
8424
  if type council_init &>/dev/null; then
8150
8425
  council_init "$prd_path"
@@ -8784,6 +9059,9 @@ if __name__ == "__main__":
8784
9059
  log_warn "Council will evaluate at next check interval (every ${COUNCIL_CHECK_INTERVAL:-5} iterations)"
8785
9060
  fi
8786
9061
 
9062
+ # Cross-provider failover: check if primary has recovered (v6.19.0)
9063
+ check_primary_recovery 2>/dev/null || true
9064
+
8787
9065
  # SUCCESS exit - continue IMMEDIATELY to next iteration (no wait!)
8788
9066
  log_step "Starting next iteration..."
8789
9067
  ((retry++))
@@ -8801,6 +9079,13 @@ if __name__ == "__main__":
8801
9079
  local wait_time
8802
9080
 
8803
9081
  if [ $rate_limit_wait -gt 0 ]; then
9082
+ # Cross-provider failover (v6.19.0): try switching provider before waiting
9083
+ if attempt_provider_failover 2>/dev/null; then
9084
+ log_info "Failover succeeded - retrying immediately with ${PROVIDER_NAME}"
9085
+ ((retry++))
9086
+ continue
9087
+ fi
9088
+
8804
9089
  wait_time=$rate_limit_wait
8805
9090
  local human_time=$(format_duration $wait_time)
8806
9091
  log_warn "Rate limit detected! Waiting until reset (~$human_time)..."
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.18.0"
10
+ __version__ = "6.20.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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.18.0
5
+ **Version:** v6.20.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.18.0'
60
+ __version__ = '6.20.0'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.18.0",
3
+ "version": "6.20.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",