loki-mode 6.62.0 → 6.62.1

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.62.0
6
+ # Loki Mode v6.62.1
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.62.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
270
+ **v6.62.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.62.0
1
+ 6.62.1
@@ -31,6 +31,7 @@
31
31
 
32
32
  set -euo pipefail
33
33
 
34
+ # shellcheck disable=SC2034
34
35
  HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
35
36
 
36
37
  # Load project-specific hook config if it exists
@@ -38,14 +39,22 @@ load_migration_hook_config() {
38
39
  local codebase_path="${1:-.}"
39
40
  local config_file="${codebase_path}/.loki/migration-hooks.yaml"
40
41
 
41
- # Defaults
42
+ # Defaults (used by other functions in this file; SC2034 disabled for globals-via-function pattern)
43
+ # shellcheck disable=SC2034
42
44
  HOOK_POST_FILE_EDIT_ENABLED=true
45
+ # shellcheck disable=SC2034
43
46
  HOOK_POST_STEP_ENABLED=true
47
+ # shellcheck disable=SC2034
44
48
  HOOK_PRE_PHASE_GATE_ENABLED=true
49
+ # shellcheck disable=SC2034
45
50
  HOOK_ON_AGENT_STOP_ENABLED=true
51
+ # shellcheck disable=SC2034
46
52
  HOOK_POST_FILE_EDIT_ACTION="run_tests"
53
+ # shellcheck disable=SC2034
47
54
  HOOK_POST_FILE_EDIT_ON_FAILURE="block_and_rollback"
55
+ # shellcheck disable=SC2034
48
56
  HOOK_POST_STEP_ON_FAILURE="reject_completion"
57
+ # shellcheck disable=SC2034
49
58
  HOOK_ON_AGENT_STOP_ON_FAILURE="force_continue"
50
59
 
51
60
  if [[ -f "$config_file" ]] && command -v python3 &>/dev/null; then
@@ -15,14 +15,19 @@
15
15
  # JSON with normalized fields: provider, number, title, body, labels, author, url, created_at
16
16
  #===============================================================================
17
17
 
18
- # Colors (safe to re-source)
18
+ # Colors (safe to re-source; used by scripts that source this file)
19
+ # shellcheck disable=SC2034
19
20
  RED='\033[0;31m'
21
+ # shellcheck disable=SC2034
20
22
  GREEN='\033[0;32m'
23
+ # shellcheck disable=SC2034
21
24
  YELLOW='\033[1;33m'
25
+ # shellcheck disable=SC2034
22
26
  CYAN='\033[0;36m'
23
27
  NC='\033[0m'
24
28
 
25
- # Supported issue providers
29
+ # Supported issue providers (exported for sourcing scripts)
30
+ # shellcheck disable=SC2034
26
31
  ISSUE_PROVIDERS=("github" "gitlab" "jira" "azure_devops")
27
32
 
28
33
  # Detect issue provider from a URL or reference
package/autonomy/run.sh CHANGED
@@ -183,7 +183,7 @@ if [[ -z "${LOKI_RUNNING_FROM_TEMP:-}" ]] && [[ "${BASH_SOURCE[0]}" == "${0}" ]]
183
183
  cp "${BASH_SOURCE[0]}" "$TEMP_SCRIPT"
184
184
  chmod 700 "$TEMP_SCRIPT"
185
185
  # BUG-XC-011: Set trap BEFORE exec so the temp file gets cleaned up
186
- trap "rm -f '$TEMP_SCRIPT'" EXIT
186
+ trap 'rm -f "$TEMP_SCRIPT"' EXIT
187
187
  export LOKI_RUNNING_FROM_TEMP=1
188
188
  export LOKI_ORIGINAL_SCRIPT_DIR="$SCRIPT_DIR"
189
189
  export LOKI_ORIGINAL_PROJECT_DIR="$PROJECT_DIR"
@@ -508,6 +508,13 @@ mapping = {
508
508
  'model.fast': 'LOKI_MODEL_FAST',
509
509
  'notify.slack': 'LOKI_SLACK_WEBHOOK',
510
510
  'notify.discord': 'LOKI_DISCORD_WEBHOOK',
511
+ 'provider': 'LOKI_PROVIDER',
512
+ 'issue.provider': 'LOKI_ISSUE_PROVIDER',
513
+ 'blind_validation': 'LOKI_BLIND_VALIDATION',
514
+ 'adversarial_testing': 'LOKI_ADVERSARIAL_TESTING',
515
+ 'spawn_timeout': 'LOKI_SPAWN_TIMEOUT',
516
+ 'spawn_retries': 'LOKI_SPAWN_RETRIES',
517
+ 'budget': 'LOKI_BUDGET_LIMIT',
511
518
  }
512
519
  for key, env_var in mapping.items():
513
520
  # Try nested dict lookup first, then flat key, then underscore variant
@@ -2108,8 +2115,10 @@ create_worktree() {
2108
2115
  git -C "$TARGET_DIR" worktree add "$worktree_path" -b "$branch_name" 2>/dev/null && wt_exit=0 || \
2109
2116
  { git -C "$TARGET_DIR" worktree add "$worktree_path" "$branch_name" 2>/dev/null && wt_exit=0; }
2110
2117
  else
2111
- # Track main branch
2112
- git -C "$TARGET_DIR" worktree add "$worktree_path" main 2>/dev/null && wt_exit=0 || \
2118
+ # BUG-PAR-001: Testing/docs worktrees use -b parallel-<stream> main (not bare main checkout)
2119
+ # This avoids "already checked out" errors and keeps each worktree on its own branch
2120
+ git -C "$TARGET_DIR" worktree add "$worktree_path" -b "parallel-${stream_name}" main 2>/dev/null && wt_exit=0 || \
2121
+ { git -C "$TARGET_DIR" worktree add "$worktree_path" "parallel-${stream_name}" 2>/dev/null && wt_exit=0; } || \
2113
2122
  { git -C "$TARGET_DIR" worktree add "$worktree_path" HEAD 2>/dev/null && wt_exit=0; }
2114
2123
  fi
2115
2124
 
@@ -2164,8 +2173,11 @@ remove_worktree() {
2164
2173
 
2165
2174
  # Remove worktree (with safety check for rm -rf)
2166
2175
  git -C "$TARGET_DIR" worktree remove "$worktree_path" --force 2>/dev/null || {
2167
- # Safety check: only rm -rf if path looks like a worktree (contains .git or is under TARGET_DIR)
2168
- if [[ -n "$worktree_path" && "$worktree_path" != "/" && "$worktree_path" == "$TARGET_DIR"* ]]; then
2176
+ # BUG-PAR-005: Safety check uses dirname with trailing / to prevent prefix-match false positives
2177
+ # e.g. TARGET_DIR=/foo/bar must not match /foo/bar-other
2178
+ local parent_dir
2179
+ parent_dir="$(dirname "$TARGET_DIR")/"
2180
+ if [[ -n "$worktree_path" && "$worktree_path" != "/" && "$worktree_path" == "${parent_dir}"* ]]; then
2169
2181
  rm -rf "$worktree_path" 2>/dev/null
2170
2182
  else
2171
2183
  log_warn "Skipping unsafe rm -rf for path: $worktree_path"
@@ -2198,7 +2210,11 @@ spawn_worktree_session() {
2198
2210
  done
2199
2211
 
2200
2212
  if [ "$active_count" -ge "$MAX_PARALLEL_SESSIONS" ]; then
2201
- log_warn "Max parallel sessions reached ($MAX_PARALLEL_SESSIONS). Waiting..."
2213
+ # BUG-PAR-014: Max-sessions rejection queues spawn for retry
2214
+ log_warn "Max parallel sessions reached ($MAX_PARALLEL_SESSIONS). Queuing $stream_name for retry."
2215
+ mkdir -p "${TARGET_DIR:-.}/.loki/signals"
2216
+ echo "{\"stream\":\"$stream_name\",\"task\":\"$(echo "$task_prompt" | head -c 200)\",\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
2217
+ > "${TARGET_DIR:-.}/.loki/signals/SPAWN_QUEUED_${stream_name}"
2202
2218
  return 1
2203
2219
  fi
2204
2220
 
@@ -2249,19 +2265,31 @@ spawn_worktree_session() {
2249
2265
 
2250
2266
  # Completion signaling (v6.7.0)
2251
2267
  if [ $_wt_exit -eq 0 ]; then
2252
- # Commit any uncommitted work
2253
- git -C "$worktree_path" add -A 2>/dev/null
2268
+ # BUG-PAR-006: git add excludes .env, *.key, *.pem, credentials*
2269
+ git -C "$worktree_path" add -A \
2270
+ ':!.env' ':!*.key' ':!*.pem' ':!credentials*' 2>/dev/null
2254
2271
  git -C "$worktree_path" commit -m "feat($stream_name): worktree work complete" 2>/dev/null || true
2255
- # Signal merge readiness to main orchestrator
2272
+ # BUG-PAR-008: Signal files written atomically (temp + mv)
2256
2273
  mkdir -p "${TARGET_DIR:-.}/.loki/signals"
2257
- cat > "${TARGET_DIR:-.}/.loki/signals/MERGE_REQUESTED_${stream_name}" <<EOSIG
2274
+ local _sig_tmp
2275
+ _sig_tmp=$(mktemp "${TARGET_DIR:-.}/.loki/signals/.tmp.XXXXXX") || true
2276
+ cat > "$_sig_tmp" <<EOSIG
2258
2277
  {"stream":"$stream_name","branch":"$(git -C "$worktree_path" branch --show-current 2>/dev/null)","worktree":"$worktree_path","timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","exit_code":$_wt_exit}
2259
2278
  EOSIG
2279
+ mv "$_sig_tmp" "${TARGET_DIR:-.}/.loki/signals/MERGE_REQUESTED_${stream_name}" 2>/dev/null || \
2280
+ cp "$_sig_tmp" "${TARGET_DIR:-.}/.loki/signals/MERGE_REQUESTED_${stream_name}" 2>/dev/null
2281
+ rm -f "$_sig_tmp" 2>/dev/null
2260
2282
  echo "WORKTREE_COMPLETE: $stream_name" >> "$log_file"
2261
2283
  else
2284
+ # BUG-PAR-008: Signal files written atomically (temp + mv)
2262
2285
  mkdir -p "${TARGET_DIR:-.}/.loki/signals"
2286
+ local _fail_tmp
2287
+ _fail_tmp=$(mktemp "${TARGET_DIR:-.}/.loki/signals/.tmp.XXXXXX") || true
2263
2288
  echo "{\"stream\":\"$stream_name\",\"status\":\"failed\",\"exit_code\":$_wt_exit,\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
2264
- > "${TARGET_DIR:-.}/.loki/signals/WORKTREE_FAILED_${stream_name}"
2289
+ > "$_fail_tmp"
2290
+ mv "$_fail_tmp" "${TARGET_DIR:-.}/.loki/signals/WORKTREE_FAILED_${stream_name}" 2>/dev/null || \
2291
+ cp "$_fail_tmp" "${TARGET_DIR:-.}/.loki/signals/WORKTREE_FAILED_${stream_name}" 2>/dev/null
2292
+ rm -f "$_fail_tmp" 2>/dev/null
2265
2293
  fi
2266
2294
  ) &
2267
2295
 
@@ -2284,9 +2312,12 @@ merge_worktree() {
2284
2312
  return 1
2285
2313
  fi
2286
2314
 
2315
+ # BUG-PAR-013: Signal file parsing falls back to jq when python3 unavailable
2287
2316
  local branch worktree_path
2288
- branch=$(python3 -c "import json; print(json.load(open('$signal_file'))['branch'])" 2>/dev/null)
2289
- worktree_path=$(python3 -c "import json; print(json.load(open('$signal_file'))['worktree'])" 2>/dev/null)
2317
+ branch=$(python3 -c "import json; print(json.load(open('$signal_file'))['branch'])" 2>/dev/null) || \
2318
+ branch=$(jq -r '.branch' "$signal_file" 2>/dev/null) || true
2319
+ worktree_path=$(python3 -c "import json; print(json.load(open('$signal_file'))['worktree'])" 2>/dev/null) || \
2320
+ worktree_path=$(jq -r '.worktree' "$signal_file" 2>/dev/null) || true
2290
2321
 
2291
2322
  if [ -z "$branch" ]; then
2292
2323
  log_error "Could not determine branch for: $stream_name"
@@ -2295,9 +2326,16 @@ merge_worktree() {
2295
2326
 
2296
2327
  log_step "Merging worktree: $stream_name (branch: $branch)"
2297
2328
 
2298
- # Merge into current branch
2329
+ # BUG-PAR-009: Verify git checkout main before merge
2299
2330
  local current_branch
2300
2331
  current_branch=$(git -C "${TARGET_DIR:-.}" branch --show-current 2>/dev/null)
2332
+ if [ "$current_branch" != "main" ]; then
2333
+ log_info "Switching to main before merge (was on: $current_branch)"
2334
+ if ! git -C "${TARGET_DIR:-.}" checkout main 2>/dev/null; then
2335
+ log_error "Failed to checkout main for merge: $stream_name"
2336
+ return 1
2337
+ fi
2338
+ fi
2301
2339
 
2302
2340
  if git -C "${TARGET_DIR:-.}" merge --no-ff "$branch" -m "merge($stream_name): auto-merge from parallel worktree" 2>&1; then
2303
2341
  log_info "Merge successful: $stream_name"
@@ -2440,50 +2478,51 @@ Output ONLY the resolved file content with no conflict markers. No explanations.
2440
2478
  }
2441
2479
 
2442
2480
  # Merge a completed feature branch (with AI conflict resolution)
2481
+ # BUG-PAR-011: Not in a subshell -- uses git -C instead of cd
2482
+ # BUG-PAR-003: Strips feature- prefix to avoid feature/feature-auth double-prefix
2443
2483
  merge_feature() {
2444
2484
  local feature="$1"
2445
- local branch="feature/$feature"
2485
+ # BUG-PAR-003: Strip feature- prefix if present to avoid double-prefix (feature/feature-auth)
2486
+ local clean_feature="${feature#feature-}"
2487
+ local branch="feature/$clean_feature"
2446
2488
 
2447
- log_step "Merging feature: $feature"
2489
+ log_step "Merging feature: $clean_feature"
2448
2490
 
2449
- (
2450
- cd "$TARGET_DIR" || exit 1
2451
-
2452
- # Ensure we're on main
2453
- git checkout main 2>/dev/null
2491
+ # BUG-PAR-011: Ensure we're on main using git -C (no subshell)
2492
+ git -C "$TARGET_DIR" checkout main 2>/dev/null
2454
2493
 
2455
- # Attempt merge with no-ff for clear history
2456
- if git merge "$branch" --no-ff -m "feat: Merge $feature" 2>/dev/null; then
2457
- log_info "Merged cleanly: $feature"
2494
+ # Attempt merge with no-ff for clear history
2495
+ if git -C "$TARGET_DIR" merge "$branch" --no-ff -m "feat: Merge $clean_feature" 2>/dev/null; then
2496
+ log_info "Merged cleanly: $clean_feature"
2497
+ else
2498
+ # Merge has conflicts - try AI resolution
2499
+ log_warn "Merge conflicts detected - attempting AI resolution"
2500
+
2501
+ if resolve_conflicts_with_ai "$clean_feature"; then
2502
+ # AI resolved conflicts, commit the merge
2503
+ git -C "$TARGET_DIR" commit -m "feat: Merge $clean_feature (AI-resolved conflicts)"
2504
+ audit_agent_action "git_commit" "Committed changes" "merge=$clean_feature,resolution=ai"
2505
+ log_info "Merged with AI conflict resolution: $clean_feature"
2458
2506
  else
2459
- # Merge has conflicts - try AI resolution
2460
- log_warn "Merge conflicts detected - attempting AI resolution"
2461
-
2462
- if resolve_conflicts_with_ai "$feature"; then
2463
- # AI resolved conflicts, commit the merge
2464
- git commit -m "feat: Merge $feature (AI-resolved conflicts)"
2465
- audit_agent_action "git_commit" "Committed changes" "merge=$feature,resolution=ai"
2466
- log_info "Merged with AI conflict resolution: $feature"
2467
- else
2468
- # AI resolution failed, abort merge
2469
- log_error "AI conflict resolution failed: $feature"
2470
- git merge --abort 2>/dev/null || true
2471
- return 1
2472
- fi
2507
+ # AI resolution failed, abort merge
2508
+ log_error "AI conflict resolution failed: $clean_feature"
2509
+ git -C "$TARGET_DIR" merge --abort 2>/dev/null || true
2510
+ return 1
2473
2511
  fi
2512
+ fi
2474
2513
 
2475
- # Remove signal
2476
- rm -f ".loki/signals/MERGE_REQUESTED_$feature"
2514
+ # Remove signal
2515
+ rm -f "$TARGET_DIR/.loki/signals/MERGE_REQUESTED_$feature"
2477
2516
 
2478
- # Remove worktree
2479
- remove_worktree "feature-$feature"
2517
+ # Remove worktree
2518
+ remove_worktree "feature-$clean_feature"
2480
2519
 
2481
- # Delete branch
2482
- git branch -d "$branch" 2>/dev/null || true
2520
+ # Delete branch
2521
+ git -C "$TARGET_DIR" branch -d "$branch" 2>/dev/null || true
2483
2522
 
2484
- # Signal for docs update
2485
- touch ".loki/signals/DOCS_NEEDED"
2486
- )
2523
+ # Signal for docs update
2524
+ mkdir -p "$TARGET_DIR/.loki/signals"
2525
+ touch "$TARGET_DIR/.loki/signals/DOCS_NEEDED"
2487
2526
  }
2488
2527
 
2489
2528
  # Initialize parallel workflow streams
@@ -2525,7 +2564,9 @@ spawn_feature_stream() {
2525
2564
  local task_description="$2"
2526
2565
 
2527
2566
  # Check worktree limit
2528
- local worktree_count=$(git -C "$TARGET_DIR" worktree list 2>/dev/null | wc -l)
2567
+ # BUG-PAR-012: Worktree count subtracts 1 for main (git worktree list includes main)
2568
+ local worktree_count_raw=$(git -C "$TARGET_DIR" worktree list 2>/dev/null | wc -l)
2569
+ local worktree_count=$((worktree_count_raw > 0 ? worktree_count_raw - 1 : 0))
2529
2570
  if [ "$worktree_count" -ge "$MAX_WORKTREES" ]; then
2530
2571
  log_warn "Max worktrees reached ($MAX_WORKTREES). Queuing feature: $feature_name"
2531
2572
  return 1
@@ -2594,12 +2635,37 @@ run_parallel_orchestrator() {
2594
2635
 
2595
2636
  # Main orchestrator loop
2596
2637
  local running=true
2597
- trap 'running=false; cleanup_parallel_streams' INT TERM
2638
+ # BUG-PAR-004: Orchestrator trap handles SIGTERM properly (cleanup + restore global trap + exit)
2639
+ trap 'running=false; cleanup_parallel_streams; trap cleanup INT TERM; exit 0' TERM
2640
+ trap 'running=false; cleanup_parallel_streams' INT
2598
2641
 
2599
2642
  while $running; do
2600
2643
  # Check for merge requests
2601
2644
  check_merge_queue
2602
2645
 
2646
+ # BUG-PAR-014: Retry queued spawns when sessions free up
2647
+ local active_count=0
2648
+ for _qpid in "${WORKTREE_PIDS[@]}"; do
2649
+ if kill -0 "$_qpid" 2>/dev/null; then
2650
+ ((active_count++))
2651
+ fi
2652
+ done
2653
+ if [ "$active_count" -lt "$MAX_PARALLEL_SESSIONS" ]; then
2654
+ for queued_signal in "${TARGET_DIR:-.}"/.loki/signals/SPAWN_QUEUED_*; do
2655
+ [ -f "$queued_signal" ] || continue
2656
+ local queued_stream
2657
+ queued_stream=$(basename "$queued_signal" | sed 's/SPAWN_QUEUED_//')
2658
+ local queued_task=""
2659
+ queued_task=$(python3 -c "import json; print(json.load(open('$queued_signal'))['task'])" 2>/dev/null) || \
2660
+ queued_task=$(jq -r '.task' "$queued_signal" 2>/dev/null) || true
2661
+ if [ -n "$queued_task" ] && [ -n "${WORKTREE_PATHS[$queued_stream]:-}" ]; then
2662
+ rm -f "$queued_signal"
2663
+ spawn_worktree_session "$queued_stream" "$queued_task" && \
2664
+ log_info "Retried queued spawn: $queued_stream"
2665
+ fi
2666
+ done
2667
+ fi
2668
+
2603
2669
  # Check session health
2604
2670
  for stream in "${!WORKTREE_PIDS[@]}"; do
2605
2671
  local pid="${WORKTREE_PIDS[$stream]}"
@@ -2613,22 +2679,28 @@ run_parallel_orchestrator() {
2613
2679
  local state_file="$TARGET_DIR/.loki/state/parallel-streams.json"
2614
2680
  mkdir -p "$(dirname "$state_file")"
2615
2681
 
2682
+ # BUG-PAR-007: Empty worktree map produces valid JSON
2683
+ local worktree_json=""
2684
+ if [ ${#WORKTREE_PATHS[@]} -gt 0 ]; then
2685
+ worktree_json=$(for stream in "${!WORKTREE_PATHS[@]}"; do
2686
+ local path="${WORKTREE_PATHS[$stream]}"
2687
+ local pid="null"
2688
+ if [ -n "${WORKTREE_PIDS[$stream]+x}" ]; then
2689
+ pid="${WORKTREE_PIDS[$stream]}"
2690
+ fi
2691
+ local status="stopped"
2692
+ if [ "$pid" != "null" ] && kill -0 "$pid" 2>/dev/null; then
2693
+ status="running"
2694
+ fi
2695
+ echo " \"$stream\": {\"path\": \"$path\", \"pid\": $pid, \"status\": \"$status\"},"
2696
+ done | sed '$ s/,$//')
2697
+ fi
2698
+
2616
2699
  cat > "$state_file" << EOF
2617
2700
  {
2618
2701
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
2619
2702
  "worktrees": {
2620
- $(for stream in "${!WORKTREE_PATHS[@]}"; do
2621
- local path="${WORKTREE_PATHS[$stream]}"
2622
- local pid="null"
2623
- if [ -n "${WORKTREE_PIDS[$stream]+x}" ]; then
2624
- pid="${WORKTREE_PIDS[$stream]}"
2625
- fi
2626
- local status="stopped"
2627
- if [ "$pid" != "null" ] && kill -0 "$pid" 2>/dev/null; then
2628
- status="running"
2629
- fi
2630
- echo " \"$stream\": {\"path\": \"$path\", \"pid\": $pid, \"status\": \"$status\"},"
2631
- done | sed '$ s/,$//')
2703
+ ${worktree_json}
2632
2704
  },
2633
2705
  "active_sessions": ${#WORKTREE_PIDS[@]},
2634
2706
  "max_sessions": $MAX_PARALLEL_SESSIONS
@@ -904,7 +904,9 @@ except: pass
904
904
  local container_path="${parts[1]}"
905
905
  local mode="${parts[2]:-ro}"
906
906
 
907
- # Safe tilde expansion (no eval)
907
+ # Safe tilde expansion (no eval) - SC2088 disabled: tilde is intentionally
908
+ # treated as a literal string here for safe expansion without eval
909
+ # shellcheck disable=SC2088
908
910
  if [[ "$host_path" == "~/"* ]]; then
909
911
  host_path="$HOME/${host_path#\~/}"
910
912
  elif [[ "$host_path" == "~" ]]; then
@@ -1121,7 +1123,8 @@ start_sandbox() {
1121
1123
  local c_container="${mount_parts[1]:-}"
1122
1124
  local c_mode="${mount_parts[2]:-ro}"
1123
1125
  if [[ -n "$c_host" ]] && [[ -n "$c_container" ]]; then
1124
- # Safe tilde expansion (no eval)
1126
+ # Safe tilde expansion (no eval) - SC2088 disabled: intentional literal match
1127
+ # shellcheck disable=SC2088
1125
1128
  if [[ "$c_host" == "~/"* ]]; then
1126
1129
  c_host="$HOME/${c_host#\~/}"
1127
1130
  elif [[ "$c_host" == "~" ]]; then
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.62.0"
10
+ __version__ = "6.62.1"
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.62.0
5
+ **Version:** v6.62.1
6
6
 
7
7
  ---
8
8
 
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '6.62.0'
60
+ __version__ = '6.62.1'
package/memory/engine.py CHANGED
@@ -360,6 +360,7 @@ class MemoryEngine:
360
360
  pattern_id = self.storage.save_pattern(pattern)
361
361
 
362
362
  # Update index
363
+ pattern_dict = pattern.model_dump() if hasattr(pattern, "model_dump") else pattern.__dict__
363
364
  self._update_index_with_pattern(pattern_dict)
364
365
 
365
366
  return pattern_id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.62.0",
3
+ "version": "6.62.1",
4
4
  "description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "agent",
package/state/manager.py CHANGED
@@ -22,6 +22,7 @@ from pathlib import Path
22
22
  from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
23
23
  from enum import Enum
24
24
  from contextlib import contextmanager
25
+ import copy
25
26
  import uuid
26
27
  import glob as glob_module
27
28
 
@@ -102,11 +103,11 @@ class VersionVector:
102
103
  def concurrent_with(self, other: "VersionVector") -> bool:
103
104
  """Check if two vectors are concurrent (neither dominates).
104
105
 
105
- Per causality rules, identical vectors are concurrent (happened
106
- independently with the same knowledge).
106
+ Identical vectors represent the same causal point, NOT
107
+ concurrent operations, so they return False (BUG-ST-006).
107
108
  """
108
109
  if self.versions == other.versions:
109
- return True
110
+ return False
110
111
  return not self.dominates(other) and not other.dominates(self)
111
112
 
112
113
  def to_dict(self) -> Dict[str, int]:
@@ -796,6 +797,9 @@ class StateManager:
796
797
  """
797
798
  Merge updates into existing state.
798
799
 
800
+ Holds a file lock across the entire read-modify-write to prevent
801
+ lost updates from concurrent callers (BUG-ST-002).
802
+
799
803
  Args:
800
804
  file_ref: File reference
801
805
  updates: Dictionary of updates to merge
@@ -804,9 +808,57 @@ class StateManager:
804
808
  Returns:
805
809
  StateChange object
806
810
  """
807
- current = self.get_state(file_ref, default={})
808
- merged = {**current, **updates}
809
- return self.set_state(file_ref, merged, source)
811
+ path = self._resolve_path(file_ref)
812
+
813
+ with self._file_lock(path, exclusive=True):
814
+ # Read current state under the lock (bypass cache to get
815
+ # the true on-disk value while we hold the exclusive lock)
816
+ old_value = None
817
+ if path.exists():
818
+ try:
819
+ with open(path, "r") as f:
820
+ old_value = json.load(f)
821
+ except (json.JSONDecodeError, IOError):
822
+ old_value = None
823
+
824
+ current = old_value if old_value is not None else {}
825
+ merged = {**current, **updates}
826
+ change_type = "create" if old_value is None else "update"
827
+
828
+ # Save version before writing new data (SYN-015)
829
+ if self.enable_versioning and old_value is not None:
830
+ self._save_version(file_ref, old_value, source, change_type)
831
+
832
+ # Write atomically (temp file + rename) while still under lock
833
+ path.parent.mkdir(parents=True, exist_ok=True)
834
+ fd, temp_path = tempfile.mkstemp(
835
+ dir=path.parent, prefix=".tmp_", suffix=".json"
836
+ )
837
+ try:
838
+ with os.fdopen(fd, "w") as f:
839
+ json.dump(merged, f, indent=2, default=str)
840
+ shutil.move(temp_path, path)
841
+ except Exception:
842
+ if os.path.exists(temp_path):
843
+ os.unlink(temp_path)
844
+ raise
845
+
846
+ # Update cache
847
+ self._put_in_cache(path, merged)
848
+
849
+ # Create change object
850
+ change = StateChange(
851
+ file_path=str(path.relative_to(self.loki_dir)),
852
+ old_value=old_value,
853
+ new_value=merged,
854
+ change_type=change_type,
855
+ source=source
856
+ )
857
+
858
+ # Broadcast change
859
+ self._broadcast(change)
860
+
861
+ return change
810
862
 
811
863
  def delete_state(
812
864
  self,
@@ -1147,16 +1199,38 @@ class StateManager:
1147
1199
  return states
1148
1200
 
1149
1201
  def refresh_cache(self) -> None:
1150
- """Refresh all cached entries from disk."""
1202
+ """Refresh all cached entries from disk.
1203
+
1204
+ Collects paths under _cache_lock, releases it, reads files
1205
+ (which acquire file locks), then re-acquires _cache_lock to
1206
+ update entries. This avoids an ABBA deadlock between the
1207
+ cache lock and file locks (BUG-ST-001).
1208
+ """
1209
+ # Step 1: collect paths under the cache lock
1210
+ with self._cache_lock:
1211
+ paths_to_refresh = list(self._cache.keys())
1212
+
1213
+ # Step 2: read files WITHOUT holding _cache_lock
1214
+ refreshed: dict = {}
1215
+ gone: list = []
1216
+ for path_str in paths_to_refresh:
1217
+ path = Path(path_str)
1218
+ if path.exists():
1219
+ data = self._read_file(path)
1220
+ if data:
1221
+ refreshed[path_str] = data
1222
+ else:
1223
+ gone.append(path_str)
1224
+
1225
+ # Step 3: re-acquire _cache_lock and update entries
1151
1226
  with self._cache_lock:
1152
- for path_str in list(self._cache.keys()):
1227
+ for path_str, data in refreshed.items():
1153
1228
  path = Path(path_str)
1154
- if path.exists():
1155
- data = self._read_file(path)
1156
- if data:
1157
- self._put_in_cache(path, data)
1158
- else:
1159
- del self._cache[path_str]
1229
+ data_hash = self._compute_hash(data)
1230
+ mtime = path.stat().st_mtime if path.exists() else 0
1231
+ self._cache[path_str] = (data, data_hash, mtime)
1232
+ for path_str in gone:
1233
+ self._cache.pop(path_str, None)
1160
1234
 
1161
1235
  # -------------------------------------------------------------------------
1162
1236
  # Optimistic Updates (SYN-014)
@@ -1231,8 +1305,9 @@ class StateManager:
1231
1305
  self._pending_updates[path_str] = []
1232
1306
  self._pending_updates[path_str].append(pending)
1233
1307
 
1234
- # Apply optimistically to local state
1235
- current_state = self.get_state(file_ref, default={})
1308
+ # Apply optimistically to local state -- deepcopy to avoid
1309
+ # mutating the cached dict in-place (BUG-ST-011)
1310
+ current_state = copy.deepcopy(self.get_state(file_ref, default={}))
1236
1311
  current_state[key] = value
1237
1312
  current_state["_version_vector"] = version_vector.to_dict()
1238
1313
  current_state["_last_source"] = source
@@ -1394,14 +1469,23 @@ class StateManager:
1394
1469
  return merged
1395
1470
 
1396
1471
  elif isinstance(local, list) and isinstance(remote, list):
1397
- # Concatenate and deduplicate (preserving order)
1472
+ # Concatenate and deduplicate (preserving order).
1473
+ # Use try/except to handle unhashable types gracefully
1474
+ # by falling back to JSON serialization (BUG-ST-013).
1398
1475
  seen = set()
1399
1476
  merged = []
1400
1477
  for item in local + remote:
1401
- item_key = json.dumps(item, sort_keys=True, default=str) if isinstance(item, (dict, list)) else item
1402
- if item_key not in seen:
1403
- seen.add(item_key)
1404
- merged.append(item)
1478
+ try:
1479
+ item_key = json.dumps(item, sort_keys=True, default=str) if isinstance(item, (dict, list)) else item
1480
+ if item_key not in seen:
1481
+ seen.add(item_key)
1482
+ merged.append(item)
1483
+ except TypeError:
1484
+ # Unhashable type -- fall back to JSON string as key
1485
+ item_key = json.dumps(item, sort_keys=True, default=str)
1486
+ if item_key not in seen:
1487
+ seen.add(item_key)
1488
+ merged.append(item)
1405
1489
  return merged
1406
1490
 
1407
1491
  else:
@@ -1598,23 +1682,27 @@ class StateManager:
1598
1682
  return version
1599
1683
 
1600
1684
  def _cleanup_old_versions(self, file_ref: Union[str, ManagedFile]) -> None:
1601
- """Remove versions beyond the retention limit."""
1685
+ """Remove versions beyond the retention limit.
1686
+
1687
+ Only considers files with purely numeric stems so that orphan
1688
+ temp files (e.g. .tmp_xxx.json) are not counted toward the
1689
+ retention limit (BUG-ST-010).
1690
+ """
1602
1691
  history_dir = self._get_history_dir(file_ref)
1603
1692
  if not history_dir.exists():
1604
1693
  return
1605
1694
 
1606
1695
  version_files = glob_module.glob(str(history_dir / "*.json"))
1607
- if len(version_files) <= self.version_retention:
1608
- return
1609
1696
 
1610
- # Sort by version number and remove oldest
1697
+ # Filter to numeric stems only (skip temp/orphan files)
1611
1698
  version_nums = []
1612
1699
  for vf in version_files:
1613
- try:
1614
- version_num = int(Path(vf).stem)
1615
- version_nums.append((version_num, vf))
1616
- except ValueError:
1617
- pass
1700
+ stem = Path(vf).stem
1701
+ if stem.isdigit():
1702
+ version_nums.append((int(stem), vf))
1703
+
1704
+ if len(version_nums) <= self.version_retention:
1705
+ return
1618
1706
 
1619
1707
  version_nums.sort(key=lambda x: x[0])
1620
1708
  to_remove = version_nums[:-self.version_retention]
@@ -1777,17 +1865,24 @@ class StateManager:
1777
1865
 
1778
1866
  # Singleton instance for convenience
1779
1867
  _default_manager: Optional[StateManager] = None
1868
+ _default_manager_lock = threading.Lock()
1780
1869
 
1781
1870
 
1782
1871
  def get_state_manager(
1783
1872
  loki_dir: Optional[Union[str, Path]] = None,
1784
1873
  **kwargs
1785
1874
  ) -> StateManager:
1786
- """Get the default state manager instance."""
1875
+ """Get the default state manager instance.
1876
+
1877
+ Uses double-checked locking to avoid a race where two threads
1878
+ could both create a StateManager simultaneously (BUG-ST-007).
1879
+ """
1787
1880
  global _default_manager
1788
1881
 
1789
1882
  if _default_manager is None:
1790
- _default_manager = StateManager(loki_dir, **kwargs)
1883
+ with _default_manager_lock:
1884
+ if _default_manager is None:
1885
+ _default_manager = StateManager(loki_dir, **kwargs)
1791
1886
 
1792
1887
  return _default_manager
1793
1888