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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +720 -31
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +1 -4
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/web-app/dist/assets/index-CxHyjUh7.css +1 -0
- package/web-app/dist/assets/index-RLTH4m8k.js +65 -0
- package/web-app/dist/index.html +17 -0
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
|
-
#
|
|
2911
|
-
#
|
|
2912
|
-
#
|
|
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
|
|
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
|
|
2945
|
-
echo " stop Stop
|
|
2946
|
-
echo " status Show
|
|
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 "
|
|
2953
|
-
echo "
|
|
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
|
|
2957
|
-
echo " loki web --no-open Start
|
|
2958
|
-
echo " loki web stop Stop the
|
|
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
|
-
#
|
|
2995
|
-
local
|
|
2996
|
-
if [ -f "$
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
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
|
|
3005
|
-
|
|
3006
|
-
|
|
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
|
-
|
|
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 "${
|
|
3013
|
-
|
|
3014
|
-
|
|
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
|
-
|
|
3033
|
-
|
|
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
|
-
|
|
3038
|
-
|
|
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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
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 "$@"
|