loki-mode 6.26.5 → 6.27.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/loki CHANGED
@@ -441,6 +441,7 @@ show_help() {
441
441
  echo " plan <PRD> Dry-run PRD analysis: complexity, cost, and execution plan"
442
442
  echo " ci [opts] CI/CD quality gate integration (--pr, --report, --github-comment)"
443
443
  echo " test [opts] AI-powered test generation (--file, --dir, --changed, --dry-run)"
444
+ echo " report [opts] Session report generator (--format text|markdown|html, --output)"
444
445
  echo " version Show version"
445
446
  echo " help Show this help"
446
447
  echo ""
@@ -2907,9 +2908,13 @@ cmd_dashboard_open() {
2907
2908
  }
2908
2909
 
2909
2910
  # Web app management
2910
- # The web app is served directly by the dashboard FastAPI server (port 57374).
2911
- # No separate web server process is needed -- the dashboard mounts web-app/dist/
2912
- # as static files and handles SPA catch-all routing.
2911
+ # Purple Lab -- standalone product web UI for Loki Mode.
2912
+ # Runs on port 57375 (separate from dashboard at 57374).
2913
+ # Backend: web-app/server.py (FastAPI), Frontend: web-app/dist/
2914
+
2915
+ PURPLE_LAB_DEFAULT_PORT=57375
2916
+ PURPLE_LAB_DEFAULT_HOST="127.0.0.1"
2917
+ PURPLE_LAB_PID_FILE="${LOKI_DIR}/purple-lab/purple-lab.pid"
2913
2918
 
2914
2919
  cmd_web() {
2915
2920
  local subcommand="${1:-start}"
@@ -2936,30 +2941,32 @@ cmd_web() {
2936
2941
  }
2937
2942
 
2938
2943
  cmd_web_help() {
2939
- echo -e "${BOLD}Loki Mode Web App${NC}"
2944
+ echo -e "${BOLD}Purple Lab -- Loki Mode Web UI${NC}"
2940
2945
  echo ""
2941
2946
  echo "Usage: loki web [command] [options]"
2942
2947
  echo ""
2943
2948
  echo "Commands:"
2944
- echo " start Start the web app (default)"
2945
- echo " stop Stop the dashboard server (alias for 'loki dashboard stop')"
2946
- echo " status Show dashboard server status"
2949
+ echo " start Start Purple Lab (default)"
2950
+ echo " stop Stop Purple Lab server"
2951
+ echo " status Show Purple Lab server status"
2947
2952
  echo " help Show this help"
2948
2953
  echo ""
2949
2954
  echo "Options (for start):"
2950
2955
  echo " --no-open Don't open browser automatically"
2956
+ echo " --port PORT Use custom port (default: ${PURPLE_LAB_DEFAULT_PORT})"
2951
2957
  echo ""
2952
- echo "The web app is served by the dashboard API at localhost:${DASHBOARD_DEFAULT_PORT}."
2953
- echo "Both API endpoints and static files are served from the same origin."
2958
+ echo "Purple Lab is the product UI where you input PRDs and watch agents build."
2959
+ echo "It runs its own backend (web-app/server.py) on port ${PURPLE_LAB_DEFAULT_PORT}."
2954
2960
  echo ""
2955
2961
  echo "Examples:"
2956
- echo " loki web Start dashboard and open web app in browser"
2957
- echo " loki web --no-open Start dashboard without opening browser"
2958
- echo " loki web stop Stop the dashboard server"
2962
+ echo " loki web Start Purple Lab and open in browser"
2963
+ echo " loki web --no-open Start without opening browser"
2964
+ echo " loki web stop Stop the Purple Lab server"
2959
2965
  }
2960
2966
 
2961
2967
  cmd_web_start() {
2962
2968
  local open_browser=true
2969
+ local port="${PURPLE_LAB_DEFAULT_PORT}"
2963
2970
 
2964
2971
  # Parse arguments
2965
2972
  while [[ $# -gt 0 ]]; do
@@ -2968,6 +2975,10 @@ cmd_web_start() {
2968
2975
  open_browser=false
2969
2976
  shift
2970
2977
  ;;
2978
+ --port)
2979
+ port="$2"
2980
+ shift 2
2981
+ ;;
2971
2982
  --help|-h)
2972
2983
  cmd_web_help
2973
2984
  exit 0
@@ -2991,27 +3002,82 @@ cmd_web_start() {
2991
3002
  exit 1
2992
3003
  fi
2993
3004
 
2994
- # Ensure dashboard server is running (it serves both API and web app)
2995
- local api_running=false
2996
- if [ -f "$DASHBOARD_PID_FILE" ]; then
2997
- local api_pid
2998
- api_pid=$(cat "$DASHBOARD_PID_FILE" 2>/dev/null)
2999
- if [ -n "$api_pid" ] && kill -0 "$api_pid" 2>/dev/null; then
3000
- api_running=true
3005
+ # Check that server.py exists
3006
+ local server_py="${SKILL_DIR}/web-app/server.py"
3007
+ if [ ! -f "$server_py" ]; then
3008
+ echo -e "${RED}Error: Purple Lab server not found at $server_py${NC}"
3009
+ exit 1
3010
+ fi
3011
+
3012
+ # Ensure Python venv with FastAPI is available
3013
+ if ! ensure_dashboard_venv; then
3014
+ echo -e "${RED}Error: Failed to set up Python environment${NC}"
3015
+ exit 1
3016
+ fi
3017
+
3018
+ # Check if already running
3019
+ mkdir -p "${LOKI_DIR}/purple-lab"
3020
+ if [ -f "$PURPLE_LAB_PID_FILE" ]; then
3021
+ local existing_pid
3022
+ existing_pid=$(cat "$PURPLE_LAB_PID_FILE" 2>/dev/null)
3023
+ if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then
3024
+ echo -e "${GREEN}Purple Lab already running (PID: $existing_pid)${NC}"
3025
+ local url="http://${PURPLE_LAB_DEFAULT_HOST}:${port}"
3026
+ echo -e "Open: ${CYAN}$url${NC}"
3027
+ if [ "$open_browser" = true ]; then
3028
+ if command -v open &> /dev/null; then
3029
+ open "$url"
3030
+ elif command -v xdg-open &> /dev/null; then
3031
+ xdg-open "$url"
3032
+ fi
3033
+ fi
3034
+ return 0
3001
3035
  fi
3002
3036
  fi
3003
3037
 
3004
- if [ "$api_running" = false ]; then
3005
- echo -e "${CYAN}Starting dashboard server...${NC}"
3006
- cmd_dashboard_start
3007
- echo ""
3038
+ # Check if port is already in use
3039
+ if lsof -i:"$port" -sTCP:LISTEN &> /dev/null; then
3040
+ echo -e "${RED}Error: Port $port already in use${NC}"
3041
+ echo "Use --port to specify a different port, or stop the existing process."
3042
+ exit 1
3008
3043
  fi
3009
3044
 
3010
- local url="http://127.0.0.1:${DASHBOARD_DEFAULT_PORT}"
3045
+ # Start the server
3046
+ local log_dir="${LOKI_DIR}/purple-lab/logs"
3047
+ mkdir -p "$log_dir"
3048
+ local log_file="$log_dir/purple-lab.log"
3011
3049
 
3012
- echo -e "${GREEN}Web app available at: $url${NC}"
3013
- echo ""
3014
- echo -e "Stop with: ${CYAN}loki web stop${NC} or ${CYAN}loki dashboard stop${NC}"
3050
+ echo -e "${CYAN}Starting Purple Lab...${NC}"
3051
+
3052
+ PURPLE_LAB_PORT="$port" PURPLE_LAB_HOST="${PURPLE_LAB_DEFAULT_HOST}" \
3053
+ LOKI_DIR="$LOKI_DIR" LOKI_SKILL_DIR="$SKILL_DIR" PYTHONPATH="$SKILL_DIR" \
3054
+ nohup "$DASHBOARD_PYTHON" "$server_py" > "$log_file" 2>&1 &
3055
+ local pid=$!
3056
+
3057
+ echo "$pid" > "$PURPLE_LAB_PID_FILE"
3058
+ echo "$port" > "${LOKI_DIR}/purple-lab/port"
3059
+
3060
+ # Wait for server to be ready
3061
+ local retries=0
3062
+ while [ $retries -lt 15 ]; do
3063
+ if curl -s "http://${PURPLE_LAB_DEFAULT_HOST}:${port}/api/session/status" > /dev/null 2>&1; then
3064
+ break
3065
+ fi
3066
+ sleep 0.5
3067
+ retries=$((retries + 1))
3068
+ done
3069
+
3070
+ if ! kill -0 "$pid" 2>/dev/null; then
3071
+ echo -e "${RED}Error: Purple Lab failed to start${NC}"
3072
+ echo "Check logs at: $log_file"
3073
+ rm -f "$PURPLE_LAB_PID_FILE"
3074
+ exit 1
3075
+ fi
3076
+
3077
+ local url="http://${PURPLE_LAB_DEFAULT_HOST}:${port}"
3078
+ echo -e "${GREEN}Purple Lab running at: $url${NC} (PID: $pid)"
3079
+ echo -e "Logs: $log_file"
3080
+ echo -e "Stop with: ${CYAN}loki web stop${NC}"
3015
3081
 
3016
3082
  # Open browser
3017
3083
  if [ "$open_browser" = true ]; then
@@ -3029,13 +3095,45 @@ cmd_web_start() {
3029
3095
  }
3030
3096
 
3031
3097
  cmd_web_stop() {
3032
- # Web app is served by the dashboard -- stopping dashboard stops the web app
3033
- cmd_dashboard_stop
3098
+ if [ ! -f "$PURPLE_LAB_PID_FILE" ]; then
3099
+ echo "Purple Lab is not running."
3100
+ return 0
3101
+ fi
3102
+
3103
+ local pid
3104
+ pid=$(cat "$PURPLE_LAB_PID_FILE" 2>/dev/null)
3105
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
3106
+ kill "$pid" 2>/dev/null
3107
+ sleep 1
3108
+ if kill -0 "$pid" 2>/dev/null; then
3109
+ kill -9 "$pid" 2>/dev/null
3110
+ fi
3111
+ echo -e "${GREEN}Purple Lab stopped (PID: $pid)${NC}"
3112
+ else
3113
+ echo "Purple Lab was not running."
3114
+ fi
3115
+ rm -f "$PURPLE_LAB_PID_FILE"
3034
3116
  }
3035
3117
 
3036
3118
  cmd_web_status() {
3037
- # Web app is served by the dashboard -- show dashboard status
3038
- cmd_dashboard_status
3119
+ if [ ! -f "$PURPLE_LAB_PID_FILE" ]; then
3120
+ echo "Purple Lab is not running."
3121
+ return 0
3122
+ fi
3123
+
3124
+ local pid
3125
+ pid=$(cat "$PURPLE_LAB_PID_FILE" 2>/dev/null)
3126
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
3127
+ local port
3128
+ port=$(cat "${LOKI_DIR}/purple-lab/port" 2>/dev/null || echo "$PURPLE_LAB_DEFAULT_PORT")
3129
+ echo -e "${GREEN}Purple Lab is running${NC}"
3130
+ echo " PID: $pid"
3131
+ echo " URL: http://${PURPLE_LAB_DEFAULT_HOST}:${port}"
3132
+ echo " Logs: ${LOKI_DIR}/purple-lab/logs/purple-lab.log"
3133
+ else
3134
+ echo "Purple Lab is not running (stale PID file)."
3135
+ rm -f "$PURPLE_LAB_PID_FILE"
3136
+ fi
3039
3137
  }
3040
3138
 
3041
3139
  # Import GitHub issues
@@ -9337,6 +9435,9 @@ main() {
9337
9435
  test)
9338
9436
  cmd_test "$@"
9339
9437
  ;;
9438
+ report)
9439
+ cmd_report "$@"
9440
+ ;;
9340
9441
  version|--version|-v)
9341
9442
  cmd_version
9342
9443
  ;;
@@ -16425,4 +16526,592 @@ _test_gen_bats() {
16425
16526
  done
16426
16527
  }
16427
16528
 
16529
+ # Session report generator (v6.27.0)
16530
+ cmd_report() {
16531
+ local session_id=""
16532
+ local format="text"
16533
+ local output_file=""
16534
+ local include_gates=true
16535
+ local include_agents=true
16536
+ local include_timeline=true
16537
+
16538
+ while [[ $# -gt 0 ]]; do
16539
+ case "$1" in
16540
+ --help|-h)
16541
+ echo -e "${BOLD}loki report${NC} - Session report generator (v6.27.0)"
16542
+ echo ""
16543
+ echo "Usage: loki report [options]"
16544
+ echo ""
16545
+ echo "Generates a readable session report from .loki/ data."
16546
+ echo ""
16547
+ echo "Options:"
16548
+ echo " --session <id> Report for specific session (default: latest)"
16549
+ echo " --format <fmt> Output format: text, markdown, html (default: text)"
16550
+ echo " --output <file> Save to file (default: stdout)"
16551
+ echo " --no-gates Exclude quality gate details"
16552
+ echo " --no-agents Exclude agent activity"
16553
+ echo " --no-timeline Exclude event timeline"
16554
+ echo " --help, -h Show this help"
16555
+ echo ""
16556
+ echo "Examples:"
16557
+ echo " loki report # Text report to stdout"
16558
+ echo " loki report --format markdown # Markdown report"
16559
+ echo " loki report --format html -o report.html # HTML to file"
16560
+ echo " loki report --no-gates --no-agents # Minimal report"
16561
+ exit 0
16562
+ ;;
16563
+ --session) session_id="${2:-}"; shift 2 ;;
16564
+ --session=*) session_id="${1#*=}"; shift ;;
16565
+ --format) format="${2:-text}"; shift 2 ;;
16566
+ --format=*) format="${1#*=}"; shift ;;
16567
+ --output|-o) output_file="${2:-}"; shift 2 ;;
16568
+ --output=*) output_file="${1#*=}"; shift ;;
16569
+ --no-gates) include_gates=false; shift ;;
16570
+ --no-agents) include_agents=false; shift ;;
16571
+ --no-timeline) include_timeline=false; shift ;;
16572
+ --include-gates) include_gates=true; shift ;;
16573
+ --include-agents) include_agents=true; shift ;;
16574
+ --include-timeline) include_timeline=true; shift ;;
16575
+ *) echo -e "${RED}Unknown option: $1${NC}"; echo "Run 'loki report --help' for usage."; exit 1 ;;
16576
+ esac
16577
+ done
16578
+
16579
+ case "$format" in
16580
+ text|markdown|md|html) ;;
16581
+ *) echo -e "${RED}Unknown format: $format${NC}"; echo "Supported: text, markdown, html"; exit 1 ;;
16582
+ esac
16583
+
16584
+ # Normalize md -> markdown
16585
+ [ "$format" = "md" ] && format="markdown"
16586
+
16587
+ if ! command -v python3 &>/dev/null; then
16588
+ echo -e "${RED}python3 is required for report generation${NC}"
16589
+ exit 1
16590
+ fi
16591
+
16592
+ local report_output
16593
+ report_output=$(LOKI_DIR="${LOKI_DIR:-.loki}" \
16594
+ REPORT_FORMAT="$format" \
16595
+ REPORT_SESSION="$session_id" \
16596
+ REPORT_GATES="$include_gates" \
16597
+ REPORT_AGENTS="$include_agents" \
16598
+ REPORT_TIMELINE="$include_timeline" \
16599
+ python3 << 'REPORT_SCRIPT'
16600
+ import json
16601
+ import os
16602
+ import glob
16603
+ from datetime import datetime, timezone
16604
+
16605
+ loki_dir = os.environ.get("LOKI_DIR", ".loki")
16606
+ fmt = os.environ.get("REPORT_FORMAT", "text")
16607
+ session_id = os.environ.get("REPORT_SESSION", "")
16608
+ show_gates = os.environ.get("REPORT_GATES", "true") == "true"
16609
+ show_agents = os.environ.get("REPORT_AGENTS", "true") == "true"
16610
+ show_timeline = os.environ.get("REPORT_TIMELINE", "true") == "true"
16611
+
16612
+ def load_json(path):
16613
+ try:
16614
+ with open(path) as f:
16615
+ return json.load(f)
16616
+ except:
16617
+ return None
16618
+
16619
+ def load_text(path):
16620
+ try:
16621
+ with open(path) as f:
16622
+ return f.read().strip()
16623
+ except:
16624
+ return None
16625
+
16626
+ def format_duration(start_str, end_str=None):
16627
+ try:
16628
+ start = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
16629
+ if end_str:
16630
+ end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
16631
+ else:
16632
+ end = datetime.now(timezone.utc)
16633
+ delta = end - start
16634
+ hours, remainder = divmod(int(delta.total_seconds()), 3600)
16635
+ minutes, seconds = divmod(remainder, 60)
16636
+ if hours > 0:
16637
+ return f"{hours}h {minutes}m {seconds}s"
16638
+ elif minutes > 0:
16639
+ return f"{minutes}m {seconds}s"
16640
+ else:
16641
+ return f"{seconds}s"
16642
+ except:
16643
+ return "unknown"
16644
+
16645
+ # --- Gather data ---
16646
+
16647
+ # Session/autonomy state
16648
+ autonomy = load_json(os.path.join(loki_dir, "autonomy-state.json")) or {}
16649
+ orchestrator = load_json(os.path.join(loki_dir, "state", "orchestrator.json")) or {}
16650
+ metrics = load_json(os.path.join(loki_dir, "state", "metrics.json")) or {}
16651
+ config = load_json(os.path.join(loki_dir, "config.json")) or {}
16652
+ dashboard_state = load_json(os.path.join(loki_dir, "dashboard-state.json")) or {}
16653
+
16654
+ # Project info
16655
+ project_name = config.get("project_name", "")
16656
+ if not project_name:
16657
+ # Try to get from git or cwd
16658
+ try:
16659
+ project_name = os.path.basename(os.getcwd())
16660
+ except:
16661
+ project_name = "Unknown Project"
16662
+
16663
+ # Timestamps
16664
+ started_at = orchestrator.get("startedAt", autonomy.get("lastRun", ""))
16665
+ status = autonomy.get("status", orchestrator.get("currentPhase", "unknown"))
16666
+ version = orchestrator.get("version", "")
16667
+ current_phase = orchestrator.get("currentPhase", "unknown")
16668
+ retry_count = autonomy.get("retryCount", 0)
16669
+
16670
+ # Provider
16671
+ provider_file = os.path.join(loki_dir, "state", "provider")
16672
+ provider = "unknown"
16673
+ if os.path.isdir(provider_file):
16674
+ for pf in glob.glob(os.path.join(provider_file, "*.json")):
16675
+ pd = load_json(pf)
16676
+ if pd and pd.get("name"):
16677
+ provider = pd["name"]
16678
+ break
16679
+ elif os.path.isfile(provider_file):
16680
+ provider = load_text(provider_file) or "unknown"
16681
+
16682
+ # PRD
16683
+ prd_path = autonomy.get("prdPath", "")
16684
+ prd_summary = ""
16685
+ if prd_path and os.path.exists(prd_path):
16686
+ prd_text = load_text(prd_path) or ""
16687
+ # Extract first non-empty, non-heading line as summary
16688
+ for line in prd_text.split("\n"):
16689
+ stripped = line.strip()
16690
+ if stripped and not stripped.startswith("#") and len(stripped) > 20:
16691
+ prd_summary = stripped[:200]
16692
+ break
16693
+
16694
+ # Quality gates
16695
+ gate_failures = load_json(os.path.join(loki_dir, "quality", "gate-failure-count.json")) or {}
16696
+ test_results = load_json(os.path.join(loki_dir, "quality", "test-results.json")) or {}
16697
+ static_analysis = load_json(os.path.join(loki_dir, "quality", "static-analysis.json")) or {}
16698
+ gate_failure_text = load_text(os.path.join(loki_dir, "quality", "gate-failures.txt")) or ""
16699
+
16700
+ # Reviews
16701
+ reviews_dir = os.path.join(loki_dir, "quality", "reviews")
16702
+ review_files = []
16703
+ if os.path.isdir(reviews_dir):
16704
+ review_files = sorted(glob.glob(os.path.join(reviews_dir, "*.json")), reverse=True)[:5]
16705
+
16706
+ # Agents
16707
+ agents = load_json(os.path.join(loki_dir, "state", "agents.json")) or []
16708
+ if isinstance(agents, dict):
16709
+ agents = list(agents.values()) if agents else []
16710
+
16711
+ # Agent audit log
16712
+ audit_log = os.path.join(loki_dir, "logs", "agent-audit.jsonl")
16713
+ audit_entries = []
16714
+ if os.path.exists(audit_log):
16715
+ try:
16716
+ with open(audit_log) as f:
16717
+ for line in f:
16718
+ try:
16719
+ audit_entries.append(json.loads(line.strip()))
16720
+ except:
16721
+ pass
16722
+ except:
16723
+ pass
16724
+
16725
+ # Queue stats
16726
+ queue_completed = load_json(os.path.join(loki_dir, "queue", "completed.json")) or []
16727
+ queue_failed = load_json(os.path.join(loki_dir, "queue", "failed.json")) or []
16728
+ queue_pending = load_json(os.path.join(loki_dir, "queue", "pending.json")) or []
16729
+ queue_in_progress = load_json(os.path.join(loki_dir, "queue", "in-progress.json")) or []
16730
+
16731
+ # Ensure lists
16732
+ if isinstance(queue_completed, dict): queue_completed = queue_completed.get("tasks", [])
16733
+ if isinstance(queue_failed, dict): queue_failed = queue_failed.get("tasks", [])
16734
+ if isinstance(queue_pending, dict): queue_pending = queue_pending.get("tasks", [])
16735
+ if isinstance(queue_in_progress, dict): queue_in_progress = queue_in_progress.get("tasks", [])
16736
+
16737
+ # Council
16738
+ council_state = load_json(os.path.join(loki_dir, "council", "state.json")) or {}
16739
+
16740
+ # Events
16741
+ events_file = os.path.join(loki_dir, "events.jsonl")
16742
+ events = []
16743
+ if os.path.exists(events_file):
16744
+ try:
16745
+ with open(events_file) as f:
16746
+ for line in f:
16747
+ try:
16748
+ events.append(json.loads(line.strip()))
16749
+ except:
16750
+ pass
16751
+ except:
16752
+ pass
16753
+
16754
+ # Git diff stats (run git command)
16755
+ git_stats = ""
16756
+ try:
16757
+ import subprocess
16758
+ result = subprocess.run(
16759
+ ["git", "diff", "--stat", "HEAD~1", "HEAD"],
16760
+ capture_output=True, text=True, timeout=10
16761
+ )
16762
+ if result.returncode == 0 and result.stdout.strip():
16763
+ git_stats = result.stdout.strip()
16764
+ except:
16765
+ pass
16766
+
16767
+ # --- Build report sections ---
16768
+
16769
+ sections = []
16770
+
16771
+ # 1. Header
16772
+ header_lines = []
16773
+ header_lines.append(f"Project: {project_name}")
16774
+ if version:
16775
+ header_lines.append(f"Version: {version}")
16776
+ header_lines.append(f"Status: {status}")
16777
+ if started_at:
16778
+ header_lines.append(f"Started: {started_at}")
16779
+ header_lines.append(f"Duration: {format_duration(started_at)}")
16780
+ if provider != "unknown":
16781
+ header_lines.append(f"Provider: {provider}")
16782
+ if current_phase != "unknown":
16783
+ header_lines.append(f"Phase: {current_phase}")
16784
+ sections.append(("Session Overview", header_lines))
16785
+
16786
+ # 2. Executive Summary
16787
+ summary_lines = []
16788
+ tasks_done = len(queue_completed) if isinstance(queue_completed, list) else 0
16789
+ tasks_failed_count = len(queue_failed) if isinstance(queue_failed, list) else 0
16790
+ tasks_total = tasks_done + tasks_failed_count + (len(queue_pending) if isinstance(queue_pending, list) else 0) + (len(queue_in_progress) if isinstance(queue_in_progress, list) else 0)
16791
+
16792
+ if tasks_total > 0:
16793
+ summary_lines.append(f"Tasks: {tasks_done} completed, {tasks_failed_count} failed, {tasks_total} total")
16794
+ else:
16795
+ summary_lines.append("No task queue data available")
16796
+
16797
+ summary_lines.append(f"Iterations: {retry_count}")
16798
+
16799
+ mc = metrics.get("tasks_completed", 0)
16800
+ mf = metrics.get("tasks_failed", 0)
16801
+ if mc or mf:
16802
+ summary_lines.append(f"Metrics: {mc} tasks completed, {mf} failed")
16803
+
16804
+ loc_added = metrics.get("lines_of_code_added", 0)
16805
+ loc_test = metrics.get("lines_of_test_added", 0)
16806
+ if loc_added or loc_test:
16807
+ summary_lines.append(f"Lines added: {loc_added} code, {loc_test} test")
16808
+
16809
+ council_votes = council_state.get("total_votes", 0)
16810
+ if council_votes:
16811
+ approve = council_state.get("approve_votes", 0)
16812
+ reject = council_state.get("reject_votes", 0)
16813
+ summary_lines.append(f"Council: {council_votes} votes ({approve} approve, {reject} reject)")
16814
+
16815
+ if prd_summary:
16816
+ summary_lines.append(f"PRD: {prd_summary}")
16817
+
16818
+ sections.append(("Executive Summary", summary_lines))
16819
+
16820
+ # 3. RARV Cycles
16821
+ rarv_lines = []
16822
+ rarv_lines.append(f"Total iterations: {retry_count}")
16823
+ rarv_lines.append(f"Max retries: {autonomy.get('maxRetries', 'N/A')}")
16824
+ rarv_lines.append(f"Current phase: {current_phase}")
16825
+ if autonomy.get("lastExitCode") is not None:
16826
+ rarv_lines.append(f"Last exit code: {autonomy.get('lastExitCode')}")
16827
+ sections.append(("RARV Cycles", rarv_lines))
16828
+
16829
+ # 4. Agent Activity
16830
+ if show_agents:
16831
+ agent_lines = []
16832
+ if agents:
16833
+ for a in agents:
16834
+ if isinstance(a, dict):
16835
+ aid = a.get("agent_id", a.get("agent_type", "unknown"))
16836
+ atype = a.get("agent_type", "")
16837
+ astatus = a.get("status", "unknown")
16838
+ model = a.get("model", "")
16839
+ completed = a.get("tasks_completed", [])
16840
+ task_count = len(completed) if isinstance(completed, list) else completed
16841
+ desc = f"{aid}"
16842
+ if atype:
16843
+ desc += f" ({atype})"
16844
+ if model:
16845
+ desc += f" [{model}]"
16846
+ desc += f" - {astatus}"
16847
+ if task_count:
16848
+ desc += f", {task_count} tasks done"
16849
+ agent_lines.append(desc)
16850
+ else:
16851
+ agent_lines.append("No agent data recorded")
16852
+
16853
+ if audit_entries:
16854
+ # Summarize by action type
16855
+ action_counts = {}
16856
+ for entry in audit_entries:
16857
+ action = entry.get("action", "unknown")
16858
+ action_counts[action] = action_counts.get(action, 0) + 1
16859
+ agent_lines.append("")
16860
+ agent_lines.append(f"Audit log: {len(audit_entries)} entries")
16861
+ for action, count in sorted(action_counts.items(), key=lambda x: -x[1])[:10]:
16862
+ agent_lines.append(f" {action}: {count}")
16863
+
16864
+ sections.append(("Agent Activity", agent_lines))
16865
+
16866
+ # 5. Quality Gates
16867
+ if show_gates:
16868
+ gate_lines = []
16869
+
16870
+ gate_names = [
16871
+ "static_analysis", "test_coverage", "code_review",
16872
+ "security_scan", "lint", "type_check",
16873
+ "integration_test", "e2e_test", "performance"
16874
+ ]
16875
+
16876
+ if gate_failures:
16877
+ for gate in gate_names:
16878
+ failures = gate_failures.get(gate, 0)
16879
+ if failures > 0:
16880
+ gate_lines.append(f" {gate}: {failures} failure(s)")
16881
+ else:
16882
+ gate_lines.append(f" {gate}: PASS")
16883
+ if not any(gate_failures.get(g, 0) for g in gate_names):
16884
+ # Check if we have any non-standard gates
16885
+ for g, c in gate_failures.items():
16886
+ if g not in gate_names:
16887
+ gate_lines.append(f" {g}: {c} failure(s)")
16888
+ else:
16889
+ gate_lines.append("No quality gate data recorded")
16890
+
16891
+ if test_results:
16892
+ gate_lines.append("")
16893
+ runner = test_results.get("runner", "unknown")
16894
+ passed = test_results.get("pass", "unknown")
16895
+ gate_lines.append(f"Test runner: {runner}")
16896
+ gate_lines.append(f"Tests passed: {passed}")
16897
+ if test_results.get("summary"):
16898
+ gate_lines.append(f"Summary: {test_results['summary'][:200]}")
16899
+
16900
+ if gate_failure_text:
16901
+ gate_lines.append("")
16902
+ gate_lines.append("Gate failure details:")
16903
+ for line in gate_failure_text.split("\n")[:10]:
16904
+ gate_lines.append(f" {line}")
16905
+
16906
+ sections.append(("Quality Gates", gate_lines))
16907
+
16908
+ # 6. What Changed
16909
+ change_lines = []
16910
+ if git_stats:
16911
+ for line in git_stats.split("\n"):
16912
+ change_lines.append(line)
16913
+ else:
16914
+ change_lines.append("No git diff data available (run from project root)")
16915
+ sections.append(("What Changed", change_lines))
16916
+
16917
+ # 7. Tests
16918
+ test_lines = []
16919
+ if test_results:
16920
+ test_lines.append(f"Runner: {test_results.get('runner', 'unknown')}")
16921
+ test_lines.append(f"Passed: {test_results.get('pass', 'unknown')}")
16922
+ test_lines.append(f"Timestamp: {test_results.get('timestamp', 'unknown')}")
16923
+ if test_results.get("min_coverage"):
16924
+ test_lines.append(f"Min coverage target: {test_results['min_coverage']}%")
16925
+ else:
16926
+ test_lines.append("No test result data recorded")
16927
+ sections.append(("Tests", test_lines))
16928
+
16929
+ # 8. Key Decisions (from events and council)
16930
+ decision_lines = []
16931
+ verdicts = council_state.get("verdicts", [])
16932
+ if verdicts:
16933
+ for v in verdicts[-10:]:
16934
+ result = v.get("result", "UNKNOWN")
16935
+ iteration = v.get("iteration", "?")
16936
+ ts = v.get("timestamp", "")
16937
+ approve = v.get("approve", 0)
16938
+ reject = v.get("reject", 0)
16939
+ decision_lines.append(f"Iteration {iteration}: {result} ({approve} approve / {reject} reject) {ts}")
16940
+
16941
+ stagnation = council_state.get("consecutive_no_change", 0)
16942
+ if stagnation > 0:
16943
+ decision_lines.append(f"Stagnation streak: {stagnation} iterations with no change")
16944
+
16945
+ done_signals = council_state.get("done_signals", 0)
16946
+ if done_signals > 0:
16947
+ decision_lines.append(f"Done signals detected: {done_signals}")
16948
+
16949
+ if not decision_lines:
16950
+ decision_lines.append("No key decisions recorded")
16951
+ sections.append(("Key Decisions", decision_lines))
16952
+
16953
+ # 9. Timeline (from events)
16954
+ if show_timeline and events:
16955
+ timeline_lines = []
16956
+ for evt in events[-20:]:
16957
+ ts = evt.get("timestamp", evt.get("ts", ""))
16958
+ etype = evt.get("type", evt.get("event", "unknown"))
16959
+ data = evt.get("data", evt.get("payload", {}))
16960
+ summary = ""
16961
+ if isinstance(data, dict):
16962
+ summary = data.get("message", data.get("description", json.dumps(data)[:80]))
16963
+ elif isinstance(data, str):
16964
+ summary = data[:80]
16965
+ if ts:
16966
+ timeline_lines.append(f"[{ts}] {etype}: {summary}")
16967
+ else:
16968
+ timeline_lines.append(f"{etype}: {summary}")
16969
+ if timeline_lines:
16970
+ sections.append(("Timeline", timeline_lines))
16971
+
16972
+ # --- Render ---
16973
+
16974
+ def render_text(sections):
16975
+ lines = []
16976
+ width = 72
16977
+ lines.append("=" * width)
16978
+ lines.append(" LOKI MODE SESSION REPORT")
16979
+ lines.append("=" * width)
16980
+ lines.append("")
16981
+ for title, content in sections:
16982
+ lines.append(f"--- {title} ---")
16983
+ for line in content:
16984
+ lines.append(f" {line}")
16985
+ lines.append("")
16986
+ lines.append("-" * width)
16987
+ lines.append(f" Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
16988
+ lines.append(f" Loki Mode v{version or 'unknown'}")
16989
+ lines.append("-" * width)
16990
+ return "\n".join(lines)
16991
+
16992
+ def render_markdown(sections):
16993
+ lines = []
16994
+ lines.append("# Loki Mode Session Report")
16995
+ lines.append("")
16996
+ for title, content in sections:
16997
+ lines.append(f"## {title}")
16998
+ lines.append("")
16999
+ for line in content:
17000
+ if line.strip() == "":
17001
+ lines.append("")
17002
+ elif line.strip().startswith(" "):
17003
+ lines.append(f"- {line.strip()}")
17004
+ else:
17005
+ lines.append(line)
17006
+ lines.append("")
17007
+ lines.append("---")
17008
+ lines.append(f"*Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')} | Loki Mode v{version or 'unknown'}*")
17009
+ return "\n".join(lines)
17010
+
17011
+ def render_html(sections):
17012
+ lines = []
17013
+ lines.append("<!DOCTYPE html>")
17014
+ lines.append("<html lang=\"en\">")
17015
+ lines.append("<head>")
17016
+ lines.append("<meta charset=\"UTF-8\">")
17017
+ lines.append("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">")
17018
+ lines.append("<title>Loki Mode Session Report</title>")
17019
+ lines.append("<style>")
17020
+ lines.append("""
17021
+ body {
17022
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
17023
+ max-width: 800px;
17024
+ margin: 40px auto;
17025
+ padding: 0 20px;
17026
+ background: #0d1117;
17027
+ color: #c9d1d9;
17028
+ line-height: 1.6;
17029
+ }
17030
+ h1 {
17031
+ color: #58a6ff;
17032
+ border-bottom: 2px solid #30363d;
17033
+ padding-bottom: 12px;
17034
+ }
17035
+ h2 {
17036
+ color: #8b949e;
17037
+ font-size: 1.1em;
17038
+ text-transform: uppercase;
17039
+ letter-spacing: 1px;
17040
+ margin-top: 32px;
17041
+ border-bottom: 1px solid #21262d;
17042
+ padding-bottom: 8px;
17043
+ }
17044
+ .section-content {
17045
+ background: #161b22;
17046
+ border: 1px solid #30363d;
17047
+ border-radius: 6px;
17048
+ padding: 16px;
17049
+ margin: 12px 0;
17050
+ font-family: 'SF Mono', 'Fira Code', monospace;
17051
+ font-size: 0.9em;
17052
+ white-space: pre-wrap;
17053
+ }
17054
+ .footer {
17055
+ margin-top: 40px;
17056
+ padding-top: 16px;
17057
+ border-top: 1px solid #30363d;
17058
+ color: #484f58;
17059
+ font-size: 0.85em;
17060
+ }
17061
+ .pass { color: #3fb950; }
17062
+ .fail { color: #f85149; }
17063
+ .warn { color: #d29922; }
17064
+ """)
17065
+ lines.append("</style>")
17066
+ lines.append("</head>")
17067
+ lines.append("<body>")
17068
+ lines.append("<h1>Loki Mode Session Report</h1>")
17069
+
17070
+ for title, content in sections:
17071
+ lines.append(f"<h2>{_html_escape(title)}</h2>")
17072
+ lines.append("<div class=\"section-content\">")
17073
+ for line in content:
17074
+ escaped = _html_escape(line)
17075
+ # Highlight pass/fail
17076
+ if "PASS" in line:
17077
+ escaped = escaped.replace("PASS", "<span class=\"pass\">PASS</span>")
17078
+ if "failure" in line.lower() or "FAIL" in line.lower() or "failed" in line.lower():
17079
+ escaped = escaped.replace("failure", "<span class=\"fail\">failure</span>")
17080
+ escaped = escaped.replace("FAIL", "<span class=\"fail\">FAIL</span>")
17081
+ escaped = escaped.replace("failed", "<span class=\"fail\">failed</span>")
17082
+ escaped = escaped.replace("Failed", "<span class=\"fail\">Failed</span>")
17083
+ lines.append(escaped)
17084
+ lines.append("</div>")
17085
+
17086
+ lines.append("<div class=\"footer\">")
17087
+ ts = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
17088
+ lines.append(f"Generated: {ts} | Loki Mode v{_html_escape(version or 'unknown')}")
17089
+ lines.append("</div>")
17090
+ lines.append("</body>")
17091
+ lines.append("</html>")
17092
+ return "\n".join(lines)
17093
+
17094
+ def _html_escape(s):
17095
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
17096
+
17097
+ if fmt == "text":
17098
+ print(render_text(sections))
17099
+ elif fmt == "markdown":
17100
+ print(render_markdown(sections))
17101
+ elif fmt == "html":
17102
+ print(render_html(sections))
17103
+ REPORT_SCRIPT
17104
+ ) || {
17105
+ echo -e "${RED}Report generation failed${NC}"
17106
+ exit 1
17107
+ }
17108
+
17109
+ if [ -n "$output_file" ]; then
17110
+ echo "$report_output" > "$output_file"
17111
+ echo -e "${GREEN}Report saved to: $output_file${NC}"
17112
+ else
17113
+ echo "$report_output"
17114
+ fi
17115
+ }
17116
+
16428
17117
  main "$@"