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/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 +597 -36
- package/autonomy/sandbox.sh +4 -10
- package/autonomy/serve.sh +10 -10
- package/dashboard/__init__.py +1 -1
- package/dashboard/auth.py +18 -5
- package/dashboard/requirements.txt +7 -7
- package/dashboard/server.py +67 -30
- 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
|
@@ -3333,7 +3333,24 @@ check_staged_autonomy() {
|
|
|
3333
3333
|
}
|
|
3334
3334
|
|
|
3335
3335
|
check_command_allowed() {
|
|
3336
|
-
# Check if a command
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
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
|
|
3956
|
-
|
|
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
|
-
|
|
3960
|
-
for remaining in "$checkpoint_dir"
|
|
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,
|
|
3964
|
-
m=json.load(open('
|
|
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
|
-
" >> "$
|
|
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(
|
|
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
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
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
|