loki-mode 5.34.0 → 5.35.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/autonomy/run.sh CHANGED
@@ -3621,6 +3621,195 @@ print("Learning extraction complete")
3621
3621
  EXTRACT_SCRIPT
3622
3622
  }
3623
3623
 
3624
+ # ============================================================================
3625
+ # Session Continuity - Automatic CONTINUITY.md Management
3626
+ # Creates/updates .loki/CONTINUITY.md with structured working memory
3627
+ # so agents can cheaply load session context (<500 tokens / ~2KB)
3628
+ # ============================================================================
3629
+
3630
+ update_continuity() {
3631
+ local continuity_file=".loki/CONTINUITY.md"
3632
+ local iteration="${ITERATION_COUNT:-0}"
3633
+ local provider="${PROVIDER_NAME:-claude}"
3634
+ local phase=""
3635
+
3636
+ # Read current phase from orchestrator state
3637
+ if [ -f ".loki/state/orchestrator.json" ]; then
3638
+ phase=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('currentPhase', 'BOOTSTRAP'))" 2>/dev/null || echo "BOOTSTRAP")
3639
+ else
3640
+ phase="BOOTSTRAP"
3641
+ fi
3642
+
3643
+ # Calculate elapsed time from orchestrator startedAt
3644
+ local elapsed="0m"
3645
+ if [ -f ".loki/state/orchestrator.json" ]; then
3646
+ local started_at
3647
+ started_at=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('startedAt', ''))" 2>/dev/null || echo "")
3648
+ if [ -n "$started_at" ]; then
3649
+ local elapsed_secs
3650
+ export _CONT_STARTED_AT="$started_at"
3651
+ elapsed_secs=$(python3 << 'ELAPSED_CALC'
3652
+ import os
3653
+ from datetime import datetime, timezone
3654
+ try:
3655
+ sa = os.environ["_CONT_STARTED_AT"]
3656
+ start = datetime.fromisoformat(sa.replace("Z", "+00:00"))
3657
+ now = datetime.now(timezone.utc)
3658
+ print(int((now - start).total_seconds()))
3659
+ except Exception:
3660
+ print(0)
3661
+ ELAPSED_CALC
3662
+ )
3663
+ elapsed_secs="${elapsed_secs:-0}"
3664
+ unset _CONT_STARTED_AT
3665
+ elapsed=$(format_duration "$elapsed_secs")
3666
+ fi
3667
+ fi
3668
+
3669
+ # Get RARV phase name
3670
+ local rarv_phase=""
3671
+ if [ "$iteration" -gt 0 ]; then
3672
+ rarv_phase=$(get_rarv_phase_name "$iteration")
3673
+ fi
3674
+
3675
+ # Use python3 with env vars (no shell interpolation into Python code)
3676
+ export _CONT_FILE="$continuity_file"
3677
+ export _CONT_ITERATION="$iteration"
3678
+ export _CONT_PHASE="$phase"
3679
+ export _CONT_PROVIDER="$provider"
3680
+ export _CONT_ELAPSED="$elapsed"
3681
+ export _CONT_RARV="$rarv_phase"
3682
+
3683
+ python3 << 'CONTINUITY_SCRIPT'
3684
+ import json
3685
+ import os
3686
+ from datetime import datetime, timezone
3687
+
3688
+ cont_file = os.environ["_CONT_FILE"]
3689
+ iteration = os.environ["_CONT_ITERATION"]
3690
+ phase = os.environ["_CONT_PHASE"]
3691
+ provider = os.environ["_CONT_PROVIDER"]
3692
+ elapsed = os.environ["_CONT_ELAPSED"]
3693
+ rarv = os.environ.get("_CONT_RARV", "")
3694
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
3695
+
3696
+ sections = []
3697
+ sections.append(f"# Session Continuity\n\nUpdated: {timestamp}\n")
3698
+
3699
+ # Current State
3700
+ state_lines = [f"- Iteration: {iteration}"]
3701
+ if phase:
3702
+ state_lines.append(f"- Phase: {phase}")
3703
+ if rarv:
3704
+ state_lines.append(f"- RARV Step: {rarv}")
3705
+ state_lines.append(f"- Provider: {provider}")
3706
+ state_lines.append(f"- Elapsed: {elapsed}")
3707
+ sections.append("## Current State\n\n" + "\n".join(state_lines) + "\n")
3708
+
3709
+ # Last Completed Task - from last git commit
3710
+ last_task_lines = []
3711
+ try:
3712
+ import subprocess
3713
+ result = subprocess.run(
3714
+ ["git", "log", "-1", "--pretty=format:%s", "--no-merges"],
3715
+ capture_output=True, text=True, timeout=5
3716
+ )
3717
+ if result.returncode == 0 and result.stdout.strip():
3718
+ last_task_lines.append(f"- Last commit: {result.stdout.strip()[:120]}")
3719
+ files_result = subprocess.run(
3720
+ ["git", "diff", "--name-only", "HEAD~1", "HEAD"],
3721
+ capture_output=True, text=True, timeout=5
3722
+ )
3723
+ if files_result.returncode == 0 and files_result.stdout.strip():
3724
+ changed = files_result.stdout.strip().split("\n")[:5]
3725
+ last_task_lines.append(f"- Files changed: {', '.join(changed)}")
3726
+ if len(files_result.stdout.strip().split("\n")) > 5:
3727
+ last_task_lines.append(f" (+{len(files_result.stdout.strip().split(chr(10))) - 5} more)")
3728
+ except Exception:
3729
+ pass
3730
+ if not last_task_lines:
3731
+ last_task_lines.append("- No commits yet")
3732
+ sections.append("## Last Completed Task\n\n" + "\n".join(last_task_lines) + "\n")
3733
+
3734
+ # Active Blockers
3735
+ blocker_lines = []
3736
+ blocked_file = ".loki/queue/blocked.json"
3737
+ if os.path.exists(blocked_file):
3738
+ try:
3739
+ with open(blocked_file) as f:
3740
+ blocked = json.load(f)
3741
+ if isinstance(blocked, dict):
3742
+ blocked = blocked.get("tasks", [])
3743
+ for b in blocked[:3]:
3744
+ title = b.get("title", b.get("id", "unknown"))
3745
+ reason = b.get("reason", b.get("description", ""))
3746
+ line = f"- {title}"
3747
+ if reason:
3748
+ line += f": {reason[:80]}"
3749
+ blocker_lines.append(line)
3750
+ except Exception:
3751
+ pass
3752
+ if not blocker_lines:
3753
+ blocker_lines.append("- None")
3754
+ sections.append("## Active Blockers\n\n" + "\n".join(blocker_lines) + "\n")
3755
+
3756
+ # Next Up - top 3 from pending queue
3757
+ next_lines = []
3758
+ pending_file = ".loki/queue/pending.json"
3759
+ if os.path.exists(pending_file):
3760
+ try:
3761
+ with open(pending_file) as f:
3762
+ pending = json.load(f)
3763
+ if isinstance(pending, dict):
3764
+ pending = pending.get("tasks", [])
3765
+ for t in pending[:3]:
3766
+ title = t.get("title", t.get("id", "unknown"))
3767
+ next_lines.append(f"- {title}")
3768
+ except Exception:
3769
+ pass
3770
+ if not next_lines:
3771
+ next_lines.append("- No pending tasks")
3772
+ sections.append("## Next Up\n\n" + "\n".join(next_lines) + "\n")
3773
+
3774
+ # Key Decisions - from memory timeline (last 5)
3775
+ decision_lines = []
3776
+ timeline_file = ".loki/memory/timeline.json"
3777
+ if os.path.exists(timeline_file):
3778
+ try:
3779
+ with open(timeline_file) as f:
3780
+ timeline = json.load(f)
3781
+ decisions = []
3782
+ if isinstance(timeline, list):
3783
+ for entry in timeline:
3784
+ if entry.get("type") == "key_decision" or "decision" in entry.get("type", ""):
3785
+ decisions.append(entry)
3786
+ elif "key_decisions" in entry:
3787
+ for d in entry["key_decisions"]:
3788
+ decisions.append(d if isinstance(d, dict) else {"description": str(d)})
3789
+ elif isinstance(timeline, dict) and "key_decisions" in timeline:
3790
+ decisions = timeline["key_decisions"]
3791
+ for d in decisions[-5:]:
3792
+ desc = d.get("description", d.get("title", d.get("summary", str(d))))
3793
+ if isinstance(desc, str):
3794
+ decision_lines.append(f"- {desc[:100]}")
3795
+ except Exception:
3796
+ pass
3797
+ if not decision_lines:
3798
+ decision_lines.append("- None recorded yet")
3799
+ sections.append("## Key Decisions This Session\n\n" + "\n".join(decision_lines) + "\n")
3800
+
3801
+ # Write the file (overwrite each time to keep it fresh)
3802
+ os.makedirs(os.path.dirname(cont_file) if os.path.dirname(cont_file) else ".", exist_ok=True)
3803
+ with open(cont_file, "w") as f:
3804
+ f.write("\n".join(sections))
3805
+ CONTINUITY_SCRIPT
3806
+
3807
+ # Clean up exported env vars
3808
+ unset _CONT_FILE _CONT_ITERATION _CONT_PHASE _CONT_PROVIDER _CONT_ELAPSED _CONT_RARV
3809
+
3810
+ log_info "Updated session continuity: $continuity_file"
3811
+ }
3812
+
3624
3813
  # ============================================================================
3625
3814
  # Knowledge Compounding - Structured Solutions (v5.30.0)
3626
3815
  # Inspired by Compound Engineering Plugin's docs/solutions/ with YAML frontmatter
@@ -3791,6 +3980,329 @@ else:
3791
3980
  COMPOUND_SCRIPT
3792
3981
  }
3793
3982
 
3983
+ # ============================================================================
3984
+ # 3-Reviewer Parallel Code Review (v5.35.0)
3985
+ # Specialist pool from skills/quality-gates.md with blind review
3986
+ # architecture-strategist always included, 2 more selected by keyword scoring
3987
+ # ============================================================================
3988
+
3989
+ run_code_review() {
3990
+ local loki_dir="${TARGET_DIR:-.}/.loki"
3991
+ local review_dir="$loki_dir/quality/reviews"
3992
+ local review_id
3993
+ review_id="review-$(date -u +%Y%m%dT%H%M%SZ)-${ITERATION_COUNT:-0}"
3994
+ mkdir -p "$review_dir/$review_id"
3995
+
3996
+ # Get diff from last commit (staged changes)
3997
+ local diff_content
3998
+ diff_content=$(git -C "${TARGET_DIR:-.}" diff HEAD~1 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --cached 2>/dev/null || echo "")
3999
+ if [ -z "$diff_content" ]; then
4000
+ log_info "Code review: No diff to review, skipping"
4001
+ return 0
4002
+ fi
4003
+
4004
+ local changed_files
4005
+ 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 "")
4006
+
4007
+ log_header "CODE REVIEW: $review_id"
4008
+ log_info "Selecting 3 specialist reviewers from pool..."
4009
+
4010
+ # Write diff/files to temp files for python to read (avoid env var size limits)
4011
+ local diff_file="$review_dir/$review_id/diff.txt"
4012
+ local files_file="$review_dir/$review_id/files.txt"
4013
+ echo "$diff_content" > "$diff_file"
4014
+ echo "$changed_files" > "$files_file"
4015
+
4016
+ # Select specialists via keyword scoring (python3 reads files, not env vars)
4017
+ export LOKI_REVIEW_DIFF_FILE="$diff_file"
4018
+ export LOKI_REVIEW_FILES_FILE="$files_file"
4019
+ local selected_specialists
4020
+ selected_specialists=$(python3 << 'SPECIALIST_SELECT'
4021
+ import os
4022
+ import json
4023
+
4024
+ SPECIALISTS = {
4025
+ "security-sentinel": {
4026
+ "keywords": ["auth", "login", "password", "token", "api", "sql", "query", "cookie", "cors", "csrf"],
4027
+ "focus": "OWASP Top 10, injection, auth, secrets, input validation",
4028
+ "checks": "injection (SQL, XSS, command, template), auth bypass, secrets in code, missing input validation, OWASP Top 10, insecure defaults",
4029
+ "priority": 0
4030
+ },
4031
+ "test-coverage-auditor": {
4032
+ "keywords": ["test", "spec", "coverage", "assert", "mock", "fixture", "expect", "describe"],
4033
+ "focus": "Missing tests, edge cases, error paths, boundary conditions",
4034
+ "checks": "missing test cases, uncovered error paths, boundary conditions, mock correctness, test isolation, flaky test patterns",
4035
+ "priority": 1
4036
+ },
4037
+ "performance-oracle": {
4038
+ "keywords": ["database", "query", "cache", "render", "loop", "fetch", "load", "index", "join", "pool"],
4039
+ "focus": "N+1 queries, memory leaks, caching, bundle size, lazy loading",
4040
+ "checks": "N+1 queries, unbounded loops, memory leaks, missing caching, excessive re-renders, large bundle imports, missing pagination",
4041
+ "priority": 2
4042
+ },
4043
+ "dependency-analyst": {
4044
+ "keywords": ["package", "import", "require", "dependency", "npm", "pip", "yarn", "lock"],
4045
+ "focus": "Outdated packages, CVEs, bloat, unused deps, license issues",
4046
+ "checks": "outdated dependencies, known CVEs, unnecessary imports, dependency bloat, license compatibility, unused packages",
4047
+ "priority": 3
4048
+ }
4049
+ }
4050
+
4051
+ diff_path = os.environ.get("LOKI_REVIEW_DIFF_FILE", "")
4052
+ files_path = os.environ.get("LOKI_REVIEW_FILES_FILE", "")
4053
+
4054
+ diff_text = ""
4055
+ files_text = ""
4056
+ if diff_path and os.path.exists(diff_path):
4057
+ with open(diff_path, "r") as f:
4058
+ diff_text = f.read().lower()
4059
+ if files_path and os.path.exists(files_path):
4060
+ with open(files_path, "r") as f:
4061
+ files_text = f.read().lower()
4062
+
4063
+ search_text = diff_text + " " + files_text
4064
+
4065
+ # Score each specialist by keyword matches
4066
+ scores = {}
4067
+ for name, spec in SPECIALISTS.items():
4068
+ score = sum(1 for kw in spec["keywords"] if kw in search_text)
4069
+ scores[name] = score
4070
+
4071
+ # Sort by score descending, then by priority ascending (tie-breaker)
4072
+ ranked = sorted(scores.keys(), key=lambda n: (-scores[n], SPECIALISTS[n]["priority"]))
4073
+
4074
+ # If no keywords matched at all, use defaults
4075
+ if all(s == 0 for s in scores.values()):
4076
+ selected = ["security-sentinel", "test-coverage-auditor"]
4077
+ else:
4078
+ selected = ranked[:2]
4079
+
4080
+ # Output JSON: architecture-strategist always first, then the 2 selected
4081
+ result = {
4082
+ "reviewers": [
4083
+ {
4084
+ "name": "architecture-strategist",
4085
+ "focus": "SOLID, coupling, cohesion, patterns, abstraction, dependency direction",
4086
+ "checks": "SOLID violations, excessive coupling, wrong patterns, missing abstractions, dependency direction issues, god classes/functions"
4087
+ }
4088
+ ] + [
4089
+ {
4090
+ "name": name,
4091
+ "focus": SPECIALISTS[name]["focus"],
4092
+ "checks": SPECIALISTS[name]["checks"]
4093
+ }
4094
+ for name in selected
4095
+ ],
4096
+ "scores": {n: scores[n] for n in scores}
4097
+ }
4098
+ print(json.dumps(result))
4099
+ SPECIALIST_SELECT
4100
+ )
4101
+ unset LOKI_REVIEW_DIFF_FILE LOKI_REVIEW_FILES_FILE
4102
+
4103
+ if [ -z "$selected_specialists" ]; then
4104
+ log_error "Code review: Specialist selection failed"
4105
+ return 1
4106
+ fi
4107
+
4108
+ # Save selection metadata
4109
+ echo "$selected_specialists" > "$review_dir/$review_id/selection.json"
4110
+
4111
+ # Extract reviewer names for logging
4112
+ local reviewer_names
4113
+ reviewer_names=$(echo "$selected_specialists" | python3 -c "import sys,json; d=json.load(sys.stdin); print(', '.join(r['name'] for r in d['reviewers']))")
4114
+ log_info "Selected reviewers: $reviewer_names"
4115
+
4116
+ emit_event_json "code_review_start" \
4117
+ "review_id=$review_id" \
4118
+ "reviewers=$reviewer_names" \
4119
+ "iteration=$ITERATION_COUNT"
4120
+
4121
+ # Dispatch 3 parallel blind reviews using provider-specific invocation
4122
+ local pids=()
4123
+ local reviewer_count
4124
+ reviewer_count=$(echo "$selected_specialists" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['reviewers']))")
4125
+
4126
+ for i in $(seq 0 $((reviewer_count - 1))); do
4127
+ local reviewer_name reviewer_focus reviewer_checks
4128
+ reviewer_name=$(echo "$selected_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['name'])")
4129
+ reviewer_focus=$(echo "$selected_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['focus'])")
4130
+ reviewer_checks=$(echo "$selected_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['checks'])")
4131
+
4132
+ local review_output="$review_dir/$review_id/${reviewer_name}.txt"
4133
+
4134
+ # Build prompt via python to avoid shell quoting issues with diff content
4135
+ local review_prompt_file="$review_dir/$review_id/${reviewer_name}-prompt.txt"
4136
+ export LOKI_REVIEW_PROMPT_NAME="$reviewer_name"
4137
+ export LOKI_REVIEW_PROMPT_FOCUS="$reviewer_focus"
4138
+ export LOKI_REVIEW_PROMPT_CHECKS="$reviewer_checks"
4139
+ export LOKI_REVIEW_PROMPT_DIFF_FILE="$diff_file"
4140
+ export LOKI_REVIEW_PROMPT_FILES_FILE="$files_file"
4141
+ export LOKI_REVIEW_PROMPT_OUT="$review_prompt_file"
4142
+ python3 << 'BUILD_PROMPT'
4143
+ import os
4144
+
4145
+ name = os.environ["LOKI_REVIEW_PROMPT_NAME"]
4146
+ focus = os.environ["LOKI_REVIEW_PROMPT_FOCUS"]
4147
+ checks = os.environ["LOKI_REVIEW_PROMPT_CHECKS"]
4148
+
4149
+ with open(os.environ["LOKI_REVIEW_PROMPT_FILES_FILE"], "r") as f:
4150
+ files = f.read().strip()
4151
+ with open(os.environ["LOKI_REVIEW_PROMPT_DIFF_FILE"], "r") as f:
4152
+ diff = f.read().strip()
4153
+
4154
+ prompt = f"""You are {name}. Your SOLE focus is: {focus}.
4155
+
4156
+ Review ONLY for: {checks}.
4157
+
4158
+ Files changed:
4159
+ {files}
4160
+
4161
+ Diff:
4162
+ {diff}
4163
+
4164
+ Output format (STRICT - follow exactly):
4165
+ VERDICT: PASS or FAIL
4166
+ FINDINGS:
4167
+ - [severity] description (file:line)
4168
+ Severity levels: Critical, High, Medium, Low
4169
+
4170
+ If no issues found, output:
4171
+ VERDICT: PASS
4172
+ FINDINGS:
4173
+ - None"""
4174
+
4175
+ with open(os.environ["LOKI_REVIEW_PROMPT_OUT"], "w") as f:
4176
+ f.write(prompt)
4177
+ BUILD_PROMPT
4178
+ unset LOKI_REVIEW_PROMPT_NAME LOKI_REVIEW_PROMPT_FOCUS LOKI_REVIEW_PROMPT_CHECKS
4179
+ unset LOKI_REVIEW_PROMPT_DIFF_FILE LOKI_REVIEW_PROMPT_FILES_FILE LOKI_REVIEW_PROMPT_OUT
4180
+
4181
+ log_step "Dispatching reviewer: $reviewer_name"
4182
+
4183
+ # Launch blind review in background (provider-specific)
4184
+ (
4185
+ local prompt_text
4186
+ prompt_text=$(cat "$review_prompt_file")
4187
+ case "${PROVIDER_NAME:-claude}" in
4188
+ claude)
4189
+ claude --dangerously-skip-permissions -p "$prompt_text" \
4190
+ --output-format text > "$review_output" 2>/dev/null
4191
+ ;;
4192
+ codex)
4193
+ codex exec --full-auto "$prompt_text" \
4194
+ > "$review_output" 2>/dev/null
4195
+ ;;
4196
+ gemini)
4197
+ invoke_gemini_capture "$prompt_text" \
4198
+ > "$review_output" 2>/dev/null
4199
+ ;;
4200
+ *)
4201
+ echo "VERDICT: PASS" > "$review_output"
4202
+ echo "FINDINGS:" >> "$review_output"
4203
+ echo "- [Low] Unknown provider, review skipped" >> "$review_output"
4204
+ ;;
4205
+ esac
4206
+ ) &
4207
+ pids+=($!)
4208
+ done
4209
+
4210
+ # Wait for all reviewers to complete
4211
+ log_info "Waiting for $reviewer_count reviewers to complete (blind review)..."
4212
+ for pid in "${pids[@]}"; do
4213
+ wait "$pid" || true
4214
+ done
4215
+
4216
+ log_info "All reviewers complete. Aggregating verdicts..."
4217
+
4218
+ # Aggregate verdicts: check for FAIL + Critical/High severity
4219
+ local has_blocking=false
4220
+ local pass_count=0
4221
+ local fail_count=0
4222
+ local verdicts_summary=""
4223
+
4224
+ for i in $(seq 0 $((reviewer_count - 1))); do
4225
+ local reviewer_name
4226
+ reviewer_name=$(echo "$selected_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['name'])")
4227
+ local review_output="$review_dir/$review_id/${reviewer_name}.txt"
4228
+
4229
+ if [ ! -f "$review_output" ] || [ ! -s "$review_output" ]; then
4230
+ log_warn "Reviewer $reviewer_name produced no output"
4231
+ verdicts_summary="${verdicts_summary}${reviewer_name}:NO_OUTPUT "
4232
+ continue
4233
+ fi
4234
+
4235
+ # Extract verdict
4236
+ local verdict
4237
+ verdict=$(grep -i "^VERDICT:" "$review_output" | head -1 | sed 's/^VERDICT:[[:space:]]*//' | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')
4238
+
4239
+ if [ "$verdict" = "FAIL" ]; then
4240
+ ((fail_count++))
4241
+ # Check for Critical/High severity findings
4242
+ if grep -qiE "\[(Critical|High)\]" "$review_output"; then
4243
+ has_blocking=true
4244
+ log_error "BLOCKING: $reviewer_name found Critical/High severity issues"
4245
+ else
4246
+ log_warn "FAIL: $reviewer_name found Medium/Low issues (non-blocking)"
4247
+ fi
4248
+ else
4249
+ ((pass_count++))
4250
+ log_info "PASS: $reviewer_name"
4251
+ fi
4252
+ verdicts_summary="${verdicts_summary}${reviewer_name}:${verdict:-UNKNOWN} "
4253
+ done
4254
+
4255
+ # Save aggregate results via python3 + env vars (no shell interpolation in JSON)
4256
+ export LOKI_REVIEW_AGG_FILE="$review_dir/$review_id/aggregate.json"
4257
+ export LOKI_REVIEW_AGG_ID="$review_id"
4258
+ export LOKI_REVIEW_AGG_ITER="$ITERATION_COUNT"
4259
+ export LOKI_REVIEW_AGG_PASS="$pass_count"
4260
+ export LOKI_REVIEW_AGG_FAIL="$fail_count"
4261
+ export LOKI_REVIEW_AGG_BLOCKING="$has_blocking"
4262
+ export LOKI_REVIEW_AGG_VERDICTS="$verdicts_summary"
4263
+ python3 << 'AGG_SCRIPT'
4264
+ import json, os
4265
+ result = {
4266
+ "review_id": os.environ["LOKI_REVIEW_AGG_ID"],
4267
+ "iteration": int(os.environ["LOKI_REVIEW_AGG_ITER"]),
4268
+ "pass_count": int(os.environ["LOKI_REVIEW_AGG_PASS"]),
4269
+ "fail_count": int(os.environ["LOKI_REVIEW_AGG_FAIL"]),
4270
+ "has_blocking": os.environ["LOKI_REVIEW_AGG_BLOCKING"] == "true",
4271
+ "verdicts": os.environ["LOKI_REVIEW_AGG_VERDICTS"].strip()
4272
+ }
4273
+ with open(os.environ["LOKI_REVIEW_AGG_FILE"], "w") as f:
4274
+ json.dump(result, f, indent=2)
4275
+ AGG_SCRIPT
4276
+ unset LOKI_REVIEW_AGG_FILE LOKI_REVIEW_AGG_ID LOKI_REVIEW_AGG_ITER
4277
+ unset LOKI_REVIEW_AGG_PASS LOKI_REVIEW_AGG_FAIL LOKI_REVIEW_AGG_BLOCKING LOKI_REVIEW_AGG_VERDICTS
4278
+
4279
+ emit_event_json "code_review_complete" \
4280
+ "review_id=$review_id" \
4281
+ "pass_count=$pass_count" \
4282
+ "fail_count=$fail_count" \
4283
+ "has_blocking=$has_blocking" \
4284
+ "iteration=$ITERATION_COUNT"
4285
+
4286
+ # Anti-sycophancy check: unanimous PASS is suspicious
4287
+ if [ "$pass_count" -eq "$reviewer_count" ] && [ "$fail_count" -eq 0 ]; then
4288
+ log_warn "ANTI-SYCOPHANCY: All $reviewer_count reviewers passed unanimously"
4289
+ log_warn "Devil's advocate note: Unanimous approval may indicate insufficient scrutiny"
4290
+ log_warn "Consider manual review of $review_dir/$review_id/"
4291
+ echo "UNANIMOUS_PASS: All reviewers approved - potential sycophancy risk" \
4292
+ >> "$review_dir/$review_id/anti-sycophancy.txt"
4293
+ fi
4294
+
4295
+ # Blocking decision
4296
+ if [ "$has_blocking" = "true" ]; then
4297
+ log_error "CODE REVIEW BLOCKED: Critical/High findings detected"
4298
+ log_error "Review details: $review_dir/$review_id/"
4299
+ return 1
4300
+ fi
4301
+
4302
+ log_info "Code review passed ($pass_count/$reviewer_count PASS, $fail_count FAIL - no blocking issues)"
4303
+ return 0
4304
+ }
4305
+
3794
4306
  load_solutions_context() {
3795
4307
  # Load relevant structured solutions for the current task context
3796
4308
  local context="$1"
@@ -3896,7 +4408,7 @@ create_checkpoint() {
3896
4408
  mkdir -p "$checkpoint_dir"
3897
4409
 
3898
4410
  # Only checkpoint if there are uncommitted changes
3899
- if ! git diff --quiet 2>/dev/null && ! git diff --cached --quiet 2>/dev/null; then
4411
+ if git diff --quiet 2>/dev/null && git diff --cached --quiet 2>/dev/null; then
3900
4412
  log_info "No uncommitted changes to checkpoint"
3901
4413
  return 0
3902
4414
  fi
@@ -3924,47 +4436,59 @@ create_checkpoint() {
3924
4436
  fi
3925
4437
  done
3926
4438
 
3927
- # Write checkpoint metadata
3928
- local safe_desc
3929
- safe_desc=$(printf '%s' "$task_desc" | sed 's/\\/\\\\/g; s/"/\\"/g' | head -c 200)
3930
- cat > "$cp_dir/metadata.json" << CPEOF
3931
- {
3932
- "id": "${checkpoint_id}",
3933
- "timestamp": "${timestamp}",
3934
- "iteration": ${iteration},
3935
- "task_id": "${task_id}",
3936
- "task_description": "${safe_desc}",
3937
- "git_sha": "${git_sha}",
3938
- "git_branch": "${git_branch}",
3939
- "provider": "${PROVIDER_NAME:-claude}",
3940
- "phase": "$(cat .loki/state/orchestrator.json 2>/dev/null | python3 -c 'import sys,json; print(json.load(sys.stdin).get("currentPhase","unknown"))' 2>/dev/null || echo 'unknown')"
3941
- }
3942
- CPEOF
4439
+ # Write checkpoint metadata (use python3 json.dumps for safe serialization)
4440
+ local phase_val
4441
+ phase_val=$(cat .loki/state/orchestrator.json 2>/dev/null | python3 -c 'import sys,json; print(json.load(sys.stdin).get("currentPhase","unknown"))' 2>/dev/null || echo 'unknown')
3943
4442
 
3944
- # Maintain checkpoint index for fast listing
3945
4443
  local index_file="${checkpoint_dir}/index.jsonl"
3946
- printf '{"id":"%s","ts":"%s","iter":%d,"task":"%s","sha":"%s"}\n' \
3947
- "$checkpoint_id" "$timestamp" "$iteration" "$safe_desc" "$git_sha" \
3948
- >> "$index_file"
4444
+ _CP_ID="$checkpoint_id" _CP_TS="$timestamp" _CP_ITER="$iteration" \
4445
+ _CP_TASK_ID="$task_id" _CP_DESC="${task_desc:0:200}" _CP_SHA="$git_sha" \
4446
+ _CP_BRANCH="$git_branch" _CP_PROVIDER="${PROVIDER_NAME:-claude}" \
4447
+ _CP_PHASE="$phase_val" _CP_DIR="$cp_dir" _CP_INDEX="$index_file" \
4448
+ python3 << 'CPEOF'
4449
+ import json, os
4450
+ metadata = {
4451
+ "id": os.environ["_CP_ID"],
4452
+ "timestamp": os.environ["_CP_TS"],
4453
+ "iteration": int(os.environ["_CP_ITER"]),
4454
+ "task_id": os.environ["_CP_TASK_ID"],
4455
+ "task_description": os.environ["_CP_DESC"],
4456
+ "git_sha": os.environ["_CP_SHA"],
4457
+ "git_branch": os.environ["_CP_BRANCH"],
4458
+ "provider": os.environ["_CP_PROVIDER"],
4459
+ "phase": os.environ["_CP_PHASE"],
4460
+ }
4461
+ with open(os.path.join(os.environ["_CP_DIR"], "metadata.json"), "w") as f:
4462
+ json.dump(metadata, f, indent=2)
4463
+ with open(os.environ["_CP_INDEX"], "a") as f:
4464
+ index_entry = {"id": metadata["id"], "ts": metadata["timestamp"],
4465
+ "iter": metadata["iteration"], "task": metadata["task_description"],
4466
+ "sha": metadata["git_sha"]}
4467
+ f.write(json.dumps(index_entry) + "\n")
4468
+ CPEOF
3949
4469
 
3950
4470
  # Retention: keep last 50 checkpoints, prune older
4471
+ # Sort by epoch suffix (field after last hyphen) for correct chronological order
3951
4472
  local cp_count
3952
4473
  cp_count=$(find "$checkpoint_dir" -maxdepth 1 -type d -name "cp-*" 2>/dev/null | wc -l | tr -d ' ')
3953
4474
  if [ "$cp_count" -gt 50 ]; then
3954
4475
  local to_remove=$((cp_count - 50))
3955
- find "$checkpoint_dir" -maxdepth 1 -type d -name "cp-*" 2>/dev/null | sort | head -n "$to_remove" | while read -r old_cp; do
3956
- rm -r "$old_cp" 2>/dev/null || true
4476
+ find "$checkpoint_dir" -maxdepth 1 -type d -name "cp-*" 2>/dev/null \
4477
+ | sort -t'-' -k3 -n \
4478
+ | head -n "$to_remove" | while read -r old_cp; do
4479
+ rm -rf "$old_cp" 2>/dev/null || true
3957
4480
  done
3958
- # Rebuild index from remaining checkpoints
3959
- : > "$index_file"
3960
- for remaining in "$checkpoint_dir"/cp-*/metadata.json; do
4481
+ # Rebuild index atomically from remaining checkpoints (sorted by epoch)
4482
+ local tmp_index="${index_file}.tmp.$$"
4483
+ for remaining in $(find "$checkpoint_dir" -maxdepth 2 -name "metadata.json" -path "*/cp-*/*" 2>/dev/null | sort -t'-' -k3 -n); do
3961
4484
  [ -f "$remaining" ] || continue
3962
- python3 -c "
3963
- import json,sys
3964
- m=json.load(open('$remaining'))
4485
+ _CP_META="$remaining" python3 -c "
4486
+ import json,os
4487
+ m=json.load(open(os.environ['_CP_META']))
3965
4488
  print(json.dumps({'id':m['id'],'ts':m['timestamp'],'iter':m['iteration'],'task':m.get('task_description',''),'sha':m['git_sha']}))
3966
- " >> "$index_file" 2>/dev/null || true
4489
+ " >> "$tmp_index" 2>/dev/null || true
3967
4490
  done
4491
+ mv -f "$tmp_index" "$index_file" 2>/dev/null || true
3968
4492
  fi
3969
4493
 
3970
4494
  log_info "Checkpoint created: ${checkpoint_id} (git: ${git_sha:0:8})"
@@ -3975,6 +4499,13 @@ rollback_to_checkpoint() {
3975
4499
  # Args: $1 = checkpoint_id
3976
4500
  local checkpoint_id="$1"
3977
4501
  local checkpoint_dir=".loki/state/checkpoints"
4502
+
4503
+ # Validate checkpoint ID (prevent path traversal)
4504
+ if [[ ! "$checkpoint_id" =~ ^[a-zA-Z0-9_-]+$ ]]; then
4505
+ log_error "Invalid checkpoint ID: must be alphanumeric, hyphens, underscores only"
4506
+ return 1
4507
+ fi
4508
+
3978
4509
  local cp_dir="${checkpoint_dir}/${checkpoint_id}"
3979
4510
 
3980
4511
  if [ ! -d "$cp_dir" ]; then
@@ -3984,7 +4515,7 @@ rollback_to_checkpoint() {
3984
4515
 
3985
4516
  # Read checkpoint metadata
3986
4517
  local git_sha
3987
- git_sha=$(python3 -c "import json; print(json.load(open('${cp_dir}/metadata.json'))['git_sha'])" 2>/dev/null || echo "")
4518
+ git_sha=$(_CP_META="${cp_dir}/metadata.json" python3 -c "import json, os; print(json.load(open(os.environ['_CP_META']))['git_sha'])" 2>/dev/null || echo "")
3988
4519
 
3989
4520
  log_warn "Rolling back to checkpoint: ${checkpoint_id}"
3990
4521
 
@@ -4000,12 +4531,14 @@ rollback_to_checkpoint() {
4000
4531
  fi
4001
4532
  done
4002
4533
 
4003
- # Log the rollback
4534
+ # Log the rollback (use python3 for safe JSON serialization)
4004
4535
  local timestamp
4005
4536
  timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
4006
- printf '{"event":"rollback","checkpoint":"%s","git_sha":"%s","timestamp":"%s"}\n' \
4007
- "$checkpoint_id" "$git_sha" "$timestamp" \
4008
- >> ".loki/events.jsonl" 2>/dev/null || true
4537
+ _RB_CPID="$checkpoint_id" _RB_SHA="$git_sha" _RB_TS="$timestamp" \
4538
+ python3 -c "
4539
+ import json,os
4540
+ print(json.dumps({'event':'rollback','checkpoint':os.environ['_RB_CPID'],'git_sha':os.environ['_RB_SHA'],'timestamp':os.environ['_RB_TS']}))
4541
+ " >> ".loki/events.jsonl" 2>/dev/null || true
4009
4542
 
4010
4543
  log_info "State files restored from checkpoint: ${checkpoint_id}"
4011
4544
 
@@ -5294,6 +5827,14 @@ if __name__ == "__main__":
5294
5827
  # Auto-track iteration completion (for dashboard task queue)
5295
5828
  track_iteration_complete "$ITERATION_COUNT" "$exit_code"
5296
5829
 
5830
+ # Update session continuity file for next iteration / agent handoff
5831
+ update_continuity
5832
+
5833
+ # Code review gate (v5.35.0)
5834
+ if [ "$PHASE_CODE_REVIEW" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
5835
+ run_code_review || log_warn "Code review found issues - check .loki/quality/reviews/"
5836
+ fi
5837
+
5297
5838
  # Check for success - ONLY stop on explicit completion promise
5298
5839
  # There's never a "complete" product - always improvements, bugs, features
5299
5840
  if [ $exit_code -eq 0 ]; then
@@ -5848,6 +6389,9 @@ main() {
5848
6389
  # Initialize .loki directory
5849
6390
  init_loki_dir
5850
6391
 
6392
+ # Initialize session continuity file with empty template
6393
+ update_continuity
6394
+
5851
6395
  # Session lock: prevent concurrent sessions on same repo
5852
6396
  local pid_file=".loki/loki.pid"
5853
6397
  if [ -f "$pid_file" ]; then