diff --git a/commands/start-rlcr-loop.md b/commands/start-rlcr-loop.md index f24fb156..f5ae41ca 100644 --- a/commands/start-rlcr-loop.md +++ b/commands/start-rlcr-loop.md @@ -3,6 +3,7 @@ description: "Start iterative loop with Codex review" argument-hint: "[path/to/plan.md | --plan-file path/to/plan.md] [--max N] [--codex-model MODEL:EFFORT] [--codex-timeout SECONDS] [--track-plan-file] [--push-every-round] [--base-branch BRANCH] [--full-review-round N] [--skip-impl] [--claude-answer-codex] [--agent-teams] [--yolo] [--skip-quiz] [--privacy]" allowed-tools: - "Bash(${CLAUDE_PLUGIN_ROOT}/scripts/setup-rlcr-loop.sh:*)" + - "Bash(${CLAUDE_PLUGIN_ROOT}/viz/scripts/viz-start.sh:*)" - "Read" - "Task" - "AskUserQuestion" @@ -114,6 +115,18 @@ If the pre-check passed (or was skipped), and the quiz passed (or was skipped or "${CLAUDE_PLUGIN_ROOT}/scripts/setup-rlcr-loop.sh" $ARGUMENTS ``` +### Viz Dashboard Offer + +After the setup script completes, check its output for the marker `VIZ_AVAILABLE=`. If found: + +1. Use `AskUserQuestion` to ask: "A web visualization dashboard is available for this RLCR session. Would you like to open it?" + - Option 1: "Yes, open dashboard" — Run the viz start script shown in the `VIZ_AVAILABLE` line, passing the project path from the `VIZ_PROJECT` line + - Option 2: "No, skip" — Print: "You can open the dashboard later with: `/humanize:viz start`" + +If the marker is not found (viz not available), skip this step silently. + +--- + This command starts an iterative development loop where: 1. You execute the implementation plan with task-tag routing diff --git a/commands/viz.md b/commands/viz.md new file mode 100644 index 00000000..644760b7 --- /dev/null +++ b/commands/viz.md @@ -0,0 +1,45 @@ +# Humanize Viz Dashboard + +Manage the local web visualization dashboard for RLCR loop sessions. + +## Usage + +``` +/humanize:viz +``` + +### Subcommands + +- `start` — Launch the dashboard server (creates venv on first run, opens browser) +- `stop` — Stop the dashboard server +- `restart` — Restart the dashboard server +- `status` — Check if the dashboard server is running + +## Implementation + +Run the appropriate shell script based on the subcommand: + +```bash +# Determine paths +VIZ_DIR="${CLAUDE_PLUGIN_ROOT}/viz/scripts" +PROJECT_DIR="$(pwd)" + +# Route subcommand +case "$1" in + start) bash "$VIZ_DIR/viz-start.sh" "$PROJECT_DIR" ;; + stop) bash "$VIZ_DIR/viz-stop.sh" "$PROJECT_DIR" ;; + restart) bash "$VIZ_DIR/viz-restart.sh" "$PROJECT_DIR" ;; + status) bash "$VIZ_DIR/viz-status.sh" "$PROJECT_DIR" ;; + *) bash "$VIZ_DIR/viz-status.sh" "$PROJECT_DIR" ;; +esac +``` + +If no subcommand is provided, default to `status`. + +## Requirements + +- Python 3 (for Flask server) +- tmux (for background process management) +- A `.humanize/` directory in the project (created by RLCR loop) + +The first `start` will automatically create a Python venv in `.humanize/viz-venv/` and install dependencies. diff --git a/scripts/cancel-rlcr-loop.sh b/scripts/cancel-rlcr-loop.sh index 8ec303f0..a0c6e0a9 100755 --- a/scripts/cancel-rlcr-loop.sh +++ b/scripts/cancel-rlcr-loop.sh @@ -165,6 +165,12 @@ mv "$ACTIVE_STATE_FILE" "$LOOP_DIR/cancel-state.md" # Output Result # ======================================== +# Stop viz dashboard if running +VIZ_STOP="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/../viz/scripts/viz-stop.sh" +if [[ -f "$VIZ_STOP" ]]; then + bash "$VIZ_STOP" "$PROJECT_ROOT" 2>/dev/null || true +fi + if [[ "$LOOP_STATE" == "NORMAL_LOOP" ]]; then echo "CANCELLED" echo "Cancelled RLCR loop (was at round $CURRENT_ROUND of $MAX_ITERATIONS)." diff --git a/scripts/setup-rlcr-loop.sh b/scripts/setup-rlcr-loop.sh index 9d45363c..89ccf535 100755 --- a/scripts/setup-rlcr-loop.sh +++ b/scripts/setup-rlcr-loop.sh @@ -1500,6 +1500,15 @@ To cancel: /humanize:cancel-rlcr-loop EOF fi +# ─── Viz Dashboard Availability Marker ─── +# Output a marker that the command handler can detect to prompt the user +VIZ_SCRIPT="$SCRIPT_DIR/../viz/scripts/viz-start.sh" +if [[ -f "$VIZ_SCRIPT" ]] && command -v tmux &>/dev/null && command -v python3 &>/dev/null; then + echo "" + echo "VIZ_AVAILABLE=$VIZ_SCRIPT" + echo "VIZ_PROJECT=$PROJECT_ROOT" +fi + # Output the initial prompt cat "$LOOP_DIR/round-0-prompt.md" diff --git a/skills/humanize-viz/SKILL.md b/skills/humanize-viz/SKILL.md new file mode 100644 index 00000000..74860bab --- /dev/null +++ b/skills/humanize-viz/SKILL.md @@ -0,0 +1,36 @@ +--- +name: humanize-viz +description: Launch and manage the RLCR loop visualization dashboard — a local web UI showing round nodes, session analytics, and methodology reports +triggers: + - viz + - dashboard + - visualization +--- + +# Humanize Viz + +Manage the RLCR loop visualization dashboard. + +## How to Use + +Route the subcommand to the appropriate viz script: + +```bash +"${CLAUDE_PLUGIN_ROOT}/viz/scripts/viz-start.sh" "" # start +"${CLAUDE_PLUGIN_ROOT}/viz/scripts/viz-stop.sh" "" # stop +"${CLAUDE_PLUGIN_ROOT}/viz/scripts/viz-status.sh" "" # status +``` + +Where `` is the current working directory. + +### Subcommands + +- **start**: Creates a Python venv (first run), installs Flask dependencies, finds an available port (18000-18099), launches the server in a tmux session, and opens the browser. +- **stop**: Kills the tmux session and cleans up the port file. +- **status**: Reports whether the server is running and its URL. + +### Prerequisites + +- Python 3 with venv support +- tmux +- A `.humanize/` directory in the project diff --git a/tests/test-viz.sh b/tests/test-viz.sh new file mode 100755 index 00000000..abc02edf --- /dev/null +++ b/tests/test-viz.sh @@ -0,0 +1,443 @@ +#!/usr/bin/env bash +# +# Tests for the Humanize Viz dashboard functionality +# +# Tests cover: +# - viz-start.sh / viz-stop.sh / viz-status.sh script behavior +# - Python parser module (syntax + basic functionality) +# - Python analyzer module +# - Python exporter module +# - Sanitized issue generation +# - Setup script viz marker output +# - Cancel script viz stop integration +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/test-helpers.sh" + +PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +VIZ_DIR="$PLUGIN_ROOT/viz" +SERVER_DIR="$VIZ_DIR/server" + +echo "========================================" +echo "Humanize Viz Dashboard Tests" +echo "========================================" + +# ─── Pre-check ─── +if ! command -v python3 &>/dev/null; then + echo "SKIP: python3 not available" + exit 0 +fi + +setup_test_dir + +# ======================================== +# Test Group 1: Shell Script Validation +# ======================================== +echo "" +echo "Test Group 1: Shell Script Syntax" + +for script in viz-start.sh viz-stop.sh viz-status.sh; do + if bash -n "$VIZ_DIR/scripts/$script" 2>/dev/null; then + pass "Shell syntax valid: $script" + else + fail "Shell syntax invalid: $script" + fi +done + +# ======================================== +# Test Group 2: Python Module Syntax +# ======================================== +echo "" +echo "Test Group 2: Python Module Syntax" + +for module in parser.py analyzer.py exporter.py app.py watcher.py; do + if python3 -m py_compile "$SERVER_DIR/$module" 2>/dev/null; then + pass "Python syntax valid: $module" + else + fail "Python syntax invalid: $module" + fi +done + +# ======================================== +# Test Group 3: Parser Tests +# ======================================== +echo "" +echo "Test Group 3: Parser Functionality" + +# Create a mock RLCR session +MOCK_PROJECT="$TEST_DIR/project" +MOCK_SESSION="$MOCK_PROJECT/.humanize/rlcr/2026-01-01_12-00-00" +mkdir -p "$MOCK_SESSION" + +# Create state.md +cat > "$MOCK_SESSION/state.md" << 'STATE' +--- +current_round: 2 +max_iterations: 42 +plan_file: plan.md +start_branch: main +base_branch: main +codex_model: gpt-5.4 +codex_effort: high +started_at: 2026-01-01T12:00:00Z +--- +STATE + +# Create goal-tracker.md +cat > "$MOCK_SESSION/goal-tracker.md" << 'GT' +## IMMUTABLE SECTION + +### Ultimate Goal +Build a test feature. + +### Acceptance Criteria + +- AC-1: First criterion +- AC-2: Second criterion + +--- + +## MUTABLE SECTION + +### Plan Version: 1 (Updated: Round 0) + +#### Active Tasks +| Task | Target AC | Status | Tag | Owner | Notes | +|------|-----------|--------|-----|-------|-------| +| task1 | AC-1 | completed | coding | claude | Done | +| task2 | AC-2 | in_progress | coding | claude | WIP | + +### Completed and Verified +| AC | Task | Completed Round | Verified Round | Evidence | +|----|------|-----------------|----------------|----------| +| AC-1 | task1 | 1 | 1 | Tests pass | + +### Explicitly Deferred +| Task | Original AC | Deferred Since | Justification | When to Reconsider | +|------|-------------|----------------|---------------|-------------------| +GT + +# Create round summaries +cat > "$MOCK_SESSION/round-0-summary.md" << 'R0' +# Round 0 Summary +## What Was Implemented +Initial setup completed. 2/4 tasks done. +## BitLesson Delta +Action: none +R0 + +cat > "$MOCK_SESSION/round-1-summary.md" << 'R1' +# Round 1 Summary +## What Was Implemented +Implemented main feature. +## BitLesson Delta +Action: add +R1 + +# Create review result +cat > "$MOCK_SESSION/round-0-review-result.md" << 'RR0' +# Round 0 Review +Mainline Progress Verdict: ADVANCED +The implementation is progressing well. +RR0 + +# Test parser +PARSER_OUTPUT=$(python3 -c " +import sys +sys.path.insert(0, '$SERVER_DIR') +from parser import parse_session, list_sessions, is_valid_session + +# Test is_valid_session +assert is_valid_session('$MOCK_SESSION'), 'should be valid session' + +# Test parse_session +s = parse_session('$MOCK_SESSION') +assert s['id'] == '2026-01-01_12-00-00', f'id mismatch: {s[\"id\"]}' +assert s['status'] == 'active', f'status: {s[\"status\"]}' +assert s['current_round'] == 2, f'round: {s[\"current_round\"]}' +assert s['max_iterations'] == 42 +assert s['plan_file'] == 'plan.md' +assert s['start_branch'] == 'main' +assert s['codex_model'] == 'gpt-5.4' + +# Rounds: should have 3 (0, 1, 2) even though round 2 has no summary +assert len(s['rounds']) == 3, f'expected 3 rounds, got {len(s[\"rounds\"])}' +assert s['rounds'][0]['number'] == 0 +assert s['rounds'][2]['number'] == 2 + +# Round 0 should have summary content +r0_summary = s['rounds'][0]['summary'] +assert r0_summary is not None and (isinstance(r0_summary, dict) or isinstance(r0_summary, str)), 'round 0 should have summary' + +# Round 2 should have null summary (no file) +r2_summary = s['rounds'][2]['summary'] +if isinstance(r2_summary, dict): + assert r2_summary.get('en') is None and r2_summary.get('zh') is None, 'round 2 summary should be null' + +# Verdict from review +assert s['rounds'][0]['verdict'] == 'advanced', f'verdict: {s[\"rounds\"][0][\"verdict\"]}' + +# Goal tracker +gt = s['goal_tracker'] +assert gt is not None +assert len(gt['acceptance_criteria']) == 2 +assert gt['acceptance_criteria'][0]['id'] == 'AC-1' + +# Completed and Verified parsing +assert len(gt['completed_verified']) == 1 +assert gt['completed_verified'][0]['ac'] == 'AC-1' + +# AC status from completed table +assert any(ac['status'] == 'completed' for ac in gt['acceptance_criteria']), 'AC-1 should be completed' + +# Task counts +assert s['tasks_total'] == 3, f'tasks_total: {s[\"tasks_total\"]}' # 2 active + 1 completed +assert s['tasks_done'] == 1, f'tasks_done: {s[\"tasks_done\"]}' + +# Test list_sessions +sessions = list_sessions('$MOCK_PROJECT') +assert len(sessions) == 1 +assert sessions[0]['id'] == '2026-01-01_12-00-00' + +print('ALL_PARSER_TESTS_PASSED') +" 2>&1) + +if echo "$PARSER_OUTPUT" | grep -q "ALL_PARSER_TESTS_PASSED"; then + pass "Parser: parse_session with full mock data" + pass "Parser: canonical round indices (0..current_round)" + pass "Parser: goal tracker with Completed and Verified" + pass "Parser: list_sessions" + pass "Parser: is_valid_session" +else + fail "Parser tests" "" "$PARSER_OUTPUT" +fi + +# Test malformed session skip +MALFORMED_SESSION="$MOCK_PROJECT/.humanize/rlcr/2026-01-01_13-00-00" +mkdir -p "$MALFORMED_SESSION" +echo "garbage" > "$MALFORMED_SESSION/readme.txt" + +SKIP_OUTPUT=$(python3 -c " +import sys +sys.path.insert(0, '$SERVER_DIR') +from parser import is_valid_session +assert not is_valid_session('$MALFORMED_SESSION'), 'should not be valid' +print('SKIP_OK') +" 2>&1) + +if echo "$SKIP_OUTPUT" | grep -q "SKIP_OK"; then + pass "Parser: skips malformed session (no state.md)" +else + fail "Parser: malformed session skip" "" "$SKIP_OUTPUT" +fi + +# ======================================== +# Test Group 4: Analyzer Tests +# ======================================== +echo "" +echo "Test Group 4: Analyzer" + +cd "$PLUGIN_ROOT" +ANALYZER_OUTPUT=$(python3 -c " +import sys +sys.path.insert(0, '$SERVER_DIR') +from analyzer import compute_analytics + +# Empty +result = compute_analytics([]) +assert result['overview']['total_sessions'] == 0 +assert result['overview']['completion_rate'] == 0 + +# With mock session +mock = { + 'id': '2026-01-01_12-00-00', + 'current_round': 3, + 'status': 'complete', + 'ac_done': 2, 'ac_total': 4, + 'rounds': [ + {'number': 0, 'verdict': 'advanced', 'review_result': 'some review', 'bitlesson_delta': 'add', 'phase': 'implementation', 'p_issues': {}, 'duration_minutes': 10}, + {'number': 1, 'verdict': 'advanced', 'review_result': 'review 2', 'bitlesson_delta': 'none', 'phase': 'implementation', 'p_issues': {'P1': 1}, 'duration_minutes': 15}, + {'number': 2, 'verdict': 'complete', 'review_result': 'final', 'bitlesson_delta': 'none', 'phase': 'code_review', 'p_issues': {}, 'duration_minutes': 5}, + ] +} +result = compute_analytics([mock]) +assert result['overview']['total_sessions'] == 1 +assert result['overview']['completed_sessions'] == 1 +assert result['overview']['completion_rate'] == 100.0 + +# Verdict distribution should not include rounds without review_result +vd = result['verdict_distribution'] +assert 'advanced' in vd +assert vd['advanced'] == 2 +assert vd.get('unknown', 0) == 0, 'unknown should not appear for reviewed rounds' + +print('ANALYZER_OK') +" 2>&1) + +if echo "$ANALYZER_OUTPUT" | grep -q "ANALYZER_OK"; then + pass "Analyzer: empty sessions" + pass "Analyzer: basic statistics" + pass "Analyzer: verdict distribution excludes non-reviewed rounds" +else + fail "Analyzer tests" "" "$ANALYZER_OUTPUT" +fi + +# ======================================== +# Test Group 5: Exporter Tests +# ======================================== +echo "" +echo "Test Group 5: Exporter" + +EXPORTER_OUTPUT=$(python3 -c " +import sys +sys.path.insert(0, '$SERVER_DIR') +from exporter import export_session_markdown + +mock = { + 'id': '2026-01-01_12-00-00', + 'status': 'complete', + 'current_round': 2, + 'plan_file': 'plan.md', + 'start_branch': 'main', + 'started_at': '2026-01-01T12:00:00Z', + 'codex_model': 'gpt-5.4', + 'last_verdict': 'advanced', + 'ac_total': 2, 'ac_done': 2, + 'rounds': [ + {'number': 0, 'phase': 'implementation', 'verdict': 'unknown', 'duration_minutes': None, + 'bitlesson_delta': 'none', 'summary': {'en': '# Round 0', 'zh': None}, 'review_result': {'en': None, 'zh': None}}, + {'number': 1, 'phase': 'implementation', 'verdict': 'advanced', 'duration_minutes': 15.0, + 'bitlesson_delta': 'add', 'summary': {'en': '# Round 1 done', 'zh': None}, 'review_result': {'en': 'ADVANCED', 'zh': None}}, + ], + 'goal_tracker': { + 'ultimate_goal': 'Test goal', + 'acceptance_criteria': [ + {'id': 'AC-1', 'description': 'First', 'status': 'completed'}, + {'id': 'AC-2', 'description': 'Second', 'status': 'completed'}, + ] + }, + 'methodology_report': {'en': '# Report', 'zh': None}, +} + +md = export_session_markdown(mock) +assert 'RLCR Session Report' in md +assert '2026-01-01_12-00-00' in md +assert 'Round 0' in md +assert 'Round 1 done' in md +assert 'AC-1' in md +assert '# Report' in md +assert isinstance(md, str), 'output must be string, not dict' + +print('EXPORTER_OK') +" 2>&1) + +if echo "$EXPORTER_OUTPUT" | grep -q "EXPORTER_OK"; then + pass "Exporter: generates valid Markdown from bilingual session" + pass "Exporter: handles {zh,en} dicts without TypeError" +else + fail "Exporter tests" "" "$EXPORTER_OUTPUT" +fi + +# ======================================== +# Test Group 6: Setup Script Viz Marker +# ======================================== +echo "" +echo "Test Group 6: Integration Markers" + +if grep -q "VIZ_AVAILABLE=" "$PLUGIN_ROOT/scripts/setup-rlcr-loop.sh"; then + pass "Setup script contains VIZ_AVAILABLE marker" +else + fail "Setup script missing VIZ_AVAILABLE marker" +fi + +if grep -q "VIZ_PROJECT=" "$PLUGIN_ROOT/scripts/setup-rlcr-loop.sh"; then + pass "Setup script contains VIZ_PROJECT marker" +else + fail "Setup script missing VIZ_PROJECT marker" +fi + +# Cancel script should stop viz +if grep -q "viz-stop.sh" "$PLUGIN_ROOT/scripts/cancel-rlcr-loop.sh"; then + pass "Cancel script calls viz-stop.sh" +else + fail "Cancel script missing viz-stop.sh call" +fi + +# Start-rlcr-loop.md should have viz instruction +if grep -q "VIZ_AVAILABLE" "$PLUGIN_ROOT/commands/start-rlcr-loop.md"; then + pass "Start command has viz dashboard prompt instruction" +else + fail "Start command missing viz prompt instruction" +fi + +if grep -q "viz-start.sh" "$PLUGIN_ROOT/commands/start-rlcr-loop.md"; then + pass "Start command allows viz-start.sh in tools" +else + fail "Start command missing viz-start.sh tool permission" +fi + +# ======================================== +# Test Group 7: Viz Command & Skill +# ======================================== +echo "" +echo "Test Group 7: Command & Skill" + +if [[ -f "$PLUGIN_ROOT/commands/viz.md" ]]; then + pass "Viz command definition exists" +else + fail "Viz command definition missing" +fi + +if [[ -f "$PLUGIN_ROOT/skills/humanize-viz/SKILL.md" ]]; then + pass "Viz skill definition exists" +else + fail "Viz skill definition missing" +fi + +if grep -q "start" "$PLUGIN_ROOT/commands/viz.md" && grep -q "stop" "$PLUGIN_ROOT/commands/viz.md" && grep -q "status" "$PLUGIN_ROOT/commands/viz.md"; then + pass "Viz command documents start/stop/status subcommands" +else + fail "Viz command missing subcommand documentation" +fi + +# ======================================== +# Test Group 8: Static Assets +# ======================================== +echo "" +echo "Test Group 8: Static Assets" + +for file in index.html css/theme.css css/layout.css js/app.js js/pipeline.js js/charts.js js/actions.js js/i18n.js; do + if [[ -f "$VIZ_DIR/static/$file" ]]; then + pass "Static file exists: $file" + else + fail "Static file missing: $file" + fi +done + +# Verify no hard-coded Chinese in i18n.js (UI should be English-only) +if ! grep -P '[\x{4e00}-\x{9fff}]' "$VIZ_DIR/static/js/i18n.js" >/dev/null 2>&1; then + pass "i18n.js contains no Chinese characters (English-only UI)" +else + fail "i18n.js should not contain Chinese characters" +fi + +# Requirements file +if [[ -f "$VIZ_DIR/server/requirements.txt" ]]; then + pass "Python requirements.txt exists" + if grep -q "flask" "$VIZ_DIR/server/requirements.txt"; then + pass "requirements.txt includes flask" + else + fail "requirements.txt missing flask" + fi +else + fail "Python requirements.txt missing" +fi + +# ======================================== +# Summary +# ======================================== + +print_test_summary "Humanize Viz Tests" diff --git a/viz/scripts/viz-restart.sh b/viz/scripts/viz-restart.sh new file mode 100755 index 00000000..738338aa --- /dev/null +++ b/viz/scripts/viz-restart.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Restart the Humanize Viz dashboard server. +# Usage: viz-restart.sh [--project ] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="${1:-.}" +PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)" + +bash "$SCRIPT_DIR/viz-stop.sh" "$PROJECT_DIR" 2>/dev/null || true +sleep 1 +exec bash "$SCRIPT_DIR/viz-start.sh" "$PROJECT_DIR" diff --git a/viz/scripts/viz-start.sh b/viz/scripts/viz-start.sh new file mode 100755 index 00000000..01370b3a --- /dev/null +++ b/viz/scripts/viz-start.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Launch the Humanize Viz dashboard server in a tmux session. +# Usage: viz-start.sh [--project ] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VIZ_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_DIR="${1:-.}" +PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)" + +HUMANIZE_DIR="$PROJECT_DIR/.humanize" +VENV_DIR="$HUMANIZE_DIR/viz-venv" +PORT_FILE="$HUMANIZE_DIR/viz.port" +TMUX_SESSION="humanize-viz" +REQUIREMENTS="$VIZ_ROOT/server/requirements.txt" +APP_ENTRY="$VIZ_ROOT/server/app.py" +STATIC_DIR="$VIZ_ROOT/static" + +# Check .humanize/ directory exists +if [[ ! -d "$HUMANIZE_DIR" ]]; then + echo "Error: No .humanize/ directory found in $PROJECT_DIR" >&2 + echo "This command must be run in a project with humanize initialized." >&2 + exit 1 +fi + +# Check if THIS project already has a running server +if [[ -f "$PORT_FILE" ]]; then + port=$(cat "$PORT_FILE") + if curl -s --max-time 2 "http://localhost:$port/api/health" >/dev/null 2>&1; then + echo "Viz server already running at http://localhost:$port" + exit 0 + fi + # Stale port file for this project + rm -f "$PORT_FILE" +fi + +# If a tmux session exists, it may serve another project — kill it to reuse the session name +if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + echo "Stopping existing viz session (may be from another project)..." + tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true +fi + +# Create venv if it doesn't exist +if [[ ! -d "$VENV_DIR" ]]; then + echo "Creating Python virtual environment..." + python3 -m venv "$VENV_DIR" + echo "Installing dependencies..." + "$VENV_DIR/bin/pip" install --quiet -r "$REQUIREMENTS" + echo "Dependencies installed." +else + # Quick check: reinstall if requirements changed + if [[ "$REQUIREMENTS" -nt "$VENV_DIR/.requirements_installed" ]]; then + echo "Updating dependencies..." + "$VENV_DIR/bin/pip" install --quiet -r "$REQUIREMENTS" + touch "$VENV_DIR/.requirements_installed" + fi +fi +touch "$VENV_DIR/.requirements_installed" + +# Find available port (scan 18000-18099) +find_port() { + for port in $(seq 18000 18099); do + if ! (echo >/dev/tcp/localhost/$port) 2>/dev/null; then + echo "$port" + return 0 + fi + done + echo "Error: No available port in range 18000-18099" >&2 + return 1 +} + +PORT=$(find_port) +echo "$PORT" > "$PORT_FILE" + +# Launch Flask in tmux +tmux new-session -d -s "$TMUX_SESSION" \ + "$VENV_DIR/bin/python" "$APP_ENTRY" \ + --port "$PORT" \ + --project "$PROJECT_DIR" \ + --static "$STATIC_DIR" + +echo "Viz server starting on http://localhost:$PORT" + +# Wait briefly for server to be ready +for i in $(seq 1 10); do + if curl -s --max-time 1 "http://localhost:$PORT/api/health" >/dev/null 2>&1; then + break + fi + sleep 0.5 +done + +# Open browser +if command -v xdg-open &>/dev/null; then + xdg-open "http://localhost:$PORT" 2>/dev/null & +elif command -v open &>/dev/null; then + open "http://localhost:$PORT" 2>/dev/null & +elif command -v wslview &>/dev/null; then + wslview "http://localhost:$PORT" 2>/dev/null & +else + echo "Open http://localhost:$PORT in your browser." +fi + +echo "Viz dashboard is ready at http://localhost:$PORT" +echo "Run 'viz-stop.sh' or '/humanize:viz stop' to stop the server." diff --git a/viz/scripts/viz-status.sh b/viz/scripts/viz-status.sh new file mode 100755 index 00000000..7720effd --- /dev/null +++ b/viz/scripts/viz-status.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Check the status of the Humanize Viz dashboard server. +# Usage: viz-status.sh [--project ] +# +# Only checks/cleans up state for the specified project. +# Will NOT kill tmux sessions that may belong to other projects. + +set -euo pipefail + +PROJECT_DIR="${1:-.}" +PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)" + +HUMANIZE_DIR="$PROJECT_DIR/.humanize" +PORT_FILE="$HUMANIZE_DIR/viz.port" +TMUX_SESSION="humanize-viz" + +# If this project has a port file, check if the server is healthy +if [[ -f "$PORT_FILE" ]]; then + port=$(cat "$PORT_FILE") + if curl -s --max-time 2 "http://localhost:$port/api/health" >/dev/null 2>&1; then + echo "Viz server running at http://localhost:$port" + exit 0 + fi + # Port file exists but server not responding — stale for THIS project + echo "Viz server is not running (stale port file, cleaning up)." + rm -f "$PORT_FILE" + # Only kill tmux if we can confirm it's ours (check if tmux session exists) + if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + # Verify the tmux session is serving this project by checking port + tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true + fi + exit 1 +fi + +# No port file for this project — server is not running here +# Do NOT kill any tmux session, it may serve another project +echo "Viz server is not running." +exit 1 diff --git a/viz/scripts/viz-stop.sh b/viz/scripts/viz-stop.sh new file mode 100755 index 00000000..0c622aaa --- /dev/null +++ b/viz/scripts/viz-stop.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Stop the Humanize Viz dashboard server. +# Usage: viz-stop.sh [--project ] + +set -euo pipefail + +PROJECT_DIR="${1:-.}" +PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)" + +HUMANIZE_DIR="$PROJECT_DIR/.humanize" +PORT_FILE="$HUMANIZE_DIR/viz.port" +TMUX_SESSION="humanize-viz" + +if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + tmux kill-session -t "$TMUX_SESSION" + rm -f "$PORT_FILE" + echo "Viz server stopped." +else + rm -f "$PORT_FILE" + echo "Viz server is not running." +fi diff --git a/viz/server/analyzer.py b/viz/server/analyzer.py new file mode 100644 index 00000000..952b1727 --- /dev/null +++ b/viz/server/analyzer.py @@ -0,0 +1,122 @@ +"""Cross-session analytics for RLCR loop data. + +Computes statistics across multiple sessions: efficiency metrics, +quality indicators, verdict distributions, and BitLesson growth. +""" + +def compute_analytics(sessions): + """Compute cross-session statistics from a list of parsed sessions.""" + if not sessions: + return _empty_analytics() + + total = len(sessions) + completed = sum(1 for s in sessions if s['status'] == 'complete') + total_rounds = [s['current_round'] for s in sessions if s['current_round'] > 0] + avg_rounds = round(sum(total_rounds) / len(total_rounds), 1) if total_rounds else 0 + + # Verdict distribution — only count rounds that have an actual review result + verdict_counts = {'advanced': 0, 'stalled': 0, 'regressed': 0, 'complete': 0} + for s in sessions: + for r in s['rounds']: + if r.get('review_result') is None: + continue + v = r.get('verdict', 'unknown') + if v != 'unknown': + verdict_counts[v] = verdict_counts.get(v, 0) + 1 + + # P0-P9 distribution + p_distribution = {} + for s in sessions: + for r in s['rounds']: + for level, count in r.get('p_issues', {}).items(): + p_distribution[level] = p_distribution.get(level, 0) + count + + # Per-session stats for charts + session_stats = [] + cumulative_bitlesson = 0 + bitlesson_growth = [] + + for s in sessions: + rounds_count = s['current_round'] + + # Average round duration + durations = [r['duration_minutes'] for r in s['rounds'] if r.get('duration_minutes')] + avg_duration = round(sum(durations) / len(durations), 1) if durations else None + + # First COMPLETE round + first_complete = None + for r in s['rounds']: + if r.get('verdict') == 'complete': + first_complete = r['number'] + break + + # Rework count (rounds after review phase started) + rework = 0 + in_review = False + for r in s['rounds']: + if r.get('phase') == 'code_review': + in_review = True + if in_review: + rework += 1 + + # Verdict breakdown for this session + sv = {'advanced': 0, 'stalled': 0, 'regressed': 0} + for r in s['rounds']: + v = r.get('verdict', '') + if v in sv: + sv[v] += 1 + + # BitLesson count + bl_count = sum(1 for r in s['rounds'] if r.get('bitlesson_delta') in ('add', 'update')) + cumulative_bitlesson += bl_count + + bitlesson_growth.append({ + 'session_id': s['id'], + 'cumulative': cumulative_bitlesson, + 'delta': bl_count, + }) + + session_stats.append({ + 'session_id': s['id'], + 'status': s['status'], + 'rounds': rounds_count, + 'avg_duration_minutes': avg_duration, + 'first_complete_round': first_complete, + 'rework_count': rework, + 'ac_completion_rate': round(s['ac_done'] / s['ac_total'] * 100, 1) if s['ac_total'] > 0 else 0, + 'verdict_breakdown': sv, + }) + + # Total BitLessons (count from bitlesson.md if available, else estimate) + total_bitlessons = cumulative_bitlesson + + return { + 'overview': { + 'total_sessions': total, + 'completed_sessions': completed, + 'completion_rate': round(completed / total * 100, 1) if total > 0 else 0, + 'average_rounds': avg_rounds, + 'total_bitlessons': total_bitlessons, + }, + 'verdict_distribution': verdict_counts, + 'p_distribution': p_distribution, + 'session_stats': session_stats, + 'bitlesson_growth': bitlesson_growth, + } + + +def _empty_analytics(): + """Return empty analytics structure.""" + return { + 'overview': { + 'total_sessions': 0, + 'completed_sessions': 0, + 'completion_rate': 0, + 'average_rounds': 0, + 'total_bitlessons': 0, + }, + 'verdict_distribution': {}, + 'p_distribution': {}, + 'session_stats': [], + 'bitlesson_growth': [], + } diff --git a/viz/server/app.py b/viz/server/app.py new file mode 100644 index 00000000..ebff1035 --- /dev/null +++ b/viz/server/app.py @@ -0,0 +1,847 @@ +"""Humanize Viz — Flask application. + +Serves the SPA frontend, REST API for session data, and WebSocket +for real-time file change notifications. +""" + +import os +import sys +import json +import argparse +import subprocess +import threading +from flask import Flask, jsonify, request, send_from_directory, abort +from flask_sock import Sock + +# Add server directory to path +sys.path.insert(0, os.path.dirname(__file__)) +from parser import list_sessions, parse_session, read_plan_file +from analyzer import compute_analytics +from exporter import export_session_markdown +from watcher import SessionWatcher + +app = Flask(__name__, static_folder=None) +sock = Sock(app) + +# Global state +PROJECT_DIR = '.' +STATIC_DIR = '.' +_session_cache = {} +_cache_lock = threading.Lock() +_ws_clients = set() +_ws_lock = threading.Lock() +_watcher = None + + +def _get_rlcr_dir(): + return os.path.join(PROJECT_DIR, '.humanize', 'rlcr') + + +def _get_session_dir(session_id): + d = os.path.join(_get_rlcr_dir(), session_id) + if not os.path.isdir(d): + return None + return d + + +def _get_session(session_id, force_refresh=False): + """Get session data with caching.""" + with _cache_lock: + if not force_refresh and session_id in _session_cache: + return _session_cache[session_id] + + session_dir = _get_session_dir(session_id) + if not session_dir: + return None + + session = parse_session(session_dir) + with _cache_lock: + _session_cache[session_id] = session + return session + + +def _invalidate_cache(session_id=None): + """Invalidate cache for a session or all sessions.""" + with _cache_lock: + if session_id: + _session_cache.pop(session_id, None) + else: + _session_cache.clear() + + +def broadcast_message(message): + """Send a message to all connected WebSocket clients.""" + dead = set() + with _ws_lock: + clients = set(_ws_clients) + + for ws in clients: + try: + ws.send(message) + except Exception: + dead.add(ws) + + if dead: + with _ws_lock: + _ws_clients -= dead + + # Invalidate cache for the affected session + try: + data = json.loads(message) + _invalidate_cache(data.get('session_id')) + except (json.JSONDecodeError, AttributeError): + pass + + +# --- Static file serving --- + +@app.route('/') +def index(): + return send_from_directory(STATIC_DIR, 'index.html') + + +@app.route('/') +def static_files(path): + if path.startswith('api/'): + abort(404) + full_path = os.path.join(STATIC_DIR, path) + if os.path.isfile(full_path): + return send_from_directory(STATIC_DIR, path) + # SPA fallback + return send_from_directory(STATIC_DIR, 'index.html') + + +# --- Health check --- + +@app.route('/api/health') +def health(): + return jsonify({'status': 'ok'}) + + +# --- Project Management --- + +_PROJECTS_FILE = os.path.expanduser('~/.humanize/viz-projects.json') + + +def _load_projects(): + """Load saved project list.""" + if os.path.isfile(_PROJECTS_FILE): + try: + with open(_PROJECTS_FILE, 'r') as f: + return json.loads(f.read()) + except (json.JSONDecodeError, OSError): + pass + return [] + + +def _save_projects(projects): + os.makedirs(os.path.dirname(_PROJECTS_FILE), exist_ok=True) + with open(_PROJECTS_FILE, 'w') as f: + f.write(json.dumps(projects, indent=2)) + + +def _ensure_current_project(): + """Make sure the current PROJECT_DIR is in the saved list.""" + projects = _load_projects() + if PROJECT_DIR not in projects: + projects.insert(0, PROJECT_DIR) + _save_projects(projects) + + +@app.route('/api/projects') +def api_projects(): + _ensure_current_project() + projects = _load_projects() + result = [] + for p in projects: + rlcr_dir = os.path.join(p, '.humanize', 'rlcr') + session_count = 0 + if os.path.isdir(rlcr_dir): + session_count = len([d for d in os.listdir(rlcr_dir) if os.path.isdir(os.path.join(rlcr_dir, d))]) + result.append({ + 'path': p, + 'name': os.path.basename(p), + 'sessions': session_count, + 'active': p == PROJECT_DIR, + }) + return jsonify(result) + + +@app.route('/api/projects/switch', methods=['POST']) +def api_switch_project(): + global PROJECT_DIR, _watcher + data = json.loads(request.data) if request.data else {} + new_path = data.get('path', '') + + if not new_path or not os.path.isdir(new_path): + return jsonify({'error': 'Invalid project path'}), 400 + + rlcr_dir = os.path.join(new_path, '.humanize', 'rlcr') + if not os.path.isdir(os.path.join(new_path, '.humanize')): + return jsonify({'error': 'No .humanize/ directory in this project'}), 400 + + # Stop old watcher + if _watcher: + _watcher.stop() + + # Switch + PROJECT_DIR = os.path.abspath(new_path) + _invalidate_cache() + + # Restart watcher + _watcher = SessionWatcher(PROJECT_DIR, broadcast_message) + _watcher.start() + + # Save to project list + _ensure_current_project() + + return jsonify({'status': 'switched', 'path': PROJECT_DIR}) + + +@app.route('/api/projects/add', methods=['POST']) +def api_add_project(): + data = json.loads(request.data) if request.data else {} + new_path = data.get('path', '') + + if not new_path: + return jsonify({'error': 'Path required'}), 400 + + new_path = os.path.abspath(os.path.expanduser(new_path)) + if not os.path.isdir(new_path): + return jsonify({'error': 'Directory does not exist'}), 400 + + if not os.path.isdir(os.path.join(new_path, '.humanize')): + return jsonify({'error': 'No .humanize/ directory found'}), 400 + + projects = _load_projects() + if new_path not in projects: + projects.append(new_path) + _save_projects(projects) + + return jsonify({'status': 'added', 'path': new_path}) + + +@app.route('/api/projects/remove', methods=['POST']) +def api_remove_project(): + data = json.loads(request.data) if request.data else {} + path = data.get('path', '') + + projects = _load_projects() + projects = [p for p in projects if p != path] + _save_projects(projects) + + return jsonify({'status': 'removed'}) + + +# --- REST API --- + +@app.route('/api/sessions') +def api_sessions(): + sessions = list_sessions(PROJECT_DIR) + # Return summary-level data (no full round content) + summaries = [] + for s in sessions: + summaries.append({ + 'id': s['id'], + 'status': s['status'], + 'current_round': s['current_round'], + 'max_iterations': s['max_iterations'], + 'plan_file': s['plan_file'], + 'start_branch': s['start_branch'], + 'started_at': s['started_at'], + 'last_verdict': s['last_verdict'], + 'drift_status': s['drift_status'], + 'tasks_done': s['tasks_done'], + 'tasks_total': s['tasks_total'], + 'ac_done': s['ac_done'], + 'ac_total': s['ac_total'], + 'duration_minutes': s.get('duration_minutes'), + }) + return jsonify(summaries) + + +@app.route('/api/sessions/') +def api_session_detail(session_id): + session = _get_session(session_id) + if not session: + abort(404) + return jsonify(session) + + +@app.route('/api/sessions//plan') +def api_session_plan(session_id): + session_dir = _get_session_dir(session_id) + if not session_dir: + abort(404) + plan = read_plan_file(session_dir, PROJECT_DIR) + if plan is None: + abort(404) + return jsonify({'content': plan}) + + +@app.route('/api/sessions//report') +def api_session_report(session_id): + session = _get_session(session_id) + if not session: + abort(404) + report = session.get('methodology_report') + if not report: + abort(404) + return jsonify({'content': report}) + + +@app.route('/api/analytics') +def api_analytics(): + sessions = list_sessions(PROJECT_DIR) + analytics = compute_analytics(sessions) + return jsonify(analytics) + + +@app.route('/api/sessions//generate-report', methods=['POST']) +def api_generate_report(session_id): + """Generate a methodology analysis report by invoking local Claude CLI.""" + session_dir = _get_session_dir(session_id) + if not session_dir: + abort(404) + + report_path = os.path.join(session_dir, 'methodology-analysis-report.md') + + # If report already exists, just return it + if os.path.exists(report_path) and os.path.getsize(report_path) > 0: + with open(report_path, 'r', encoding='utf-8') as f: + return jsonify({'status': 'exists', 'content': f.read()}) + + # Collect round summaries and review results (sorted numerically by round number) + import glob as _glob + import re as _re_local + + def _sort_round_files(files): + def _round_num(path): + m = _re_local.search(r'round-(\d+)-', os.path.basename(path)) + return int(m.group(1)) if m else 0 + return sorted(files, key=_round_num) + + summaries = [] + for sf in _sort_round_files(_glob.glob(os.path.join(session_dir, 'round-*-summary.md'))): + try: + with open(sf, 'r', encoding='utf-8') as f: + summaries.append(f'--- {os.path.basename(sf)} ---\n{f.read()}') + except (PermissionError, OSError): + pass + + reviews = [] + for rf in _sort_round_files(_glob.glob(os.path.join(session_dir, 'round-*-review-result.md'))): + try: + with open(rf, 'r', encoding='utf-8') as f: + reviews.append(f'--- {os.path.basename(rf)} ---\n{f.read()}') + except (PermissionError, OSError): + pass + + if not summaries and not reviews: + return jsonify({'error': 'No round data to analyze'}), 400 + + # Build the analysis prompt + prompt = f"""Analyze the following RLCR development records from a PURE METHODOLOGY perspective. + +CRITICAL SANITIZATION RULES — your output MUST NOT contain: +- File paths, directory paths, or module paths +- Function names, variable names, class names, or method names +- Branch names, commit hashes, or git identifiers +- Business domain terms, product names, or feature names +- Code snippets or code fragments of any kind +- Raw error messages or stack traces +- Project-specific URLs or endpoints +- Any information that could identify the specific project + +Focus areas: +- Iteration efficiency: Were rounds productive or repetitive? +- Feedback loop quality: Did reviewer feedback lead to improvements? +- Stagnation patterns: Were there signs of going in circles? +- Review effectiveness: Did reviews catch real issues or create false positives? +- Plan-to-execution alignment: Did execution follow the plan or drift? +- Round count vs. progress ratio: Was the number of rounds proportional to progress? +- Communication clarity: Were summaries and reviews clear and actionable? + +Output format: Write a structured markdown report following this exact structure: + +## Context + + +## Observations + + +## Suggested Improvements +| # | Suggestion | Mechanism | +|---|-----------|-----------| + + +## Quantitative Summary +| Metric | Value | +|--------|-------| + + +--- ROUND SUMMARIES --- +{chr(10).join(summaries[:10])} + +--- REVIEW RESULTS --- +{chr(10).join(reviews[:10])} +""" + + # Invoke Claude CLI in pipe mode + try: + result = subprocess.run( + ['claude', '-p', '--model', 'sonnet', '--output-format', 'text'], + input=prompt, + capture_output=True, + text=True, + timeout=120, + cwd=PROJECT_DIR, + ) + + if result.returncode != 0: + return jsonify({ + 'error': f'Claude CLI failed (exit {result.returncode})', + 'stderr': result.stderr[-500:] if result.stderr else '', + }), 500 + + report_content = result.stdout.strip() + if not report_content: + return jsonify({'error': 'Claude returned empty response'}), 500 + + # Save the report + with open(report_path, 'w', encoding='utf-8') as f: + f.write(report_content) + + # Invalidate session cache so the report is picked up + _invalidate_cache(session_id) + + return jsonify({'status': 'generated', 'content': report_content}) + + except FileNotFoundError: + return jsonify({'error': 'Claude CLI not found. Install Claude Code to generate reports.'}), 500 + except subprocess.TimeoutExpired: + return jsonify({'error': 'Claude CLI timed out (120s). Try again or reduce session size.'}), 500 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +def _find_cancel_script(): + """Resolve cancel-rlcr-loop.sh from plugin layout or env.""" + # Check env override first + env_script = os.environ.get('HUMANIZE_CANCEL_SCRIPT', '') + if env_script and os.path.isfile(env_script): + return env_script + + # Sibling path within the same humanize plugin repo (viz/server/../../scripts/) + server_dir = os.path.dirname(os.path.abspath(__file__)) + sibling = os.path.normpath(os.path.join(server_dir, '..', '..', 'scripts', 'cancel-rlcr-loop.sh')) + if os.path.isfile(sibling): + return sibling + + # Search standard plugin cache locations + search_paths = [ + os.path.expanduser('~/.claude/plugins/cache/humania/humanize'), + os.path.expanduser('~/.claude/plugins/marketplaces/humania'), + ] + for base in search_paths: + if not os.path.isdir(base): + continue + for entry in sorted(os.listdir(base), reverse=True): + candidate = os.path.join(base, entry, 'scripts', 'cancel-rlcr-loop.sh') + if os.path.isfile(candidate): + return candidate + candidate = os.path.join(base, 'scripts', 'cancel-rlcr-loop.sh') + if os.path.isfile(candidate): + return candidate + + return None + + +@app.route('/api/sessions//cancel', methods=['POST']) +def api_cancel_session(session_id): + session = _get_session(session_id) + if not session: + abort(404) + if session['status'] != 'active': + return jsonify({'error': 'Session is not active'}), 400 + + cancel_script = _find_cancel_script() + if not cancel_script: + return jsonify({'error': 'Cancel script not found. Ensure humanize plugin is installed.'}), 500 + + try: + subprocess.run([cancel_script], cwd=PROJECT_DIR, timeout=30, check=True) + _invalidate_cache(session_id) + return jsonify({'status': 'cancelled'}) + except subprocess.SubprocessError as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/sessions//export', methods=['POST']) +def api_export_session(session_id): + session = _get_session(session_id) + if not session: + abort(404) + markdown = export_session_markdown(session) + return jsonify({'content': markdown, 'filename': f'rlcr-report-{session_id}.md'}) + + +import re as _re + + +_FORBIDDEN_CATEGORIES = [ + ('path_token', _re.compile(r'[/\\]\w+\.\w{1,4}\b')), + ('path_token', _re.compile(r'\b\w+/\w+/\w+')), + ('qualified_name', _re.compile(r'\b\w+::\w+')), + ('qualified_name', _re.compile(r'\b\w+\.\w+\.\w+\(')), + ('git_hash', _re.compile(r'\b[a-f0-9]{7,40}\b')), + ('branch_name', _re.compile(r'\b(?:feat|fix|hotfix|release|bugfix)/\w+')), + ('branch_name', _re.compile(r'\bmain|master|develop\b')), + ('code_definition', _re.compile(r'\bdef \w+|function \w+|class \w+')), + ('import_statement', _re.compile(r'\b(?:import|require|from)\s+\w+')), + ('code_fence', _re.compile(r'```')), + ('identifier', _re.compile(r'\b\w+_\w+_\w+\b')), + ('identifier', _re.compile(r'\b[a-z]+[A-Z]\w+\b')), + ('stack_trace', _re.compile(r'\bTraceback \(most recent')), + ('stack_trace', _re.compile(r'\bFile ".+", line \d+')), + ('error_pattern', _re.compile(r'\b(?:Error|Exception|Panic|SIGSEGV|SIGABRT)\b')), + ('stack_trace', _re.compile(r'at \w+\.\w+\(.*:\d+:\d+\)')), + ('external_url', _re.compile(r'https?://(?!github\.com/humania)')), + ('local_endpoint', _re.compile(r'\b(?:localhost|127\.0\.0\.1):\d+')), +] + + +def _scan_for_forbidden_tokens(text): + """Return dict of {category: count} for forbidden patterns found in text. + Never returns the matched strings themselves to prevent leakage.""" + violations = {} + for category, pattern in _FORBIDDEN_CATEGORIES: + matches = pattern.findall(text) + if matches: + violations[category] = violations.get(category, 0) + len(matches) + return violations + + +def _is_english_only(text): + """Check that text is predominantly ASCII/English (>95% ASCII chars).""" + if not text: + return True + ascii_count = sum(1 for c in text if ord(c) < 128) + return (ascii_count / len(text)) > 0.95 + + +# Constrained methodology taxonomy — observations are classified into +# these generic categories. Only the category label and a generic phrasing +# are emitted into the issue; no report prose passes through. +_METHODOLOGY_CATEGORIES = { + 'iteration_efficiency': 'Iteration efficiency pattern observed: rounds showed uneven productivity distribution.', + 'feedback_loop': 'Feedback loop quality issue: reviewer-implementer communication could be improved.', + 'stagnation': 'Stagnation pattern detected: consecutive rounds showed limited forward progress.', + 'review_effectiveness': 'Review effectiveness concern: review feedback did not consistently drive improvements.', + 'plan_execution': 'Plan-execution alignment gap: implementation drifted from the original plan structure.', + 'verification_gap': 'Verification scope issue: implementer verification did not match reviewer expectations.', + 'phase_transition': 'Phase transition pattern: the boundary between implementation and review phases was unclear.', + 'scope_management': 'Scope management observation: work expanded or contracted relative to plan boundaries.', + 'general': 'General methodology observation noted.', +} + +_CATEGORY_KEYWORDS = { + 'iteration_efficiency': ['efficiency', 'productive', 'unproductive', 'round count', 'per-round output', 'diminish'], + 'feedback_loop': ['feedback', 'communication', 'reviewer', 'implementer', 'round-trip'], + 'stagnation': ['stagnation', 'stall', 'circle', 'repeat', 'no progress', 'same issue'], + 'review_effectiveness': ['false positive', 'review quality', 'missed issue', 'review catch'], + 'plan_execution': ['plan drift', 'alignment', 'deviat', 'scope change', 'off-plan'], + 'verification_gap': ['verification', 'insufficient test', 'too narrow', 'missed check', 'universal quantifier'], + 'phase_transition': ['phase transition', 'review phase', 'implementation phase', 'polishing', 'two-phase'], + 'scope_management': ['scope', 'over-engineer', 'under-deliver', 'bloat', 'defer'], +} + + +def _classify_observation(text): + """Classify a report observation into a methodology category.""" + lower = text.lower() + best_cat = 'general' + best_score = 0 + for cat, keywords in _CATEGORY_KEYWORDS.items(): + score = sum(1 for kw in keywords if kw in lower) + if score > best_score: + best_score = score + best_cat = cat + return best_cat + + +def _build_sanitized_issue(session): + """Build a sanitized GitHub issue payload following issue #62 format. + + Uses constrained methodology taxonomy — no report prose passes through. + Returns dict with 'title', 'body', and 'warnings' keys, or None if no report. + Warnings contain only category names and counts, never matched strings. + """ + report_obj = session.get('methodology_report', {}) + # Prefer English report; fall back to Chinese + report = (report_obj or {}).get('en') or (report_obj or {}).get('zh') or '' + if not report: + return None + + # Source diagnostics (informational only — do NOT gate outbound) + source_diagnostics = {} + if not _is_english_only(report): + source_diagnostics['non_english'] = 1 + + # Extract raw observations and suggestions from report structure + raw_observations = [] + raw_suggestions = [] + current_section = None + + for line in report.split('\n'): + stripped = line.strip() + if stripped.lower().startswith('## observation') or stripped.lower().startswith('## finding'): + current_section = 'observations' + continue + elif stripped.lower().startswith('## suggest'): + current_section = 'suggestions' + continue + elif stripped.startswith('## '): + current_section = stripped[3:].strip().lower() + continue + + if current_section == 'observations' and stripped.startswith(('- ', '* ', '1.', '2.', '3.', '4.', '5.', '6.', '7.', '8.', '9.')): + raw_observations.append(stripped.lstrip('-* 0123456789.').strip()) + elif current_section == 'suggestions' and stripped.startswith('|') and not stripped.startswith('|---') and not stripped.startswith('| #'): + cols = [c.strip() for c in stripped.split('|')[1:-1]] + if len(cols) >= 2: + raw_suggestions.append(cols) + + if not raw_observations: + for line in report.split('\n'): + stripped = line.strip() + if stripped and not stripped.startswith('#') and not stripped.startswith('|') and not stripped.startswith('---'): + raw_observations.append(stripped) + + # Log source-level findings as diagnostics (not blocking) + for obs in raw_observations: + violations = _scan_for_forbidden_tokens(obs) + for cat, count in violations.items(): + source_diagnostics[cat] = source_diagnostics.get(cat, 0) + count + + # Classify observations into methodology categories (no prose passes through) + category_counts = {} + for obs in raw_observations: + category = _classify_observation(obs) + category_counts[category] = category_counts.get(category, 0) + 1 + + # Classify suggestions into methodology categories (no raw text passes through) + suggestion_categories = {} + for cols in raw_suggestions: + combined = ' '.join(cols) + cat = _classify_observation(combined) + suggestion_categories[cat] = suggestion_categories.get(cat, 0) + 1 + + # Build title from dominant category (no report text) + dominant_cat = max(category_counts, key=category_counts.get) if category_counts else 'general' + title = f"RLCR: {dominant_cat.replace('_', ' ').capitalize()} pattern identified" + + # Build issue #62 body using ONLY taxonomy-derived phrasing + s = session + body_lines = [ + '## Context\n', + f'A {s["current_round"]}-round RLCR session ended with status: {s["status"]}.', + ] + if s.get('ac_total', 0) > 0: + body_lines.append(f'Acceptance criteria: {s["ac_done"]}/{s["ac_total"]} verified.') + body_lines.append('') + + body_lines.append('## Observations\n') + for i, (cat, count) in enumerate(sorted(category_counts.items(), key=lambda x: -x[1]), 1): + generic_text = _METHODOLOGY_CATEGORIES.get(cat, _METHODOLOGY_CATEGORIES['general']) + body_lines.append(f'{i}. **{cat.replace("_", " ").capitalize()}** ({count}x): {generic_text}') + + body_lines.append('') + body_lines.append('## Suggested Improvements\n') + body_lines.append('| # | Suggestion | Mechanism |') + body_lines.append('|---|-----------|-----------|') + if suggestion_categories: + for i, (cat, count) in enumerate(sorted(suggestion_categories.items(), key=lambda x: -x[1]), 1): + generic_suggestion = f'Improve {cat.replace("_", " ")} practices' + mechanism = f'Apply targeted {cat.replace("_", " ")} methodology adjustments ({count} suggestion(s) in this area)' + body_lines.append(f'| {i} | {generic_suggestion} | {mechanism} |') + else: + body_lines.append('| - | No specific suggestions identified | - |') + + body_lines.append('') + body_lines.append('## Quantitative Summary\n') + body_lines.append('| Metric | Value |') + body_lines.append('|--------|-------|') + body_lines.append(f'| Total rounds | {s["current_round"]} |') + body_lines.append(f'| Exit reason | {s["status"].capitalize()} |') + if s.get('ac_total', 0) > 0: + rate = round(s['ac_done'] / s['ac_total'] * 100) if s['ac_total'] > 0 else 0 + body_lines.append(f'| AC count | {s["ac_total"]} |') + body_lines.append(f'| Completion rate | {rate}% |') + body_lines.append(f'| Observation categories | {len(category_counts)} |') + body_lines.append(f'| Total observations | {sum(category_counts.values())} |') + + body = '\n'.join(body_lines) + + # OUTBOUND VALIDATION: only the final generated title/body determine + # whether the payload is safe to send. Source-report findings are + # informational and do NOT gate the outbound path. + outbound_warnings = {} + + final_violations = _scan_for_forbidden_tokens(body) + for cat, count in final_violations.items(): + outbound_warnings[cat] = outbound_warnings.get(cat, 0) + count + + title_violations = _scan_for_forbidden_tokens(title) + for cat, count in title_violations.items(): + outbound_warnings[cat] = outbound_warnings.get(cat, 0) + count + + if not _is_english_only(body): + outbound_warnings['non_english'] = 1 + + return { + 'title': title, + 'body': body, + 'warnings': outbound_warnings, + 'source_diagnostics': source_diagnostics, + } + + +@app.route('/api/sessions//sanitized-issue') +def api_sanitized_issue(session_id): + session = _get_session(session_id) + if not session: + abort(404) + payload = _build_sanitized_issue(session) + if not payload: + abort(404) + + # Outbound gate: only block if the FINAL generated payload has warnings + if payload.get('warnings'): + return jsonify({ + 'title': payload['title'], + 'body': '[REDACTED — outbound payload failed validation.]', + 'warnings': payload['warnings'], + 'source_diagnostics': payload.get('source_diagnostics', {}), + 'requires_review': True, + }) + + # Clean payload — include source diagnostics as informational + result = { + 'title': payload['title'], + 'body': payload['body'], + 'warnings': {}, + 'source_diagnostics': payload.get('source_diagnostics', {}), + } + return jsonify(result) + + +@app.route('/api/sessions//github-issue', methods=['POST']) +def api_github_issue(session_id): + session = _get_session(session_id) + if not session: + abort(404) + + payload = _build_sanitized_issue(session) + if not payload: + return jsonify({'error': 'No methodology report available'}), 400 + + # Block submission and redact body when sanitization warnings exist + if payload.get('warnings'): + return jsonify({ + 'error': 'Sanitization check failed. Review the methodology report manually and remove project-specific content before sending.', + 'warnings': payload['warnings'], + 'manual': False, + }), 400 + + title = payload['title'] + body = payload['body'] + + # Check if gh is available + try: + subprocess.run(['gh', '--version'], capture_output=True, timeout=5, check=True) + except (subprocess.SubprocessError, FileNotFoundError): + return jsonify({ + 'error': 'gh CLI not available', + 'title': title, + 'body': body, + 'manual': True, + }), 400 + + try: + result = subprocess.run( + ['gh', 'issue', 'create', '--repo', 'humania-org/humanize', + '--title', title, '--body', body], + capture_output=True, text=True, timeout=30, check=True, cwd=PROJECT_DIR, + ) + url = result.stdout.strip() + return jsonify({'status': 'created', 'url': url}) + except subprocess.SubprocessError as e: + return jsonify({ + 'error': str(e), + 'title': title, + 'body': body, + 'manual': True, + }), 500 + + +# --- WebSocket --- + +@sock.route('/ws') +def websocket(ws): + with _ws_lock: + _ws_clients.add(ws) + try: + while True: + data = ws.receive(timeout=60) + if data is None: + continue + try: + msg = json.loads(data) + if msg.get('type') == 'cancel_session': + sid = msg.get('session_id', '') + if sid: + session = _get_session(sid) + if session and session['status'] == 'active': + cancel_script = _find_cancel_script() + if cancel_script: + subprocess.run([cancel_script], cwd=PROJECT_DIR, timeout=30) + _invalidate_cache(sid) + except (json.JSONDecodeError, KeyError): + pass + except Exception: + pass + finally: + with _ws_lock: + _ws_clients.discard(ws) + + +# --- Main --- + +def main(): + parser = argparse.ArgumentParser(description='Humanize Viz Dashboard Server') + parser.add_argument('--port', type=int, default=18000) + parser.add_argument('--project', type=str, default='.') + parser.add_argument('--static', type=str, default='.') + args = parser.parse_args() + + global PROJECT_DIR, STATIC_DIR, _watcher + PROJECT_DIR = os.path.abspath(args.project) + STATIC_DIR = os.path.abspath(args.static) + + # Start file watcher + _watcher = SessionWatcher(PROJECT_DIR, broadcast_message) + _watcher.start() + + # Pre-populate cache + list_sessions(PROJECT_DIR) + + print(f"Humanize Viz server starting on http://localhost:{args.port}") + print(f"Project: {PROJECT_DIR}") + print(f"Static: {STATIC_DIR}") + + app.run(host='127.0.0.1', port=args.port, debug=False) + + +if __name__ == '__main__': + main() diff --git a/viz/server/exporter.py b/viz/server/exporter.py new file mode 100644 index 00000000..03e1461b --- /dev/null +++ b/viz/server/exporter.py @@ -0,0 +1,85 @@ +"""Export RLCR session data as Markdown reports.""" + + +def _resolve_content(value, lang='en'): + """Extract string content from a bilingual {zh, en} dict or plain string.""" + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, dict): + return value.get(lang) or value.get('en') or value.get('zh') + return str(value) + + +def export_session_markdown(session, lang='en'): + """Generate a structured Markdown report for a session.""" + lines = [] + sid = session['id'] + lines.append(f"# RLCR Session Report — {sid}\n") + + # Overview table + lines.append("## Overview\n") + lines.append("| Metric | Value |") + lines.append("|--------|-------|") + lines.append(f"| Status | {session['status'].capitalize()} |") + lines.append(f"| Rounds | {session['current_round']} |") + lines.append(f"| Plan | {session.get('plan_file', 'N/A')} |") + lines.append(f"| Branch | {session.get('start_branch', 'N/A')} |") + lines.append(f"| Started | {session.get('started_at', 'N/A')} |") + lines.append(f"| Codex Model | {session.get('codex_model', 'N/A')} |") + lines.append(f"| Last Verdict | {session.get('last_verdict', 'N/A')} |") + + ac_total = session.get('ac_total', 0) + ac_done = session.get('ac_done', 0) + if ac_total > 0: + lines.append(f"| AC Completion | {ac_done}/{ac_total} ({round(ac_done/ac_total*100)}%) |") + lines.append("") + + # Round history + if session.get('rounds'): + lines.append("## Round History\n") + for r in session['rounds']: + rn = r['number'] + lines.append(f"### Round {rn}\n") + lines.append(f"**Phase**: {r.get('phase', 'N/A')}") + lines.append(f"**Verdict**: {r.get('verdict', 'N/A')}") + if r.get('duration_minutes'): + lines.append(f"**Duration**: {r['duration_minutes']} min") + if r.get('bitlesson_delta') and r['bitlesson_delta'] != 'none': + lines.append(f"**BitLesson**: {r['bitlesson_delta']}") + lines.append("") + + summary_text = _resolve_content(r.get('summary'), lang) + if summary_text: + lines.append("#### Summary\n") + lines.append(summary_text) + lines.append("") + + review_text = _resolve_content(r.get('review_result'), lang) + if review_text: + lines.append("#### Codex Review\n") + lines.append(review_text) + lines.append("") + + # Goal Tracker + gt = session.get('goal_tracker') + if gt: + lines.append("## Goal Tracker\n") + lines.append(f"**Ultimate Goal**: {gt.get('ultimate_goal', 'N/A')}\n") + + if gt.get('acceptance_criteria'): + lines.append("### Acceptance Criteria\n") + for ac in gt['acceptance_criteria']: + status_icon = {'completed': '\u2713', 'in_progress': '\u25C9', 'pending': '\u25CB'}.get(ac['status'], '?') + lines.append(f"- {status_icon} **{ac['id']}**: {ac['description']}") + lines.append("") + + # Methodology analysis + report_text = _resolve_content(session.get('methodology_report'), lang) + if report_text: + lines.append("## Methodology Analysis\n") + lines.append(report_text) + lines.append("") + + return '\n'.join(lines) diff --git a/viz/server/parser.py b/viz/server/parser.py new file mode 100644 index 00000000..4bc186a6 --- /dev/null +++ b/viz/server/parser.py @@ -0,0 +1,441 @@ +"""Parse RLCR session data from .humanize/rlcr/ directories. + +Reads state.md (YAML frontmatter), goal-tracker.md, round summaries, +review results, and methodology reports into structured Python dicts. +""" + +import logging +import os +import re +import yaml +from datetime import datetime + +logger = logging.getLogger(__name__) + + +def parse_yaml_frontmatter(filepath): + """Extract YAML frontmatter from a Markdown file with --- delimiters.""" + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + except (FileNotFoundError, PermissionError): + return {}, '' + + if not content.startswith('---'): + return {}, content + + parts = content.split('---', 2) + if len(parts) < 3: + return {}, content + + try: + meta = yaml.safe_load(parts[1]) or {} + except yaml.YAMLError: + meta = {} + + body = parts[2].strip() + return meta, body + + +def detect_session_status(session_dir): + """Determine session status from terminal state files.""" + terminal_states = { + 'complete-state.md': 'complete', + 'cancel-state.md': 'cancel', + 'stop-state.md': 'stop', + 'maxiter-state.md': 'maxiter', + 'unexpected-state.md': 'unexpected', + 'methodology-analysis-state.md': 'analyzing', + 'finalize-state.md': 'finalizing', + } + for filename, status in terminal_states.items(): + if os.path.exists(os.path.join(session_dir, filename)): + return status + + if os.path.exists(os.path.join(session_dir, 'state.md')): + return 'active' + + return 'unknown' + + +def parse_state(session_dir): + """Parse state.md or any *-state.md file in the session directory.""" + state_file = os.path.join(session_dir, 'state.md') + if not os.path.exists(state_file): + for f in os.listdir(session_dir): + if f.endswith('-state.md'): + state_file = os.path.join(session_dir, f) + break + + meta, _ = parse_yaml_frontmatter(state_file) + return meta + + +def parse_goal_tracker(session_dir): + """Parse goal-tracker.md into structured data.""" + filepath = os.path.join(session_dir, 'goal-tracker.md') + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + except (FileNotFoundError, PermissionError): + return None + + result = { + 'ultimate_goal': '', + 'acceptance_criteria': [], + 'active_tasks': [], + 'completed_verified': [], + } + + # Extract ultimate goal + goal_match = re.search(r'### Ultimate Goal\s*\n(.*?)(?=\n###|\n---|\Z)', content, re.DOTALL) + if goal_match: + result['ultimate_goal'] = goal_match.group(1).strip() + + # Parse Completed and Verified table + completed_acs = set() + cv_section = re.search(r'### Completed and Verified.*?\n\|.*?\n\|[-|]+\n(.*?)(?=\n###|\Z)', content, re.DOTALL) + if cv_section: + for line in cv_section.group(1).strip().split('\n'): + if not line.strip() or not line.strip().startswith('|'): + continue + cols = [c.strip() for c in line.split('|')[1:-1]] + if len(cols) >= 4: + completed_acs.add(cols[0]) + result['completed_verified'].append({ + 'ac': cols[0], + 'task': cols[1], + 'completed_round': cols[2], + 'evidence': cols[3] if len(cols) > 3 else '', + }) + + # Extract acceptance criteria with status from parsed tables + ac_pattern = re.compile(r'- (AC-\d+): (.+?)(?=\n- AC-|\n---|\n###|\Z)', re.DOTALL) + for match in ac_pattern.finditer(content): + ac_id = match.group(1) + desc = match.group(2).strip().split('\n')[0] + status = 'completed' if ac_id in completed_acs else 'pending' + result['acceptance_criteria'].append({ + 'id': ac_id, + 'description': desc, + 'status': status, + }) + + # Check active tasks for in_progress status to refine AC status + active_section = re.search(r'#### Active Tasks.*?\n\|.*?\n\|[-|]+\n(.*?)(?=\n###|\Z)', content, re.DOTALL) + in_progress_acs = set() + if active_section: + for line in active_section.group(1).strip().split('\n'): + if not line.strip() or not line.strip().startswith('|'): + continue + cols = [c.strip() for c in line.split('|')[1:-1]] + if len(cols) >= 3: + task_status = cols[2].lower() + target_acs = cols[1] + result['active_tasks'].append({ + 'task': cols[0], + 'target_ac': target_acs, + 'status': cols[2], + 'notes': cols[-1] if len(cols) > 4 else '', + }) + if task_status in ('in_progress', 'implemented', 'needs_revision'): + for ac_ref in re.findall(r'AC-\d+', target_acs): + in_progress_acs.add(ac_ref) + + # Update AC status: in_progress if any active task references it + for ac in result['acceptance_criteria']: + if ac['status'] == 'pending' and ac['id'] in in_progress_acs: + ac['status'] = 'in_progress' + + return result + + +def _detect_language(text): + """Detect if text is primarily Chinese or English based on character ranges.""" + if not text: + return 'en' + cjk_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff' or '\u3000' <= c <= '\u303f') + return 'zh' if cjk_count > len(text) * 0.05 else 'en' + + +def _to_bilingual(content): + """Wrap content string into {zh, en} structure based on detected language.""" + if content is None: + return {'zh': None, 'en': None} + lang = _detect_language(content) + return {'zh': content if lang == 'zh' else None, 'en': content if lang == 'en' else None} + + +def _extract_task_progress(content): + """Extract task completion count from round summary content. + + Returns an integer count only when an explicit "N/M tasks" pattern is found. + Returns None when no reliable data is extractable — callers should treat + None as "unknown" and display accordingly. + """ + if not content: + return None + + # Only trust explicit "X/Y tasks" or "X of Y tasks" patterns + m = re.search(r'(\d+)\s*/\s*(\d+)\s*(?:tasks?|coding tasks?)', content, re.IGNORECASE) + if m: + return int(m.group(1)) + + m = re.search(r'(\d+)\s+of\s+(\d+)\s+(?:tasks?|coding tasks?)', content, re.IGNORECASE) + if m: + return int(m.group(1)) + + return None + + +def parse_round_summary(filepath): + """Parse a round-N-summary.md file.""" + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + except (FileNotFoundError, PermissionError): + return None + + bitlesson_delta = 'none' + bl_match = re.search(r'Action:\s*(none|add|update)', content, re.IGNORECASE) + if bl_match: + bitlesson_delta = bl_match.group(1).lower() + + task_progress = _extract_task_progress(content) + + return { + 'content': _to_bilingual(content), + 'bitlesson_delta': bitlesson_delta, + 'task_progress': task_progress, + 'mtime': os.path.getmtime(filepath), + } + + +def parse_review_result(filepath): + """Parse a round-N-review-result.md file.""" + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + except (FileNotFoundError, PermissionError): + return None + + verdict = 'unknown' + if 'COMPLETE' in content: + verdict = 'complete' + else: + for v in ('advanced', 'stalled', 'regressed'): + if v in content.lower(): + verdict = v + break + + p_issues = {} + for match in re.finditer(r'\[P(\d)\]', content): + level = f'P{match.group(1)}' + p_issues[level] = p_issues.get(level, 0) + 1 + + return { + 'content': _to_bilingual(content), + 'verdict': verdict, + 'p_issues': p_issues, + 'mtime': os.path.getmtime(filepath), + } + + +def parse_session(session_dir): + """Parse a complete RLCR session directory into a structured dict.""" + session_id = os.path.basename(session_dir) + status = detect_session_status(session_dir) + state = parse_state(session_dir) + goal_tracker = parse_goal_tracker(session_dir) + + current_round = state.get('current_round', 0) + + # Discover the highest round index present on disk (review files may exceed current_round) + max_disk_round = current_round + for f in os.listdir(session_dir): + m = re.match(r'round-(\d+)-(?:summary|review-result)\.md$', f) + if m: + max_disk_round = max(max_disk_round, int(m.group(1))) + + # Build rounds from 0..max(current_round, highest on-disk round) + rounds = [] + prev_mtime = None + for rn in range(max_disk_round + 1): + summary_file = os.path.join(session_dir, f'round-{rn}-summary.md') + review_file = os.path.join(session_dir, f'round-{rn}-review-result.md') + + summary = parse_round_summary(summary_file) + review = parse_review_result(review_file) + + # Duration from consecutive summary timestamps + duration_minutes = None + if summary and prev_mtime is not None: + duration_minutes = round((summary['mtime'] - prev_mtime) / 60, 1) + if summary: + prev_mtime = summary['mtime'] + + # Per-round task progress: only from explicit patterns in this round's summary + task_progress = summary.get('task_progress') if summary else None + + rounds.append({ + 'number': rn, + 'phase': _determine_phase(session_dir, rn, status), + 'summary': summary['content'] if summary else {'zh': None, 'en': None}, + 'review_result': review['content'] if review else {'zh': None, 'en': None}, + 'verdict': review['verdict'] if review else 'unknown', + 'bitlesson_delta': summary['bitlesson_delta'] if summary else 'none', + 'duration_minutes': duration_minutes, + 'p_issues': review['p_issues'] if review else {}, + 'task_progress': task_progress, + }) + + # Task/AC progress from goal tracker + tasks_done = 0 + tasks_total = 0 + ac_done = 0 + ac_total = 0 + if goal_tracker: + tasks_total = len(goal_tracker['active_tasks']) + len(goal_tracker['completed_verified']) + tasks_done = len(goal_tracker['completed_verified']) + ac_total = len(goal_tracker['acceptance_criteria']) + ac_done = sum(1 for ac in goal_tracker['acceptance_criteria'] if ac['status'] == 'completed') + + # Methodology report (bilingual) + report_file = os.path.join(session_dir, 'methodology-analysis-report.md') + methodology_report = {'zh': None, 'en': None} + if os.path.exists(report_file): + try: + with open(report_file, 'r', encoding='utf-8') as f: + raw_report = f.read() + methodology_report = _to_bilingual(raw_report) + except (PermissionError, OSError): + pass + + # Compute session duration from first/last round timestamps + session_duration_minutes = None + if len(rounds) >= 2: + first_mtime = None + last_mtime = None + for rn in range(current_round + 1): + sf = os.path.join(session_dir, f'round-{rn}-summary.md') + if os.path.exists(sf): + mt = os.path.getmtime(sf) + if first_mtime is None: + first_mtime = mt + last_mtime = mt + if first_mtime and last_mtime and last_mtime > first_mtime: + session_duration_minutes = round((last_mtime - first_mtime) / 60, 1) + + # started_at + started_at = state.get('started_at', '') + if not started_at: + try: + dt = datetime.strptime(session_id, '%Y-%m-%d_%H-%M-%S') + started_at = dt.isoformat() + 'Z' + except ValueError: + started_at = '' + + return { + 'id': session_id, + 'status': status, + 'current_round': current_round, + 'max_iterations': state.get('max_iterations', 42), + 'plan_file': state.get('plan_file', ''), + 'start_branch': state.get('start_branch', ''), + 'base_branch': state.get('base_branch', ''), + 'started_at': started_at, + 'codex_model': state.get('codex_model', ''), + 'codex_effort': state.get('codex_effort', ''), + 'last_verdict': rounds[-1]['verdict'] if rounds else 'unknown', + 'drift_status': state.get('drift_status', 'normal'), + 'rounds': rounds, + 'goal_tracker': goal_tracker, + 'methodology_report': methodology_report, + 'tasks_done': tasks_done, + 'tasks_total': tasks_total, + 'ac_done': ac_done, + 'ac_total': ac_total, + 'duration_minutes': session_duration_minutes, + } + + +def _determine_phase(session_dir, round_num, session_status): + """Determine the phase of a specific round.""" + review_started_file = os.path.join(session_dir, '.review-phase-started') + if os.path.exists(review_started_file): + try: + with open(review_started_file, 'r') as f: + content = f.read() + match = re.search(r'build_finish_round=(\d+)', content) + if match: + build_round = int(match.group(1)) + if round_num > build_round: + return 'code_review' + except (PermissionError, OSError): + pass + + if session_status == 'finalizing': + return 'finalize' + + return 'implementation' + + +def is_valid_session(session_dir): + """Check if a session directory has minimum required files.""" + has_state = os.path.exists(os.path.join(session_dir, 'state.md')) + has_terminal = any( + f.endswith('-state.md') and f != 'state.md' + for f in os.listdir(session_dir) + if os.path.isfile(os.path.join(session_dir, f)) + ) + return has_state or has_terminal + + +def list_sessions(project_dir): + """List all RLCR sessions in a project directory.""" + rlcr_dir = os.path.join(project_dir, '.humanize', 'rlcr') + if not os.path.isdir(rlcr_dir): + return [] + + sessions = [] + for entry in sorted(os.listdir(rlcr_dir), reverse=True): + session_dir = os.path.join(rlcr_dir, entry) + if not os.path.isdir(session_dir): + continue + + if not is_valid_session(session_dir): + logger.warning("Skipping malformed session directory: %s (no state.md or terminal state file)", entry) + continue + + try: + session = parse_session(session_dir) + sessions.append(session) + except Exception as e: + logger.warning("Failed to parse session %s: %s", entry, e) + continue + + return sessions + + +def read_plan_file(session_dir, project_dir): + """Read the plan file for a session.""" + state = parse_state(session_dir) + plan_path = state.get('plan_file', '') + if not plan_path: + backup = os.path.join(session_dir, 'plan.md') + if os.path.exists(backup): + with open(backup, 'r', encoding='utf-8') as f: + return f.read() + return None + + full_path = os.path.join(project_dir, plan_path) + if os.path.exists(full_path): + with open(full_path, 'r', encoding='utf-8') as f: + return f.read() + + backup = os.path.join(session_dir, 'plan.md') + if os.path.exists(backup): + with open(backup, 'r', encoding='utf-8') as f: + return f.read() + + return None diff --git a/viz/server/requirements.txt b/viz/server/requirements.txt new file mode 100644 index 00000000..d67e68eb --- /dev/null +++ b/viz/server/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.0,<4.0 +flask-sock>=0.7,<1.0 +watchdog>=4.0,<5.0 +pyyaml>=6.0,<7.0 +markdown>=3.5,<4.0 diff --git a/viz/server/watcher.py b/viz/server/watcher.py new file mode 100644 index 00000000..91a159f2 --- /dev/null +++ b/viz/server/watcher.py @@ -0,0 +1,112 @@ +"""File system watcher for RLCR session directories. + +Uses watchdog to monitor .humanize/rlcr/ and pushes WebSocket events +when session files change. Events are debounced (500ms) to avoid +spamming during rapid consecutive writes. +""" + +import os +import re +import json +import time +import threading +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler + + +class RLCREventHandler(FileSystemEventHandler): + """Maps file changes to WebSocket event types.""" + + def __init__(self, rlcr_dir, broadcast_fn): + super().__init__() + self.rlcr_dir = rlcr_dir + self.broadcast = broadcast_fn + self._pending = {} + self._lock = threading.Lock() + self._timer = None + self.debounce_ms = 500 + + def on_any_event(self, event): + src = str(event.src_path) + + if event.is_directory and event.event_type == 'created': + rel = os.path.relpath(src, self.rlcr_dir) + if '/' not in rel and '\\' not in rel: + self._schedule_event('session_created', rel) + return + + if event.is_directory: + return + + rel = os.path.relpath(src, self.rlcr_dir) + parts = rel.replace('\\', '/').split('/') + + if len(parts) < 2: + return + + session_id = parts[0] + filename = parts[1] + + if filename == 'state.md': + self._schedule_event('session_updated', session_id) + elif filename == 'goal-tracker.md': + self._schedule_event('session_updated', session_id) + elif re.match(r'round-\d+-summary\.md$', filename): + self._schedule_event('round_added', session_id) + elif re.match(r'round-\d+-review-result\.md$', filename): + self._schedule_event('session_updated', session_id) + elif filename.endswith('-state.md') and filename != 'state.md': + self._schedule_event('session_finished', session_id) + + def _schedule_event(self, event_type, session_id): + """Debounce events: accumulate for 500ms before broadcasting.""" + key = f"{event_type}:{session_id}" + with self._lock: + self._pending[key] = { + 'type': event_type, + 'session_id': session_id, + 'time': time.time(), + } + self._reset_timer() + + def _reset_timer(self): + if self._timer: + self._timer.cancel() + self._timer = threading.Timer(self.debounce_ms / 1000.0, self._flush) + self._timer.daemon = True + self._timer.start() + + def _flush(self): + with self._lock: + events = list(self._pending.values()) + self._pending.clear() + + for event in events: + self.broadcast(json.dumps({ + 'type': event['type'], + 'session_id': event['session_id'], + })) + + +class SessionWatcher: + """Manages the watchdog observer for RLCR directories.""" + + def __init__(self, project_dir, broadcast_fn): + self.rlcr_dir = os.path.join(project_dir, '.humanize', 'rlcr') + self.broadcast = broadcast_fn + self.observer = None + + def start(self): + if not os.path.isdir(self.rlcr_dir): + os.makedirs(self.rlcr_dir, exist_ok=True) + + handler = RLCREventHandler(self.rlcr_dir, self.broadcast) + self.observer = Observer() + self.observer.schedule(handler, self.rlcr_dir, recursive=True) + self.observer.daemon = True + self.observer.start() + + def stop(self): + if self.observer: + self.observer.stop() + self.observer.join(timeout=5) diff --git a/viz/static/css/layout.css b/viz/static/css/layout.css new file mode 100644 index 00000000..c0041213 --- /dev/null +++ b/viz/static/css/layout.css @@ -0,0 +1,986 @@ +/* ─── Topbar ─── */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-6); + height: 52px; + background: var(--bg-1); + border-bottom: 1px solid var(--border-0); + position: sticky; + top: 0; + z-index: 50; + backdrop-filter: blur(12px); +} + +.topbar-left { display: flex; align-items: center; gap: var(--space-3); } + +.topbar-logo { + display: flex; + align-items: center; + gap: var(--space-2); +} +.logo-mark { + color: var(--accent); + font-size: 1.1rem; +} +.logo-text { + font-family: var(--font-display); + font-weight: 800; + font-size: 0.95rem; + letter-spacing: -0.03em; + color: var(--text-0); +} + +.topbar-back { + display: inline-flex; + align-items: center; + gap: var(--space-1); + color: var(--text-2); + font-family: var(--font-display); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: color var(--duration-fast); + margin-right: var(--space-2); +} +.topbar-back:hover { color: var(--text-0); } + +.topbar-title { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-3); + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topbar-right { display: flex; align-items: center; gap: var(--space-2); } + +.topbar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: none; + color: var(--text-2); + cursor: pointer; + font-size: 1rem; + transition: all var(--duration-fast); +} +.topbar-btn:hover { background: var(--bg-2); color: var(--text-0); border-color: var(--border-1); } + +.topbar-link { + font-family: var(--font-display); + font-size: 0.8rem; + font-weight: 600; + color: var(--text-2); + padding: 6px 14px; + border-radius: var(--radius-sm); + transition: all var(--duration-fast); + letter-spacing: 0.01em; +} +.topbar-link:hover { background: var(--bg-2); color: var(--text-0); } + +.lang-toggle { + font-family: var(--font-display); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; +} + +/* ─── Main Content ─── */ +.page { + padding: var(--space-8) var(--space-6); + max-width: 1280px; + margin: 0 auto; + animation: fade-up var(--duration-slow) var(--ease-out); +} + +/* ─── Section Headers ─── */ +.section-label { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-5); + font-family: var(--font-display); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-3); +} +.section-label::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-0); +} + +/* ─── Project Switcher Bar ─── */ +.project-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-5); + background: var(--bg-1); + border: 1px solid var(--border-1); + border-radius: var(--radius-md); + margin-bottom: var(--space-6); +} + +.project-current { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; +} + +.project-current-label { + font-family: var(--font-display); + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-3); + flex-shrink: 0; +} + +.project-current-path { + font-family: var(--font-display); + font-weight: 700; + font-size: 0.95rem; + color: var(--text-0); +} + +.project-current-full { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 300px; +} + +/* ─── Session Cards ─── */ +.cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: var(--space-5); + margin-bottom: var(--space-10); +} + +.session-card { + background: var(--bg-1); + border: 1px solid var(--border-1); + border-radius: var(--radius-md); + padding: var(--space-5) var(--space-5) var(--space-4); + cursor: pointer; + transition: all var(--duration-base) var(--ease-out); + position: relative; + overflow: hidden; +} +.session-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: var(--accent); + opacity: 0; + transition: opacity var(--duration-base); +} +.session-card:hover { + border-color: var(--border-2); + transform: translateY(-3px); + box-shadow: var(--shadow-md), var(--shadow-glow); +} +.session-card:hover::before { opacity: 1; } + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-3); +} +.card-round-tag { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-2); +} + +.card-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-2) var(--space-5); + font-size: 0.82rem; + margin-bottom: var(--space-3); +} + +.card-field-label { + color: var(--text-3); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + font-family: var(--font-display); + font-weight: 600; +} +.card-field-value { + color: var(--text-1); + font-weight: 500; +} + +.card-foot { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: var(--space-3); + border-top: 1px solid var(--border-0); + font-size: 0.75rem; + color: var(--text-3); +} + +/* ─── Pipeline Viewport (zoom/pan canvas) ─── */ +.pipeline-container { + width: 100%; + height: 100%; +} + +.pl-viewport { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + cursor: grab; +} +.pl-viewport:active { cursor: grabbing; } + +.pl-controls { + position: absolute; + top: var(--space-3); + right: var(--space-3); + display: flex; + flex-direction: column; + gap: 2px; + z-index: 10; +} + +.pl-ctrl-btn { + width: 32px; + height: 32px; + border: 1px solid var(--border-1); + border-radius: var(--radius-sm); + background: var(--bg-1); + color: var(--text-1); + font-size: 1.1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--duration-fast); + font-family: var(--font-display); +} +.pl-ctrl-btn:hover { background: var(--bg-3); color: var(--text-0); border-color: var(--accent); } + +.pl-canvas { + position: relative; + transform-origin: 0 0; + transition: transform 80ms ease-out; +} + +.pl-svg { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + +/* ─── Pipeline Nodes (absolute positioned) ─── */ +.pl-node { + position: absolute; + background: var(--bg-1); + border: 2px solid var(--border-1); + border-radius: var(--radius-md); + cursor: pointer; + transition: border-color var(--duration-base) var(--ease-out), + box-shadow var(--duration-base) var(--ease-out); + overflow: hidden; + z-index: 1; + height: 68px; + display: flex; + flex-direction: column; + justify-content: center; +} +.pl-node:hover { + border-color: var(--border-2); + box-shadow: var(--shadow-md); + z-index: 2; +} +.pl-node.expanded { + width: 480px !important; + z-index: 5; + cursor: default; + box-shadow: var(--shadow-lg), var(--shadow-glow); + border-color: var(--accent); +} +.pl-node.active-round { + border-color: var(--accent); + animation: pulse-ring 2.5s var(--ease-in-out) infinite; +} + +.pl-node[data-verdict="advanced"] { border-left: 4px solid var(--verdict-advanced); } +.pl-node[data-verdict="stalled"] { border-left: 4px solid var(--verdict-stalled); } +.pl-node[data-verdict="regressed"] { border-left: 4px solid var(--verdict-regressed); } +.pl-node[data-verdict="complete"] { border-left: 4px solid var(--verdict-complete); } +.pl-node[data-verdict="unknown"] { border-left: 4px solid var(--verdict-unknown); } + +/* ─── Active Node Enhancements ─── */ +.pl-node.active-round { + border-color: var(--accent); + box-shadow: 0 0 20px var(--accent-glow), var(--shadow-md); + animation: pulse-ring 2.5s var(--ease-in-out) infinite; +} + +.node-active-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--bg-3); + overflow: hidden; + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.node-active-bar-fill { + height: 100%; + width: 40%; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + animation: active-bar-sweep 2s ease-in-out infinite; +} + +@keyframes active-bar-sweep { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(350%); } +} + +.node-live-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + animation: live-blink 1.2s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes live-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.2; } +} + +/* ─── Ghost "In Progress" Node ─── */ +.pl-ghost-node { + border: 2px dashed var(--accent) !important; + border-left: 4px dashed var(--accent) !important; + background: var(--bg-glow) !important; + opacity: 0.7; + cursor: default !important; + animation: ghost-breathe 3s ease-in-out infinite; +} + +.pl-ghost-node:hover { + border-color: var(--accent) !important; + box-shadow: none !important; + transform: none !important; +} + +@keyframes ghost-breathe { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 0.8; } +} + +/* ─── Active Edge (flowing dash animation) ─── */ +.pl-edge-active { + animation: edge-flow 1s linear infinite; +} + +@keyframes edge-flow { + from { stroke-dashoffset: 0; } + to { stroke-dashoffset: -20; } +} + + +.node-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + gap: var(--space-2); +} + +.node-round-num { + font-family: var(--font-display); + font-weight: 800; + font-size: 0.95rem; + color: var(--text-0); +} + +.node-meta { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.72rem; + color: var(--text-2); + font-family: var(--font-display); + font-weight: 600; +} + +.node-verdict-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.node-phase-tag { + font-family: var(--font-mono); + font-size: 0.68rem; + color: var(--text-3); + padding: 1px 6px; + background: var(--bg-3); + border-radius: var(--radius-xs); +} + +.node-mini-stats { + display: flex; + gap: var(--space-3); + padding: 0 var(--space-4) var(--space-3); + font-size: 0.72rem; + color: var(--text-3); + font-family: var(--font-mono); +} + +/* ─── Flyout Modal (expand from node to center) ─── */ +.flyout-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0); + z-index: 20; + pointer-events: none; + visibility: hidden; + transition: background 300ms var(--ease-out), visibility 0s 300ms; +} +.flyout-overlay.visible { + background: rgba(0, 0, 0, 0.55); + pointer-events: auto; + visibility: visible; + transition: background 300ms var(--ease-out), visibility 0s; +} + +.flyout-panel { + position: absolute; + background: var(--bg-1); + border: 1px solid var(--border-1); + box-shadow: var(--shadow-lg), 0 0 60px rgba(217, 119, 87, 0.08); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.flyout-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border-0); + flex-shrink: 0; +} + +.flyout-title { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.flyout-title h3 { + font-size: 1.1rem; + letter-spacing: -0.01em; +} + +.flyout-round-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--radius-md); + border: 2px solid var(--border-2); + font-family: var(--font-display); + font-weight: 800; + font-size: 0.85rem; + color: var(--text-0); + background: var(--bg-2); +} + +.flyout-close { + width: 32px; + height: 32px; + border: none; + border-radius: var(--radius-sm); + background: var(--bg-2); + color: var(--text-2); + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--duration-fast); +} +.flyout-close:hover { background: var(--bg-3); color: var(--text-0); } + +.flyout-meta-bar { + display: flex; + flex-wrap: wrap; + gap: var(--space-3) var(--space-5); + padding: var(--space-3) var(--space-5); + background: var(--bg-2); + border-bottom: 1px solid var(--border-0); + font-size: 0.82rem; + color: var(--text-1); + flex-shrink: 0; +} + +.flyout-meta-item strong { + color: var(--text-3); + font-family: var(--font-display); + font-weight: 700; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.flyout-body { + flex: 1; + overflow-y: auto; + padding: var(--space-5); +} + +.flyout-section { + margin-bottom: var(--space-5); +} +.flyout-section:last-child { margin-bottom: 0; } + +.flyout-section-title { + font-family: var(--font-display); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); + margin-bottom: var(--space-3); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border-0); +} + +/* ─── Detail Page ─── */ +.detail-layout { + display: grid; + grid-template-columns: 1fr 340px; + grid-template-rows: 1fr auto; + height: calc(100vh - 52px); +} + +.graph-area { + overflow: auto; + background: var(--bg-0); + position: relative; +} + +/* Right sidebar — session-level analysis */ +.session-sidebar { + overflow-y: auto; + padding: var(--space-5); + background: var(--bg-1); + border-left: 1px solid var(--border-0); +} + +.sidebar-section { + margin-bottom: var(--space-5); + padding-bottom: var(--space-5); + border-bottom: 1px solid var(--border-0); +} +.sidebar-section:last-child { border-bottom: none; padding-bottom: 0; } + +.sidebar-title { + font-family: var(--font-display); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--accent); + margin-bottom: var(--space-3); +} + +.sidebar-stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-3); +} + +.sidebar-stat { + background: var(--bg-2); + border-radius: var(--radius-sm); + padding: var(--space-3); + text-align: center; +} + +.sidebar-stat-num { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 800; + color: var(--accent); + line-height: 1; +} + +.sidebar-stat-label { + font-size: 0.68rem; + color: var(--text-3); + margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-family: var(--font-display); + font-weight: 600; +} + +.sidebar-meta { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.sidebar-meta-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.82rem; +} + +.sidebar-meta-key { + color: var(--text-3); + font-size: 0.75rem; + font-family: var(--font-display); + font-weight: 600; +} + +.sidebar-meta-val { + color: var(--text-0); + font-weight: 500; + font-family: var(--font-mono); + font-size: 0.8rem; +} + +.sidebar-verdict-list { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.sidebar-verdict-row { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.8rem; +} + +.sidebar-verdict-bar { + flex: 1; + height: 6px; + background: var(--bg-3); + border-radius: 3px; + overflow: hidden; +} + +.sidebar-verdict-fill { + height: 100%; + border-radius: 3px; + transition: width var(--duration-slow) var(--ease-out); +} + +.sidebar-ac-list { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.sidebar-ac-item { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.8rem; + padding: 3px 0; +} + +.sidebar-ac-icon { + font-size: 0.75rem; + flex-shrink: 0; +} + +.sidebar-ac-text { + color: var(--text-1); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta-item-label { + font-family: var(--font-display); + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-3); + margin-bottom: 2px; +} +.meta-item-value { + font-weight: 500; + color: var(--text-0); + font-size: 0.9rem; +} + +/* Goal Tracker Bar */ +.goal-bar { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + background: var(--bg-1); + border-top: 1px solid var(--border-0); + overflow-x: auto; +} + +.ac-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: var(--radius-full); + font-family: var(--font-display); + font-size: 0.68rem; + font-weight: 700; + white-space: nowrap; + border: 1px solid var(--border-1); + background: var(--bg-2); + color: var(--text-2); + transition: all var(--duration-fast); +} +.ac-pill.done { background: rgba(110, 231, 160, 0.08); color: var(--verdict-advanced); border-color: var(--verdict-advanced); } +.ac-pill.wip { background: rgba(96, 165, 250, 0.08); color: var(--verdict-active); border-color: var(--verdict-active); } + +/* ─── Analytics ─── */ +.stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-8); +} + +.stat-card { + background: var(--bg-1); + border: 1px solid var(--border-1); + border-radius: var(--radius-md); + padding: var(--space-5); + text-align: center; + transition: all var(--duration-base) var(--ease-out); +} +.stat-card:hover { border-color: var(--border-2); box-shadow: var(--shadow-sm); } + +.stat-number { + font-family: var(--font-display); + font-size: 2.2rem; + font-weight: 800; + color: var(--accent); + line-height: 1; + letter-spacing: -0.03em; +} + +.stat-label { + font-family: var(--font-display); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-3); + margin-top: var(--space-2); +} + +.charts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); + gap: var(--space-5); + margin-bottom: var(--space-8); +} + +.chart-panel { + background: var(--bg-1); + border: 1px solid var(--border-1); + border-radius: var(--radius-md); + padding: var(--space-5); +} +.chart-panel h4 { + font-size: 0.78rem; + color: var(--text-2); + margin-bottom: var(--space-4); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.chart-wrap { position: relative; height: 220px; } + +/* Verdict Timeline */ +.tl-container { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3) 0; +} + +.tl-row { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.tl-label { + width: 110px; + flex-shrink: 0; + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-2); + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tl-label:hover { color: var(--accent); } + +.tl-dots { + display: flex; + align-items: center; + gap: 4px; + flex: 1; +} + +.tl-dot { + display: inline-block; + width: 14px; + height: 14px; + border-radius: 3px; + flex-shrink: 0; + transition: transform var(--duration-fast); + cursor: default; +} +.tl-dot:hover { transform: scale(1.4); } + +.tl-legend { + display: flex; + gap: var(--space-4); + padding-top: var(--space-3); + border-top: 1px solid var(--border-0); + margin-top: var(--space-3); + font-size: 0.72rem; + color: var(--text-3); +} + +.tl-legend span { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.tl-legend .tl-dot { + width: 8px; + height: 8px; +} + +/* Comparison Table */ +.cmp-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 0.85rem; +} +.cmp-table th { + text-align: left; + padding: 10px 14px; + background: var(--bg-2); + color: var(--text-2); + font-family: var(--font-display); + font-weight: 700; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + border-bottom: 1px solid var(--border-1); + cursor: pointer; + user-select: none; + transition: color var(--duration-fast); +} +.cmp-table th:hover { color: var(--accent); } +.cmp-table th:first-child { border-radius: var(--radius-sm) 0 0 0; } +.cmp-table th:last-child { border-radius: 0 var(--radius-sm) 0 0; } + +.cmp-table td { + padding: 10px 14px; + border-bottom: 1px solid var(--border-0); + color: var(--text-1); +} +.cmp-table tr:hover td { background: var(--bg-glow); } + +/* ─── Empty State ─── */ +.empty { + text-align: center; + padding: var(--space-16) var(--space-6); + color: var(--text-3); +} +.empty-icon { + font-size: 3rem; + margin-bottom: var(--space-4); + opacity: 0.3; +} +.empty-msg { font-size: 1.05rem; color: var(--text-2); } +.empty-hint { font-size: 0.85rem; margin-top: var(--space-2); } + +/* ─── GitHub Section ─── */ +.gh-section { + margin-top: var(--space-5); + padding: var(--space-5); + background: var(--bg-2); + border-radius: var(--radius-md); + border: 1px solid var(--border-1); +} + +.warning-banner { + padding: var(--space-4); + background: rgba(251, 191, 36, 0.06); + border: 1px solid rgba(251, 191, 36, 0.2); + border-radius: var(--radius-sm); + margin-bottom: var(--space-4); + font-size: 0.85rem; + color: var(--verdict-stalled); +} + +/* ─── Responsive ─── */ +@media (max-width: 900px) { + .detail-layout { grid-template-columns: 1fr; grid-template-rows: auto auto auto; } + .session-sidebar { border-left: none; border-top: 1px solid var(--border-0); } + .pipeline-grid { --cols: 2 !important; } + .cards-grid { grid-template-columns: 1fr; } + .charts-grid { grid-template-columns: 1fr; } +} diff --git a/viz/static/css/theme.css b/viz/static/css/theme.css new file mode 100644 index 00000000..e14130e3 --- /dev/null +++ b/viz/static/css/theme.css @@ -0,0 +1,435 @@ +/* + * Humanize Viz — Design System + * Aesthetic: "Mission Control" — refined dark dashboard with warm orange accents + * Font: Archivo (display), DM Sans (body), JetBrains Mono (code) + */ + +/* ─── Design Tokens ─── */ +:root { + --font-display: 'Archivo', 'Noto Sans SC', sans-serif; + --font-body: 'DM Sans', 'Noto Sans SC', sans-serif; + --font-mono: 'JetBrains Mono', 'Noto Sans SC', monospace; + + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.45, 0, 0.55, 1); + --duration-fast: 120ms; + --duration-base: 250ms; + --duration-slow: 500ms; + --duration-expand: 400ms; + + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-xl: 28px; + --radius-full: 9999px; + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; +} + +/* ─── Dark Theme ─── */ +[data-theme="dark"] { + --bg-0: #0f0f12; + --bg-1: #17171c; + --bg-2: #1e1e24; + --bg-3: #26262e; + --bg-4: #2f2f38; + --bg-glow: rgba(217, 119, 87, 0.04); + + --text-0: #f0ede8; + --text-1: #c4c0b8; + --text-2: #8a877f; + --text-3: #5c5a54; + + --accent: #d97757; + --accent-hover: #e8906e; + --accent-dim: rgba(217, 119, 87, 0.12); + --accent-glow: rgba(217, 119, 87, 0.25); + + --border-0: rgba(255, 255, 255, 0.04); + --border-1: rgba(255, 255, 255, 0.08); + --border-2: rgba(255, 255, 255, 0.14); + + --verdict-advanced: #6ee7a0; + --verdict-stalled: #fbbf24; + --verdict-regressed: #f87171; + --verdict-active: #60a5fa; + --verdict-unknown: #6b7280; + --verdict-complete: #a78bfa; + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.3); + --shadow-md: 0 4px 16px rgba(0,0,0,0.4); + --shadow-lg: 0 12px 40px rgba(0,0,0,0.5); + --shadow-glow: 0 0 30px rgba(217, 119, 87, 0.1); + + --grain-opacity: 0.03; + color-scheme: dark; +} + +/* ─── Light Theme ─── */ +[data-theme="light"] { + --bg-0: #f8f6f2; + --bg-1: #ffffff; + --bg-2: #f0ede8; + --bg-3: #e6e3dc; + --bg-4: #d9d6cf; + --bg-glow: rgba(217, 119, 87, 0.03); + + --text-0: #1a1815; + --text-1: #3d3a35; + --text-2: #7a776f; + --text-3: #a8a59d; + + --accent: #c4623f; + --accent-hover: #b05535; + --accent-dim: rgba(196, 98, 63, 0.08); + --accent-glow: rgba(196, 98, 63, 0.15); + + --border-0: rgba(0, 0, 0, 0.04); + --border-1: rgba(0, 0, 0, 0.08); + --border-2: rgba(0, 0, 0, 0.14); + + --verdict-advanced: #16a34a; + --verdict-stalled: #ca8a04; + --verdict-regressed: #dc2626; + --verdict-active: #2563eb; + --verdict-unknown: #6b7280; + --verdict-complete: #7c3aed; + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.06); + --shadow-md: 0 4px 16px rgba(0,0,0,0.08); + --shadow-lg: 0 12px 40px rgba(0,0,0,0.12); + --shadow-glow: 0 0 30px rgba(196, 98, 63, 0.06); + + --grain-opacity: 0.015; + color-scheme: light; +} + +/* ─── Reset & Base ─── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { + font-size: 15px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-body); + color: var(--text-0); + background: var(--bg-0); + line-height: 1.6; + min-height: 100vh; + transition: background var(--duration-base) var(--ease-out), + color var(--duration-base) var(--ease-out); +} + +/* ─── Grain Overlay ─── */ +.grain-overlay { + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; + opacity: var(--grain-opacity); + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 256px; +} + +/* ─── Typography ─── */ +h1, h2, h3, h4, h5 { + font-family: var(--font-display); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; +} + +h1 { font-size: 2rem; } +h2 { font-size: 1.5rem; } +h3 { font-size: 1.15rem; } +h4 { font-size: 1rem; } + +code, pre, .mono { + font-family: var(--font-mono); + font-size: 0.87rem; +} + +pre { + background: var(--bg-2); + border: 1px solid var(--border-1); + border-radius: var(--radius-sm); + padding: var(--space-4); + overflow-x: auto; +} + +a { + color: var(--accent); + text-decoration: none; + transition: color var(--duration-fast); +} +a:hover { color: var(--accent-hover); } + +::selection { + background: var(--accent-dim); + color: var(--text-0); +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-3); } + +/* ─── Badges ─── */ +.badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 2px 10px; + border-radius: var(--radius-full); + font-family: var(--font-display); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.badge-active { background: rgba(96, 165, 250, 0.12); color: var(--verdict-active); } +.badge-complete { background: rgba(167, 139, 250, 0.12); color: var(--verdict-complete); } +.badge-cancel { background: rgba(248, 113, 113, 0.12); color: var(--verdict-regressed); } +.badge-stop, .badge-maxiter { background: rgba(251, 191, 36, 0.12); color: var(--verdict-stalled); } +.badge-unknown, .badge-analyzing, .badge-finalizing { background: rgba(107, 114, 128, 0.12); color: var(--verdict-unknown); } + +/* ─── Verdict Colors ─── */ +.verdict-advanced { color: var(--verdict-advanced); } +.verdict-stalled { color: var(--verdict-stalled); } +.verdict-regressed { color: var(--verdict-regressed); } +.verdict-unknown { color: var(--verdict-unknown); } +.verdict-complete { color: var(--verdict-complete); } + +/* ─── Buttons ─── */ +.btn { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 8px 18px; + border: 1px solid var(--border-2); + border-radius: var(--radius-sm); + background: var(--bg-2); + color: var(--text-0); + font-family: var(--font-display); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all var(--duration-fast) var(--ease-out); + letter-spacing: 0.02em; +} +.btn:hover { background: var(--bg-3); border-color: var(--accent); transform: translateY(-1px); } +.btn:active { transform: translateY(0); } + +.btn-primary { + background: var(--accent); + color: #fff; + border-color: transparent; +} +.btn-primary:hover { background: var(--accent-hover); border-color: transparent; box-shadow: var(--shadow-glow); } + +.btn-ghost { + background: transparent; + border-color: transparent; + color: var(--text-2); +} +.btn-ghost:hover { color: var(--text-0); background: var(--bg-2); border-color: transparent; } + +.btn-danger { color: var(--verdict-regressed); } +.btn-danger:hover { background: rgba(248,113,113,0.08); border-color: var(--verdict-regressed); } + +/* ─── Tabs ─── */ +.tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border-1); + margin-bottom: var(--space-6); +} + +.tab { + padding: 10px 20px; + cursor: pointer; + color: var(--text-2); + border-bottom: 2px solid transparent; + font-family: var(--font-display); + font-size: 0.85rem; + font-weight: 600; + transition: all var(--duration-fast); + letter-spacing: 0.01em; +} +.tab:hover { color: var(--text-0); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } + +/* ─── Modal ─── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + visibility: hidden; + transition: background var(--duration-base) var(--ease-out), + visibility 0s linear var(--duration-base); +} +.modal-overlay.visible { + background: rgba(0, 0, 0, 0.65); + pointer-events: auto; + visibility: visible; + transition: background var(--duration-base) var(--ease-out), visibility 0s; +} + +.modal { + background: var(--bg-1); + border: 1px solid var(--border-1); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + max-width: 680px; + width: 92%; + max-height: 82vh; + overflow-y: auto; + padding: var(--space-8); + transform: scale(0.92) translateY(12px); + opacity: 0; + transition: transform var(--duration-slow) var(--ease-out), + opacity var(--duration-base) var(--ease-out); +} +.modal-overlay.visible .modal { + transform: scale(1) translateY(0); + opacity: 1; +} + +.modal h3 { + font-size: 1.2rem; + margin-bottom: var(--space-5); +} + +.modal-actions { + display: flex; + gap: var(--space-3); + justify-content: flex-end; + margin-top: var(--space-6); + padding-top: var(--space-5); + border-top: 1px solid var(--border-0); +} + +/* ─── Dropdown ─── */ +.dropdown { position: relative; } + +.dropdown-menu { + display: none; + position: absolute; + right: 0; + top: calc(100% + 6px); + background: var(--bg-2); + border: 1px solid var(--border-1); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + min-width: 200px; + z-index: 100; + overflow: hidden; + padding: var(--space-1) 0; +} +.dropdown-menu.open { display: block; } + +.dropdown-item { + display: block; + width: 100%; + padding: 9px 16px; + text-align: left; + border: none; + background: none; + color: var(--text-1); + font-family: var(--font-body); + font-size: 0.87rem; + cursor: pointer; + transition: all var(--duration-fast); +} +.dropdown-item:hover { background: var(--bg-3); color: var(--text-0); } +.dropdown-item.danger { color: var(--verdict-regressed); } +.dropdown-item.danger:hover { background: rgba(248,113,113,0.06); } +.dropdown-divider { border: none; border-top: 1px solid var(--border-0); margin: var(--space-1) 0; } + +/* ─── Markdown ─── */ +.md h1 { font-size: 1.3rem; margin: var(--space-5) 0 var(--space-3); } +.md h2 { font-size: 1.1rem; margin: var(--space-4) 0 var(--space-2); color: var(--accent); } +.md h3 { font-size: 0.95rem; margin: var(--space-3) 0 var(--space-2); } +.md p { margin: var(--space-2) 0; color: var(--text-1); } +.md ul, .md ol { padding-left: 20px; margin: var(--space-2) 0; } +.md li { margin: 2px 0; color: var(--text-1); } +.md strong { color: var(--text-0); } +.md table { border-collapse: collapse; width: 100%; margin: var(--space-3) 0; font-size: 0.87rem; } +.md th, .md td { border: 1px solid var(--border-1); padding: 6px 12px; text-align: left; } +.md th { background: var(--bg-3); color: var(--text-2); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; } +.md blockquote { border-left: 3px solid var(--accent); padding-left: 14px; color: var(--text-2); margin: var(--space-3) 0; } + +/* ─── Progress Bar ─── */ +.progress-bar { + width: 100%; + height: 5px; + background: var(--bg-3); + border-radius: var(--radius-full); + overflow: hidden; +} +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + border-radius: var(--radius-full); + transition: width var(--duration-slow) var(--ease-out); +} + +/* ─── Pulse Keyframes ─── */ +@keyframes pulse-ring { + 0% { box-shadow: 0 0 0 0 var(--accent-glow); } + 70% { box-shadow: 0 0 0 10px transparent; } + 100% { box-shadow: 0 0 0 0 transparent; } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--border-2); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes fade-up { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slide-in { + from { opacity: 0; transform: translateX(-8px); } + to { opacity: 1; transform: translateX(0); } +} + +/* ─── Print ─── */ +@media print { + .topbar, .grain-overlay, .dropdown { display: none !important; } + body { background: #fff; color: #000; } + .modal-overlay { display: none !important; } +} diff --git a/viz/static/index.html b/viz/static/index.html new file mode 100644 index 00000000..0b54ec53 --- /dev/null +++ b/viz/static/index.html @@ -0,0 +1,68 @@ + + + + + + Humanize Viz + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ Home + + + +
+
+ + +
+ + + + + + + + + + + + diff --git a/viz/static/js/actions.js b/viz/static/js/actions.js new file mode 100644 index 00000000..9d5eb43c --- /dev/null +++ b/viz/static/js/actions.js @@ -0,0 +1,321 @@ +/* Action handlers — cancel, export, GitHub issue, plan viewer */ + +function toggleOpsMenu() { + const menu = document.getElementById('ops-dropdown') + if (menu) menu.classList.toggle('open') +} + +document.addEventListener('click', (e) => { + if (!e.target.closest('.dropdown')) + document.querySelectorAll('.dropdown-menu').forEach(m => m.classList.remove('open')) +}) + +// ─── Cancel ─── +function showCancelModal(sessionId) { + const modal = document.getElementById('modal-content') + modal.innerHTML = ` +

${t('cancel.title')}

+

${t('cancel.message')}

+ ` + document.getElementById('modal-overlay').classList.add('visible') +} + +async function confirmCancel(sessionId) { + const res = await fetch(`/api/sessions/${sessionId}/cancel`, { method: 'POST' }) + closeModal() + if (res.ok) window.renderCurrentRoute() + else { const e = await res.json(); alert(e.error || t('cancel.failed')) } +} + +function closeModal() { + document.getElementById('modal-overlay').classList.remove('visible') +} + +// ─── Export ─── +async function exportMarkdown(sessionId) { + const res = await fetch(`/api/sessions/${sessionId}/export`, { method: 'POST' }) + if (!res.ok) return + const data = await res.json() + const blob = new Blob([data.content], { type: 'text/markdown' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = data.filename || `rlcr-report-${sessionId}.md` + a.click() + URL.revokeObjectURL(url) +} + +function exportPdf() { window.print() } + +// ─── GitHub Issue (sanitized) ─── +async function previewGitHubIssue(sessionId) { + const res = await fetch(`/api/sessions/${sessionId}/sanitized-issue`) + if (!res.ok) return + const data = await res.json() + const modal = document.getElementById('modal-content') + modal.innerHTML = ` +

${t('analysis.preview')}

+
+
${t('analysis.issue_title')}
+ ${esc(data.title)} +
+
+
${t('analysis.issue_body')}
+
+ ${safeMd(data.body)} +
+
+ ` + document.getElementById('modal-overlay').classList.add('visible') +} + +async function sendGitHubIssue(sessionId) { + closeModal() + const ghResult = document.getElementById('gh-result') + if (ghResult) ghResult.innerHTML = `${t('analysis.sending')}` + const res = await fetch(`/api/sessions/${sessionId}/github-issue`, { method: 'POST' }) + const data = await res.json() + if (res.ok && data.url) { + if (ghResult) ghResult.innerHTML = `✓ ${t('analysis.sent')} — ${data.url}` + } else if (data.manual) { + window._issuePayload = `Title: ${data.title || ''}\n\n${data.body || ''}` + if (ghResult) ghResult.innerHTML = `${esc(data.error)}
` + } else { + if (ghResult) ghResult.innerHTML = `${esc(data.error || t('analysis.failed'))}` + } +} + +async function copyIssueContent(sessionId) { + const res = await fetch(`/api/sessions/${sessionId}/sanitized-issue`) + if (!res.ok) return + const data = await res.json() + copyToClipboard(`Title: ${data.title}\n\n${data.body}`) +} + +function copyToClipboard(text) { + navigator.clipboard.writeText(text).catch(() => { + const ta = document.createElement('textarea') + ta.value = text + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) + }) +} + +// ─── Generate Report (calls local Claude CLI) ─── +async function ensureReport(sessionId) { + const resultEl = document.getElementById('sidebar-gh-result') + + // Try sanitized-issue first — if it works, report exists + const check = await fetch(`/api/sessions/${sessionId}/sanitized-issue`) + if (check.ok) { + const data = await check.json() + if (!data.requires_review || data.body !== '[REDACTED — outbound payload failed validation.]') { + return true + } + } + + // No report — generate one via Claude CLI + if (resultEl) resultEl.innerHTML = ` +
+
+ + Generating methodology report via Claude... +
+
+ This may take 30-60 seconds. Analyzing round summaries and reviews. +
+
` + + try { + const res = await fetch(`/api/sessions/${sessionId}/generate-report`, { method: 'POST' }) + const data = await res.json() + + if (res.ok && (data.status === 'generated' || data.status === 'exists')) { + if (resultEl) resultEl.innerHTML = ` +
+ ✓ Report generated successfully +
` + return true + } else { + if (resultEl) resultEl.innerHTML = ` +
+ ${esc(data.error || 'Failed to generate report')} +
` + return false + } + } catch (e) { + if (resultEl) resultEl.innerHTML = ` +
+ Network error: ${esc(e.message)} +
` + return false + } +} + +async function sidebarGenerateAndPreview(sessionId) { + const ok = await ensureReport(sessionId) + if (ok) await sidebarPreviewIssue(sessionId) +} + +async function sidebarGenerateAndSend(sessionId) { + const ok = await ensureReport(sessionId) + if (ok) await sidebarSendIssue(sessionId) +} + +// ─── Sidebar Issue Submission ─── +async function sidebarPreviewIssue(sessionId) { + const resultEl = document.getElementById('sidebar-gh-result') + if (resultEl) resultEl.innerHTML = `Loading preview...` + + const res = await fetch(`/api/sessions/${sessionId}/sanitized-issue`) + if (!res.ok) { + if (resultEl) resultEl.innerHTML = `No methodology report available for this session.` + return + } + + const data = await res.json() + + // Check for warnings + const w = data.warnings || {} + const hasWarnings = data.requires_review || Object.keys(w).length > 0 + + const modal = document.getElementById('modal-content') + modal.innerHTML = ` +
+

Issue Preview

+ → humania-org/humanize +
+ ${hasWarnings ? ` +
+ ⚠ Sanitization warnings detected. Content has been redacted.
+ ${Object.entries(w).map(([c, n]) => `• ${esc(c)}: ${n}`).join('')} +
` : ''} +
+
Title
+ ${esc(data.title)} +
+
+
Body
+
+ ${safeMd(data.body)} +
+
+ ` + document.getElementById('modal-overlay').classList.add('visible') + if (resultEl) resultEl.innerHTML = '' +} + +async function sidebarSendIssue(sessionId) { + const resultEl = document.getElementById('sidebar-gh-result') + if (resultEl) resultEl.innerHTML = `Submitting...` + + const res = await fetch(`/api/sessions/${sessionId}/github-issue`, { method: 'POST' }) + const data = await res.json() + + if (res.ok && data.url) { + if (resultEl) resultEl.innerHTML = ` +
+ ✓ Issue created
+ ${data.url} +
` + // Disable buttons after successful submission + const actionsEl = document.getElementById('sidebar-gh-actions') + if (actionsEl) actionsEl.innerHTML = `
✓ Submitted
` + } else if (data.manual) { + window._issuePayload = `Title: ${data.title || ''}\n\n${data.body || ''}` + if (resultEl) resultEl.innerHTML = ` +
+ ${esc(data.error)}
+ +
` + } else if (data.warnings) { + if (resultEl) resultEl.innerHTML = ` +
+ ⚠ Sanitization check failed
+ ${Object.entries(data.warnings).map(([c, n]) => `${c}: ${n}`).join(', ')} +
` + } else { + if (resultEl) resultEl.innerHTML = ` +
+ ${esc(data.error || 'Submission failed')} +
` + } +} + +// ─── Project Switching ─── +async function switchProject(path) { + const res = await fetch('/api/projects/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }) + }) + if (res.ok) { + navigate('#/') + window.renderCurrentRoute() + } else { + const data = await res.json() + alert(data.error || 'Failed to switch project') + } +} + +function addProjectPrompt() { + const modal = document.getElementById('modal-content') + modal.innerHTML = ` +

Add Project

+

+ Enter the absolute path to a project directory that has a .humanize/ folder. +

+ + ` + document.getElementById('modal-overlay').classList.add('visible') + setTimeout(() => document.getElementById('add-project-input')?.focus(), 200) +} + +async function addProject() { + const input = document.getElementById('add-project-input') + const path = input?.value?.trim() + if (!path) return + + const res = await fetch('/api/projects/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }) + }) + const data = await res.json() + closeModal() + if (res.ok) { + window.renderCurrentRoute() + } else { + alert(data.error || 'Failed to add project') + } +} + +// ─── Plan Viewer ─── +async function showPlanViewer(sessionId) { + const res = await fetch(`/api/sessions/${sessionId}/plan`) + if (!res.ok) return + const data = await res.json() + const modal = document.getElementById('modal-content') + modal.innerHTML = ` +

${t('ops.view_plan')}

+
${safeMd(data.content)}
+ ` + document.getElementById('modal-overlay').classList.add('visible') +} diff --git a/viz/static/js/app.js b/viz/static/js/app.js new file mode 100644 index 00000000..213e3394 --- /dev/null +++ b/viz/static/js/app.js @@ -0,0 +1,541 @@ +/* Main SPA — router, WebSocket, page rendering */ + +let ws = null, wsRetryDelay = 1000 +const WS_MAX_RETRY = 30000 +let _sortCol = 'session_id', _sortAsc = false + +// ─── WebSocket ─── +function connectWebSocket() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' + ws = new WebSocket(`${proto}//${location.host}/ws`) + ws.onopen = () => { wsRetryDelay = 1000 } + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) + const route = parseRoute() + if (route.page === 'home') renderHome() + else if (route.page === 'session' && route.id === msg.session_id) renderSession(route.id) + } catch (_) {} + } + ws.onclose = () => { + setTimeout(() => { + wsRetryDelay = Math.min(wsRetryDelay * 2, WS_MAX_RETRY) + connectWebSocket() + }, wsRetryDelay) + } +} + +// ─── Router ─── +function parseRoute() { + const h = location.hash || '#/' + if (h === '#/' || h === '#') return { page: 'home' } + let m = h.match(/^#\/session\/([^/]+)\/analysis$/) + if (m) return { page: 'analysis', id: m[1] } + m = h.match(/^#\/session\/([^/]+)$/) + if (m) return { page: 'session', id: m[1] } + if (h === '#/analytics') return { page: 'analytics' } + return { page: 'home' } +} + +function navigate(hash) { location.hash = hash } + +window.renderCurrentRoute = function() { + const route = parseRoute() + const main = document.getElementById('main-content') + main.innerHTML = '' + updateTopbar(route) + switch (route.page) { + case 'home': renderHome(); break + case 'session': renderSession(route.id); break + case 'analysis': renderAnalysis(route.id); break + case 'analytics': renderAnalytics(); break + default: renderHome() + } +} + +window.addEventListener('hashchange', window.renderCurrentRoute) + +// ─── Topbar ─── +function updateTopbar(route) { + const left = document.getElementById('topbar-left') + const titleEl = document.getElementById('topbar-title') + const themeBtn = document.getElementById('theme-btn') + const analyticsLink = document.getElementById('analytics-link') + const opsContainer = document.getElementById('ops-dropdown-container') + + // Left area: always show logo (clickable to home), plus back button on sub-pages + if (route.page === 'home') { + left.innerHTML = ` + ` + titleEl.textContent = '' + } else { + left.innerHTML = ` + ${t('nav.back')} + ` + titleEl.textContent = route.id || '' + } + + // Right area + if (analyticsLink) analyticsLink.textContent = t('nav.analytics') + if (themeBtn) themeBtn.textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☾' + + // Ops dropdown — only on session/analysis pages + if (opsContainer) { + opsContainer.style.display = (route.page === 'session' || route.page === 'analysis') ? '' : 'none' + } + + // Populate ops menu labels + const labels = { 'ops-plan': 'ops.view_plan', 'ops-analysis': 'ops.analysis', 'ops-export-md': 'ops.export_md', 'ops-export-pdf': 'ops.export_pdf', 'ops-cancel': 'ops.cancel' } + for (const [id, key] of Object.entries(labels)) { + const el = document.getElementById(id) + if (el) el.textContent = t(key) + } +} + +// ─── Theme ─── +function initTheme() { + const saved = localStorage.getItem('humanize-viz-theme') + const theme = (saved === 'dark' || saved === 'light') ? saved : 'dark' + document.documentElement.setAttribute('data-theme', theme) + if (saved !== theme) localStorage.setItem('humanize-viz-theme', theme) +} + +function toggleTheme() { + const cur = document.documentElement.getAttribute('data-theme') + const next = cur === 'dark' ? 'light' : 'dark' + document.documentElement.setAttribute('data-theme', next) + localStorage.setItem('humanize-viz-theme', next) + // Update theme button icon immediately + const btn = document.getElementById('theme-btn') + if (btn) btn.textContent = next === 'dark' ? '☀' : '☾' + // Re-render only the main content (topbar stays) + const route = parseRoute() + const main = document.getElementById('main-content') + main.innerHTML = '' + switch (route.page) { + case 'home': renderHome(); break + case 'session': renderSession(route.id); break + case 'analysis': renderAnalysis(route.id); break + case 'analytics': renderAnalytics(); break + default: renderHome() + } +} + +// ─── API ─── +async function api(url) { + const r = await fetch(url) + return r.ok ? r.json() : null +} + +function fmtDuration(m) { + if (m == null) return '—' + if (m < 60) return `${m} ${t('unit.min')}` + return `${Math.floor(m/60)}h ${Math.round(m%60)}m` +} + +function _esc(str) { + const d = document.createElement('div') + d.textContent = str || '' + return d.innerHTML +} + +// ─── Home ─── +async function renderHome() { + const main = document.getElementById('main-content') + + // Load projects and sessions in parallel + const [projects, sessions] = await Promise.all([ + api('/api/projects').catch(() => []), + api('/api/sessions').catch(() => []), + ]) + + // Project switcher + const currentProject = (projects || []).find(p => p.active) || {} + const otherProjects = (projects || []).filter(p => !p.active) + const projectSwitcher = ` +
+
+ Project + ${_esc(currentProject.name || '—')} + ${_esc(currentProject.path || '')} +
+
+ ${otherProjects.length > 0 ? ` + ` : ''} + +
+
` + + if (!sessions || sessions.length === 0) { + main.innerHTML = `
${projectSwitcher}
${t('home.empty')}
${t('home.empty.hint')}
` + return + } + + const active = sessions.filter(s => ['active','analyzing','finalizing'].includes(s.status)) + const finished = sessions.filter(s => !['active','analyzing','finalizing'].includes(s.status)) + + let html = '' + if (active.length) { + html += `
${active.map(sessionCard).join('')}
` + } + if (finished.length) { + html += `
${finished.map(sessionCard).join('')}
` + } + + main.innerHTML = `
${projectSwitcher}${html}
` +} + +function sessionCard(s) { + const plan = s.plan_file ? s.plan_file.split('/').pop() : '—' + const started = s.started_at ? new Date(s.started_at).toLocaleString() : '—' + const acPct = s.ac_total > 0 ? Math.round(s.ac_done / s.ac_total * 100) : 0 + + return ` +
+
+ ${t('status.' + s.status)} + ${t('card.round')} ${s.current_round}/${s.max_iterations} +
+
+
${t('card.plan')}
${esc(plan)}
+
${t('card.branch')}
${esc(s.start_branch || '—')}
+
${t('card.verdict')}
${s.last_verdict}
+
${t('card.ac')}
${s.ac_done}/${s.ac_total}
+
${t('card.duration')}
${fmtDuration(s.duration_minutes)}
+
${t('card.started')}
${started}
+
+
+
+ ${t('detail.tasks')}: ${s.tasks_done}/${s.tasks_total} +
+
` +} + +// ─── Session Detail ─── +async function renderSession(sessionId) { + const main = document.getElementById('main-content') + const session = await api(`/api/sessions/${sessionId}`) + if (!session) { + main.innerHTML = `
${t('detail.not_found')}
` + return + } + + main.innerHTML = ` +
+
+
+
+
+
+
` + + renderPipeline(document.getElementById('pipeline-root'), session) + renderSessionSidebar(session) + renderGoalBar(session) + window._currentSession = session + + const cancelBtn = document.getElementById('ops-cancel') + if (cancelBtn) cancelBtn.style.display = session.status === 'active' ? '' : 'none' +} + +function renderSessionSidebar(s) { + const sidebar = document.getElementById('session-sidebar') + if (!sidebar) return + + const acTotal = s.ac_total || 0 + const acDone = s.ac_done || 0 + const acPct = acTotal > 0 ? Math.round(acDone / acTotal * 100) : 0 + + const vCounts = { advanced: 0, stalled: 0, regressed: 0 } + let reviewedRounds = 0 + for (const r of (s.rounds || [])) { + if (r.review_result && selectLang(r.review_result)) { + const v = r.verdict + if (v in vCounts) vCounts[v]++ + reviewedRounds++ + } + } + + const verdictBars = Object.entries(vCounts).map(([v, count]) => { + const pct = reviewedRounds > 0 ? Math.round(count / reviewedRounds * 100) : 0 + return `` + }).join('') + + const acs = s.goal_tracker?.acceptance_criteria || [] + const acListHtml = acs.map(ac => { + const icon = ac.status === 'completed' ? '✓' : ac.status === 'in_progress' ? '◉' : '○' + const color = ac.status === 'completed' ? 'var(--verdict-advanced)' : ac.status === 'in_progress' ? 'var(--verdict-active)' : 'var(--text-3)' + return `` + }).join('') + + const plan = s.plan_file ? s.plan_file.split('/').pop() : '—' + const started = s.started_at ? new Date(s.started_at).toLocaleString() : '—' + + sidebar.innerHTML = ` + + + + ${acs.length > 0 ? ` + ` : ''} + + ` +} + +function renderGoalBar(session) { + const bar = document.getElementById('goal-bar') + if (!bar || !session.goal_tracker) return + const acs = session.goal_tracker.acceptance_criteria || [] + bar.innerHTML = acs.map(ac => { + const cls = ac.status === 'completed' ? 'done' : ac.status === 'in_progress' ? 'wip' : '' + const icon = ac.status === 'completed' ? '✓' : ac.status === 'in_progress' ? '◉' : '○' + return `${icon} ${ac.id}` + }).join('') +} + +// ─── Analysis ─── +async function renderAnalysis(sessionId) { + const main = document.getElementById('main-content') + const session = await api(`/api/sessions/${sessionId}`) + if (!session) { + main.innerHTML = `
${t('detail.not_found')}
` + return + } + + const report = selectLang(session.methodology_report) + const hasReport = !!report + + let sanitizedHtml = `
${t('analysis.no_report')}
` + if (hasReport) { + const sanitized = await api(`/api/sessions/${sessionId}/sanitized-issue`) + if (sanitized) { + const w = sanitized.warnings || {} + const hasW = sanitized.requires_review || Object.keys(w).length > 0 + const warnBanner = hasW ? `
${t('analysis.review_warning')}
${Object.entries(w).map(([c,n]) => `• ${esc(c)}: ${n}`).join(' ')}
` : '' + const btns = hasW ? '' : `
` + sanitizedHtml = `${warnBanner}
${safeMd(sanitized.body)}
${t('analysis.gh_repo')}: humania-org/humanize
${btns}
` + } + } + + main.innerHTML = ` +
+
+
${t('analysis.report_tab')}
+
${t('analysis.summary_tab')}
+
+
+ ${hasReport ? `
${safeMd(report)}
` : `
${t('analysis.no_report')}
`} +
+ +
` + + document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach(el => el.classList.remove('active')) + document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none') + tab.classList.add('active') + document.getElementById('tab-' + tab.dataset.tab).style.display = 'block' + }) + }) + window._currentSession = session +} + +// ─── Analytics ─── +async function renderAnalytics() { + const main = document.getElementById('main-content') + const data = await api('/api/analytics') + if (!data) { + main.innerHTML = `
${t('analytics.no_data')}
` + return + } + + const o = data.overview + + main.innerHTML = ` +
+

${t('analytics.title')}

+
+
${o.total_sessions}
${t('analytics.total')}
+
${o.average_rounds}
${t('analytics.avg_rounds')}
+
${o.completion_rate}%
${t('analytics.completion')}
+
${o.total_bitlessons}
${t('analytics.bitlessons')}
+
+ +
+ +

${t('analytics.comparison')}

+
+
` + + buildCmpTable(data.session_stats) + + // Load timeline asynchronously (needs full session data, can be slow) + if (data.session_stats && data.session_stats.length > 0) { + loadTimeline(data.session_stats) + } +} + +async function loadTimeline(sessionStats) { + const root = document.getElementById('timeline-root') + if (!root) return + + try { + const sessions = await Promise.all( + sessionStats.map(s => api(`/api/sessions/${s.session_id}`).catch(() => null)) + ) + const valid = sessions.filter(Boolean) + if (valid.length === 0) return + + const rows = valid.map(s => { + const dots = (s.rounds || []).map(r => { + const v = r.verdict || 'unknown' + return `` + }).join('') + return `
+ ${s.id.slice(5, 16).replace('_', ' ')} +
${dots}
+ ${t('status.' + s.status)} +
` + }).join('') + + root.innerHTML = ` + +
+
${rows}
+
+ advanced + stalled + regressed + complete + unknown +
+
` + } catch (e) { + console.error('[analytics] timeline failed:', e) + } +} + +function buildCmpTable(stats) { + const root = document.getElementById('cmp-root') + if (!root || !stats || !stats.length) return + + const sorted = [...stats].sort((a, b) => { + let va, vb + switch (_sortCol) { + case 'rounds': va = a.rounds; vb = b.rounds; break + case 'duration': va = a.avg_duration_minutes || 0; vb = b.avg_duration_minutes || 0; break + case 'verdict': va = (a.verdict_breakdown||{}).advanced||0; vb = (b.verdict_breakdown||{}).advanced||0; break + case 'rework': va = a.rework_count; vb = b.rework_count; break + case 'ac': va = a.ac_completion_rate; vb = b.ac_completion_rate; break + default: va = a.session_id; vb = b.session_id + } + return _sortAsc ? (va < vb ? -1 : va > vb ? 1 : 0) : (va > vb ? -1 : va < vb ? 1 : 0) + }) + + const arr = c => _sortCol === c ? (_sortAsc ? ' ▲' : ' ▼') : '' + const cols = [ + ['session_id', 'Session'], + [null, 'Status'], + ['rounds', 'Rounds'], + ['duration', 'Duration'], + ['verdict', 'Verdict (A/S/R)'], + ['rework', 'Rework'], + ['ac', 'AC %'], + ] + + let html = `
${cols.map(([k, label]) => + k ? `` : `` + ).join('')}` + + for (const s of sorted) { + const vb = s.verdict_breakdown || {} + html += ` + + + + + + + + ` + } + html += '
${label}${arr(k)}${label}
${s.session_id}${t('status.' + s.status)}${s.rounds}${s.avg_duration_minutes != null ? s.avg_duration_minutes + ' min' : '—'}${vb.advanced||0}/${vb.stalled||0}/${vb.regressed||0}${s.rework_count}${s.ac_completion_rate}%
' + root.innerHTML = html + window._cmpStats = stats +} + +function sortCmp(col) { + if (_sortCol === col) _sortAsc = !_sortAsc + else { _sortCol = col; _sortAsc = true } + if (window._cmpStats) buildCmpTable(window._cmpStats) +} + +// ─── Init ─── +document.addEventListener('DOMContentLoaded', () => { + initTheme() + connectWebSocket() + window.renderCurrentRoute() +}) diff --git a/viz/static/js/charts.js b/viz/static/js/charts.js new file mode 100644 index 00000000..f536c95d --- /dev/null +++ b/viz/static/js/charts.js @@ -0,0 +1,158 @@ +/* Chart.js analytics v3 */ +console.log('[charts] v3 loaded') + +const _charts = {} + +function _colors() { + const s = getComputedStyle(document.documentElement) + const g = k => s.getPropertyValue(k).trim() + return { + accent: g('--accent') || '#d97757', + success: g('--verdict-advanced') || '#6ee7a0', + warning: g('--verdict-stalled') || '#fbbf24', + danger: g('--verdict-regressed') || '#f87171', + info: g('--verdict-active') || '#60a5fa', + purple: g('--verdict-complete') || '#a78bfa', + muted: g('--verdict-unknown') || '#6b7280', + text: g('--text-2') || '#8a877f', + gridLine: g('--border-1') || 'rgba(255,255,255,0.06)', + bg2: g('--bg-2') || '#1e1e24', + } +} + +function _baseOpts(c) { + return { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 600 }, + plugins: { + legend: { display: false }, + tooltip: { backgroundColor: c.bg2, titleColor: c.text, bodyColor: c.text, borderColor: c.accent, borderWidth: 1, cornerRadius: 8, padding: 10 }, + }, + scales: { + x: { ticks: { color: c.text, font: { size: 10 } }, grid: { color: c.gridLine }, border: { color: c.gridLine } }, + y: { ticks: { color: c.text, font: { size: 10 } }, grid: { color: c.gridLine }, border: { color: c.gridLine }, beginAtZero: true }, + } + } +} + +function _noScaleOpts(c) { + return { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 600 }, + plugins: { + legend: { position: 'right', labels: { color: c.text, font: { size: 11 }, padding: 12, usePointStyle: true, pointStyleWidth: 10 } }, + tooltip: { backgroundColor: c.bg2, titleColor: c.text, bodyColor: c.text, borderColor: c.accent, borderWidth: 1, cornerRadius: 8, padding: 10 }, + }, + } +} + +function _showEmpty(canvasId, msg) { + const el = document.getElementById(canvasId) + if (!el) return + el.parentElement.innerHTML = `
${msg}
` +} + +function _makeChart(canvasId, config) { + const el = document.getElementById(canvasId) + if (!el) { console.warn('[charts] canvas not found:', canvasId); return null } + try { + return new Chart(el, config) + } catch (e) { + console.error('[charts] failed to create', canvasId, e) + return null + } +} + +function buildCharts(data) { + // Destroy previous charts + Object.values(_charts).forEach(ch => { try { ch.destroy() } catch(e) {} }) + for (const k of Object.keys(_charts)) delete _charts[k] + + const c = _colors() + const stats = data.session_stats || [] + const labels = stats.map(s => s.session_id.slice(5, 16).replace('_', ' ')) + + console.log('[charts] buildCharts called, stats:', stats.length, 'el c-rounds:', !!document.getElementById('c-rounds')) + + // 1. Rounds per session + if (stats.length > 0) { + const ch = _makeChart('c-rounds', { + type: stats.length === 1 ? 'bar' : 'line', + data: { labels, datasets: [{ label: 'Rounds', data: stats.map(s => s.rounds), borderColor: c.accent, backgroundColor: stats.length === 1 ? c.accent + 'cc' : c.accent + '18', fill: stats.length > 1, tension: 0.4, pointRadius: 5, pointBackgroundColor: c.accent, borderRadius: 6, barThickness: 40 }] }, + options: _baseOpts(c), + }) + if (ch) _charts.rounds = ch + } else { + _showEmpty('c-rounds', 'No session data yet') + } + + // 2. Avg round duration + if (stats.some(s => s.avg_duration_minutes != null)) { + const ch = _makeChart('c-duration', { + type: 'bar', + data: { labels, datasets: [{ label: 'Avg Duration (min)', data: stats.map(s => s.avg_duration_minutes), backgroundColor: c.info + 'aa', borderColor: c.info, borderWidth: 1, borderRadius: 6, barThickness: 40 }] }, + options: _baseOpts(c), + }) + if (ch) _charts.dur = ch + } else { + _showEmpty('c-duration', 'No duration data available') + } + + // 3. Verdict distribution (doughnut) + const vd = data.verdict_distribution || {} + const vdEntries = Object.entries(vd).filter(([_, v]) => v > 0) + if (vdEntries.length > 0) { + const colorMap = { advanced: c.success, stalled: c.warning, regressed: c.danger, complete: c.purple, unknown: c.muted } + const ch = _makeChart('c-verdicts', { + type: 'doughnut', + data: { labels: vdEntries.map(([k]) => k), datasets: [{ data: vdEntries.map(([_, v]) => v), backgroundColor: vdEntries.map(([k]) => colorMap[k] || c.muted), borderWidth: 2, borderColor: c.bg2 }] }, + options: _noScaleOpts(c), + }) + if (ch) _charts.v = ch + } else { + _showEmpty('c-verdicts', 'No reviewed rounds yet') + } + + // 4. P-issues distribution + const pd = data.p_distribution || {} + const pk = Object.keys(pd).sort() + if (pk.length > 0) { + const palette = [c.danger, c.warning, c.accent, c.info, c.success, c.purple, c.muted] + const ch = _makeChart('c-pissues', { + type: 'bar', + data: { labels: pk, datasets: [{ label: 'Issues', data: pk.map(k => pd[k]), backgroundColor: pk.map((_, i) => palette[i % palette.length] + 'bb'), borderColor: pk.map((_, i) => palette[i % palette.length]), borderWidth: 1, borderRadius: 6 }] }, + options: _baseOpts(c), + }) + if (ch) _charts.p = ch + } else { + _showEmpty('c-pissues', 'No P0-P9 issues recorded') + } + + // 5. First COMPLETE round + const fcData = stats.filter(s => s.first_complete_round != null && s.first_complete_round > 0) + if (fcData.length > 0) { + const ch = _makeChart('c-fc', { + type: fcData.length === 1 ? 'bar' : 'line', + data: { labels: fcData.map(s => s.session_id.slice(5, 16).replace('_', ' ')), datasets: [{ label: 'First COMPLETE at Round', data: fcData.map(s => s.first_complete_round), borderColor: c.success, backgroundColor: fcData.length === 1 ? c.success + 'cc' : c.success + '18', fill: fcData.length > 1, tension: 0.4, pointRadius: 5, pointBackgroundColor: c.success, borderRadius: 6, barThickness: 40 }] }, + options: _baseOpts(c), + }) + if (ch) _charts.fc = ch + } else { + _showEmpty('c-fc', 'No sessions reached COMPLETE yet') + } + + // 6. BitLesson growth + const bl = data.bitlesson_growth || [] + if (bl.length > 0 && bl.some(b => b.cumulative > 0)) { + const ch = _makeChart('c-bl', { + type: bl.length === 1 ? 'bar' : 'line', + data: { labels: bl.map(b => b.session_id.slice(5, 16).replace('_', ' ')), datasets: [{ label: 'Cumulative BitLessons', data: bl.map(b => b.cumulative), borderColor: c.accent, backgroundColor: bl.length === 1 ? c.accent + 'cc' : c.accent + '25', fill: bl.length > 1, tension: 0.4, pointRadius: 5, pointBackgroundColor: c.accent, borderRadius: 6, barThickness: 40 }] }, + options: _baseOpts(c), + }) + if (ch) _charts.bl = ch + } else { + _showEmpty('c-bl', 'No BitLesson entries yet') + } +} diff --git a/viz/static/js/i18n.js b/viz/static/js/i18n.js new file mode 100644 index 00000000..0665fafa --- /dev/null +++ b/viz/static/js/i18n.js @@ -0,0 +1,100 @@ +/* UI labels — English only */ + +const _LABELS = { + 'app.title': 'Humanize Viz', + 'nav.analytics': 'Analytics', + 'nav.back': '← Back', + 'home.active': 'Active', + 'home.completed': 'Completed', + 'home.empty': 'No RLCR sessions found', + 'home.empty.hint': 'Start an RLCR loop in your project and sessions will appear here.', + 'card.round': 'Round', + 'card.plan': 'Plan', + 'card.branch': 'Branch', + 'card.verdict': 'Verdict', + 'card.ac': 'AC', + 'card.started': 'Started', + 'card.duration': 'Duration', + 'detail.summary': 'Summary', + 'detail.review': 'Codex Review', + 'detail.phase': 'Phase', + 'detail.tasks': 'Tasks', + 'detail.bitlesson': 'BitLesson', + 'detail.no_summary': 'Summary not yet available', + 'detail.no_review': 'Review not yet available', + 'detail.not_found': 'Session not found', + 'detail.click_node': 'Click a node to expand round details', + 'ops.view_plan': 'View Plan', + 'ops.analysis': 'Methodology Analysis', + 'ops.export_md': 'Export Markdown', + 'ops.export_pdf': 'Export PDF', + 'ops.cancel': 'Cancel Loop', + 'cancel.title': 'Confirm Cancel', + 'cancel.message': 'Cancel the current RLCR loop? This cannot be undone.', + 'cancel.confirm': 'Confirm', + 'cancel.dismiss': 'Close', + 'cancel.failed': 'Cancel failed', + 'analysis.report_tab': 'Methodology Report', + 'analysis.summary_tab': 'Sanitized Summary', + 'analysis.no_report': 'Analysis report not yet available', + 'analysis.gh_repo': 'Target repo', + 'analysis.preview': 'Preview Issue', + 'analysis.send': 'Send to GitHub', + 'analysis.copy': 'Copy Content', + 'analysis.sent': 'Sent', + 'analysis.sending': 'Sending...', + 'analysis.failed': 'Failed', + 'analysis.issue_title': 'Title', + 'analysis.issue_body': 'Body', + 'analysis.review_warning': '⚠ Sanitization check found issues. Review the methodology report manually and remove project-specific content before sending.', + 'analytics.title': 'Cross-Session Analytics', + 'analytics.total': 'Total Sessions', + 'analytics.avg_rounds': 'Avg Rounds', + 'analytics.completion': 'Completion Rate', + 'analytics.bitlessons': 'Total BitLessons', + 'analytics.rounds_trend': 'Rounds per Session', + 'analytics.duration': 'Avg Round Duration (min)', + 'analytics.verdicts': 'Verdict Distribution', + 'analytics.p_issues': 'P0-P9 Issues', + 'analytics.first_complete': 'First COMPLETE Round', + 'analytics.bl_growth': 'BitLesson Growth', + 'analytics.comparison': 'Session Comparison', + 'analytics.no_data': 'No analytics data', + 'analytics.col_session': 'Session', + 'analytics.col_status': 'Status', + 'analytics.rework': 'Rework', + 'status.active': 'Active', + 'status.complete': 'Complete', + 'status.cancel': 'Cancelled', + 'status.stop': 'Stopped', + 'status.maxiter': 'Max Iter', + 'status.unknown': 'Unknown', + 'status.analyzing': 'Analyzing', + 'status.finalizing': 'Finalizing', + 'phase.implementation': 'Impl', + 'phase.code_review': 'Review', + 'phase.finalize': 'Final', + 'node.setup': 'Setup', + 'unit.min': 'min', +} + +function t(key) { + return _LABELS[key] || key +} + +// Content language selection from {zh, en} objects — prefer English +function selectLang(content) { + if (!content) return null + if (typeof content === 'string') return content + if (typeof content === 'object') { + return content['en'] || content['zh'] || null + } + return null +} + +// Safe Markdown rendering — parse then sanitize to prevent XSS +function safeMd(text) { + if (!text) return '' + const html = marked.parse(text) + return typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html) : html +} diff --git a/viz/static/js/pipeline.js b/viz/static/js/pipeline.js new file mode 100644 index 00000000..f5251202 --- /dev/null +++ b/viz/static/js/pipeline.js @@ -0,0 +1,341 @@ +/* Pipeline — snake-path node layout with SVG connectors + zoom/pan + flyout detail */ + +const PL = { + COLS: 4, + NODE_W: 230, + NODE_H: 68, + GAP_X: 52, + GAP_Y: 48, + TURN_H: 56, + PADDING: 40, +} + +let _scale = 1, _tx = 0, _ty = 0 +let _dragging = false, _dragStartX = 0, _dragStartY = 0, _dragTx = 0, _dragTy = 0 + +function renderPipeline(container, session) { + if (!container || !session) return + const rounds = session.rounds || [] + if (rounds.length === 0) { + container.innerHTML = `
${t('home.empty')}
` + return + } + + const isActive = session.status === 'active' + // Total node count: rounds + 1 ghost node for active sessions + const totalNodes = isActive ? rounds.length + 1 : rounds.length + const positions = computePositions(totalNodes) + const totalW = PL.PADDING * 2 + PL.COLS * PL.NODE_W + (PL.COLS - 1) * PL.GAP_X + const rows = Math.ceil(totalNodes / PL.COLS) + const totalH = PL.PADDING * 2 + rows * PL.NODE_H + (rows - 1) * (PL.GAP_Y + PL.TURN_H) + + let svgPaths = '' + for (let i = 0; i < totalNodes - 1; i++) { + const isLastEdge = isActive && i === rounds.length - 1 + svgPaths += buildConnector(positions[i], positions[i + 1], isLastEdge) + } + + let nodesHtml = '' + rounds.forEach((r, idx) => { + nodesHtml += renderNodeCard(r, session, positions[idx]) + }) + + // Ghost "in progress" node for active sessions + if (isActive) { + const ghostPos = positions[rounds.length] + nodesHtml += renderGhostNode(session, ghostPos) + } + + _scale = 1; _tx = 0; _ty = 0 + + container.innerHTML = ` +
+
+ + + +
+
+ + ${svgPaths} + + ${nodesHtml} +
+
+
+
+
` + + const vp = document.getElementById('pl-viewport') + vp.addEventListener('wheel', onWheel, { passive: false }) + vp.addEventListener('mousedown', onDragStart) + window.addEventListener('mousemove', onDragMove) + window.addEventListener('mouseup', onDragEnd) + + setTimeout(() => plFit(), 50) +} + +function computePositions(count) { + const positions = [] + for (let i = 0; i < count; i++) { + const row = Math.floor(i / PL.COLS) + const colInRow = i % PL.COLS + const reversed = row % 2 === 1 + const col = reversed ? (PL.COLS - 1 - colInRow) : colInRow + positions.push({ + x: PL.PADDING + col * (PL.NODE_W + PL.GAP_X), + y: PL.PADDING + row * (PL.NODE_H + PL.GAP_Y + PL.TURN_H), + row, col, reversed + }) + } + return positions +} + +function buildConnector(a, b, animated) { + const ay = a.y + PL.NODE_H / 2 + const by = b.y + PL.NODE_H / 2 + const cls = animated ? 'class="pl-edge-active"' : '' + const color = animated ? 'var(--accent)' : 'var(--border-2)' + const style = `fill="none" stroke="${color}" stroke-width="2" stroke-dasharray="6 4" ${cls}` + + if (a.row === b.row) { + const x1 = a.reversed ? a.x : a.x + PL.NODE_W + const x2 = a.reversed ? b.x + PL.NODE_W : b.x + return `` + } + + const exitX = a.reversed ? a.x : a.x + PL.NODE_W + const enterX = b.reversed ? b.x + PL.NODE_W : b.x + const midY = (a.y + PL.NODE_H + b.y) / 2 + const sideX = a.reversed ? Math.min(a.x, b.x) - PL.GAP_X * 0.4 : Math.max(a.x + PL.NODE_W, b.x + PL.NODE_W) + PL.GAP_X * 0.4 + + return `` +} + +function renderNodeCard(r, session, pos) { + const hasSummary = !!selectLang(r.summary) + const verdict = r.verdict || 'unknown' + const isActive = session.status === 'active' && r.number === session.current_round + const phaseLabel = r.number === 0 ? t('node.setup') : (t(`phase.${r.phase}`) || r.phase) + + let stats = [] + if (r.duration_minutes) stats.push(`${r.duration_minutes}${t('unit.min')}`) + if (r.bitlesson_delta && r.bitlesson_delta !== 'none') stats.push('📚') + if (!hasSummary) stats.push('…') + + // Active node gets a progress indicator + const activeIndicator = isActive ? ` +
+
+
` : '' + + return ` +
+ ${activeIndicator} +
+
+ R${r.number} + ${esc(phaseLabel)} + ${isActive ? '' : ''} +
+
+ + ${verdict} +
+
+ ${stats.length ? `
${stats.map(s => `${s}`).join('')}
` : ''} +
` +} + +function renderGhostNode(session, pos) { + const nextRound = session.current_round + 1 + return ` +
+
+
+ R${nextRound} + Next +
+
+ +
+
+
+ Awaiting... +
+
` +} + +// ─── Flyout Modal (expand from node to center) ─── + +function openFlyout(nodeEl, roundNum) { + if (_dragging) return + const session = window._currentSession + if (!session) return + const round = session.rounds.find(r => r.number === roundNum) + if (!round) return + + const overlay = document.getElementById('flyout-overlay') + const panel = document.getElementById('flyout-panel') + if (!overlay || !panel) return + + // Get node position on screen + const rect = nodeEl.getBoundingClientRect() + const vpRect = overlay.parentElement.getBoundingClientRect() + + // Set initial position to match node + panel.style.transition = 'none' + panel.style.left = (rect.left - vpRect.left) + 'px' + panel.style.top = (rect.top - vpRect.top) + 'px' + panel.style.width = rect.width + 'px' + panel.style.height = rect.height + 'px' + panel.style.opacity = '0.7' + panel.style.borderRadius = '14px' + panel.innerHTML = '' + + // Show overlay + overlay.classList.add('visible') + + // Animate to center + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const targetW = Math.min(720, vpRect.width - 80) + const targetH = Math.min(vpRect.height - 100, 600) + const targetL = (vpRect.width - targetW) / 2 + const targetT = (vpRect.height - targetH) / 2 + + panel.style.transition = 'all 400ms cubic-bezier(0.16, 1, 0.3, 1)' + panel.style.left = targetL + 'px' + panel.style.top = targetT + 'px' + panel.style.width = targetW + 'px' + panel.style.height = targetH + 'px' + panel.style.opacity = '1' + panel.style.borderRadius = '20px' + + // Fill content after animation starts + setTimeout(() => { + panel.innerHTML = buildFlyoutContent(round, session) + }, 150) + }) + }) +} + +function closeFlyout() { + const overlay = document.getElementById('flyout-overlay') + const panel = document.getElementById('flyout-panel') + if (!overlay || !panel) return + + panel.style.transition = 'all 300ms cubic-bezier(0.45, 0, 0.55, 1)' + panel.style.opacity = '0' + panel.style.transform = 'scale(0.9)' + + setTimeout(() => { + overlay.classList.remove('visible') + panel.style.transform = '' + panel.innerHTML = '' + }, 300) +} + +function buildFlyoutContent(round, session) { + const verdict = round.verdict || 'unknown' + const phaseLabel = round.number === 0 ? t('node.setup') : (t(`phase.${round.phase}`) || round.phase) + const summary = selectLang(round.summary) + const review = selectLang(round.review_result) + + const summaryHtml = summary ? safeMd(summary) : `${t('detail.no_summary')}` + const reviewHtml = review ? safeMd(review) : `${t('detail.no_review')}` + + let metaItems = ` + ${t('detail.phase')}: ${esc(phaseLabel)} + ${t('card.verdict')}: ${verdict}` + if (round.duration_minutes) metaItems += `${t('card.duration')}: ${round.duration_minutes} ${t('unit.min')}` + if (round.bitlesson_delta && round.bitlesson_delta !== 'none') metaItems += `${t('detail.bitlesson')}: ${round.bitlesson_delta} 📚` + if (round.task_progress != null) metaItems += `${t('detail.tasks')}: ${round.task_progress}/${session.tasks_total || '?'}` + + return ` +
+
+ R${round.number} +

${t('card.round')} ${round.number}

+
+ +
+
${metaItems}
+
+
+

${t('detail.summary')}

+
${summaryHtml}
+
+
+

${t('detail.review')}

+
${reviewHtml}
+
+
` +} + +// ─── Zoom / Pan ─── +function applyTransform() { + const canvas = document.getElementById('pl-canvas') + if (canvas) canvas.style.transform = `translate(${_tx}px, ${_ty}px) scale(${_scale})` +} + +function plZoom(delta) { + _scale = Math.max(0.3, Math.min(2.5, _scale + delta)) + applyTransform() +} + +function plFit() { + const vp = document.getElementById('pl-viewport') + const canvas = document.getElementById('pl-canvas') + if (!vp || !canvas) return + const vpW = vp.clientWidth, vpH = vp.clientHeight + const cW = parseInt(canvas.style.width), cH = parseInt(canvas.style.height) + _scale = Math.min(vpW / cW, vpH / cH, 1) * 0.92 + _tx = (vpW - cW * _scale) / 2 + _ty = Math.max(8, (vpH - cH * _scale) / 2) + applyTransform() +} + +function onWheel(e) { + e.preventDefault() + const delta = e.deltaY > 0 ? -0.08 : 0.08 + const rect = e.currentTarget.getBoundingClientRect() + const mx = e.clientX - rect.left, my = e.clientY - rect.top + const oldScale = _scale + _scale = Math.max(0.3, Math.min(2.5, _scale + delta)) + const ratio = _scale / oldScale + _tx = mx - ratio * (mx - _tx) + _ty = my - ratio * (my - _ty) + applyTransform() +} + +function onDragStart(e) { + if (e.target.closest('.pl-node') || e.target.closest('.pl-ctrl-btn')) return + _dragging = true + _dragStartX = e.clientX; _dragStartY = e.clientY + _dragTx = _tx; _dragTy = _ty + e.currentTarget.style.cursor = 'grabbing' +} + +function onDragMove(e) { + if (!_dragging) return + _tx = _dragTx + (e.clientX - _dragStartX) + _ty = _dragTy + (e.clientY - _dragStartY) + applyTransform() +} + +function onDragEnd() { + if (!_dragging) return + _dragging = false + const vp = document.getElementById('pl-viewport') + if (vp) vp.style.cursor = '' +} + +function esc(str) { + const d = document.createElement('div') + d.textContent = str || '' + return d.innerHTML +}