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/README.md +5 -5
- package/SKILL.md +4 -4
- package/VERSION +1 -1
- package/api/README.md +1 -1
- package/api/server.js +1 -1
- package/api/server.ts +5 -5
- package/api/test.js +1 -1
- package/autonomy/api-server.js +6 -4
- package/autonomy/completion-council.sh +4 -2
- package/autonomy/hooks/store-episode.sh +2 -2
- package/autonomy/loki +84 -54
- package/autonomy/run.sh +579 -35
- package/autonomy/sandbox.sh +3 -9
- package/autonomy/serve.sh +10 -10
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +16 -16
- package/dashboard/static/index.html +359 -58
- package/docs/INSTALLATION.md +4 -4
- package/docs/SYNERGY-ROADMAP.md +1 -1
- package/docs/architecture/DASHBOARD_V2_ARCHITECTURE.md +4 -3
- package/docs/dashboard-guide.md +1 -1
- package/memory/layers/index_layer.py +4 -4
- package/memory/layers/timeline_layer.py +5 -5
- package/memory/retrieval.py +10 -2
- package/memory/storage.py +1 -1
- package/memory/token_economics.py +12 -8
- package/package.json +1 -1
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
|
|
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
|
|
3929
|
-
|
|
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
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
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
|
|
3956
|
-
|
|
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
|
-
|
|
3960
|
-
for remaining in "$checkpoint_dir"
|
|
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,
|
|
3964
|
-
m=json.load(open('
|
|
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
|
-
" >> "$
|
|
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(
|
|
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
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
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
|