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