#!/usr/bin/env bash # ── Consolidated Cycle v1 ────────────────────────────────────────────── # Single-execution cycle. Cron fires this. No while-true. No sleep. # # 3 phases, always in order: # 1. Watchdog — bash only, zero tokens, always runs # 2. Dev cycle — sonnet, skips if no work or plateau detected # 3. Philosophy — opus, skips if ran in last 24h # # PLATEAU DETECTION: # Tracks cycle outcomes in .loop/cycle-metrics.jsonl # If last N cycles produced zero merged PRs and zero new issues filed, # the loop is plateauing — it skips the LLM call and logs why. # Plateau resets when: new issues appear, PRs merge, or owner comments. # ─────────────────────────────────────────────────────────────────────── set -uo pipefail export PATH="$HOME/.local/bin:$HOME/.hermes/bin:/usr/local/bin:$PATH" REPO="$HOME/Timmy-Time-dashboard" STATE="$REPO/.loop/state.json" CLAIMS="$REPO/.loop/claims.json" PROMPT_FILE="$HOME/.hermes/bin/timmy-loop-prompt.md" LOG_DIR="$REPO/.loop/logs" METRICS="$REPO/.loop/cycle-metrics.jsonl" QUEUE_FILE="$REPO/.loop/queue.json" TRIAGE_SCRIPT="$REPO/scripts/triage_score.py" RETRO_SCRIPT="$REPO/scripts/cycle_retro.py" GITEA_URL="http://143.198.27.163:3000" GITEA_API="$GITEA_URL/api/v1" GITEA_TOKEN=$(cat ~/.hermes/gitea_token_vps 2>/dev/null || cat ~/.hermes/gitea_token 2>/dev/null) REPO_API="$GITEA_API/repos/rockachopa/Timmy-time-dashboard" MAX_CYCLE_TIME=1200 PHILOSOPHY_MARKER="/tmp/philosophy-last-run" PLATEAU_THRESHOLD=3 # skip after N consecutive zero-output cycles DEV_MODEL="claude-sonnet-4-20250514" PHILOSOPHY_MODEL="claude-opus-4-6" # macOS timeout fallback if ! command -v timeout &>/dev/null; then timeout() { local d="$1"; shift; perl -e "alarm $d; exec @ARGV" -- "$@"; } fi log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } # ── Plateau Detection ───────────────────────────────────────────────── check_plateau() { # Returns 0 (true/plateau) or 1 (false/work to do) [ ! -f "$METRICS" ] && return 1 # no history = no plateau local recent recent=$(tail -n "$PLATEAU_THRESHOLD" "$METRICS" 2>/dev/null) local zero_count=0 local total=0 while IFS= read -r line; do total=$((total + 1)) local prs_merged issues_filed prs_merged=$(echo "$line" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('prs_merged',0))" 2>/dev/null || echo 0) issues_filed=$(echo "$line" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('issues_filed',0))" 2>/dev/null || echo 0) if [ "$prs_merged" -eq 0 ] && [ "$issues_filed" -eq 0 ]; then zero_count=$((zero_count + 1)) fi done <<< "$recent" if [ "$total" -ge "$PLATEAU_THRESHOLD" ] && [ "$zero_count" -ge "$PLATEAU_THRESHOLD" ]; then return 0 # plateau detected fi return 1 # not plateauing } log_metric() { # $1=prs_merged $2=issues_filed $3=outcome local ts ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) echo "{\"ts\":\"$ts\",\"prs_merged\":${1:-0},\"issues_filed\":${2:-0},\"outcome\":\"${3:-unknown}\"}" >> "$METRICS" } # ── Phase 1: Watchdog (bash only, zero tokens) ─────────────────────── phase_watchdog() { log "── WATCHDOG ──" # Kill orphaned pytest processes (> 20 min) ps aux | grep "pytest tests/" | grep -v grep | while read -r line; do local pid etime pid=$(echo "$line" | awk '{print $2}') etime=$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ') if [[ "$etime" == *:*:* ]]; then log " Killing stale pytest PID $pid ($etime)" kill "$pid" 2>/dev/null fi done # Kill stuck git pushes (> 10 min) ps aux | grep "git.*push\|git-remote-http" | grep -v grep | while read -r line; do local pid etime pid=$(echo "$line" | awk '{print $2}') etime=$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ') if [[ "$etime" == *:*:* ]]; then log " Killing stuck git PID $pid ($etime)" kill "$pid" 2>/dev/null fi done # Kill orphaned vi/vim editors ps aux | grep "vi.*COMMIT_EDITMSG" | grep -v grep | awk '{print $2}' | xargs kill 2>/dev/null # Expire stale claims if [ -f "$CLAIMS" ]; then python3 -c " import json, time try: claims = json.load(open('$CLAIMS')) now = time.time() expired = [k for k,v in claims.items() if isinstance(v, dict) and now - v.get('ts', now) > 3600] for k in expired: del claims[k] print(f' Expired claim: {k}') if expired: json.dump(claims, open('$CLAIMS', 'w'), indent=2) except: pass " 2>/dev/null fi # Gitea health if curl -s --max-time 5 "$GITEA_URL/api/v1/version" >/dev/null 2>&1; then log " Gitea: OK" else log " WARNING: Gitea unreachable" fi } # ── Phase 2: Dev Cycle (sonnet) ─────────────────────────────────────── phase_dev() { log "── DEV CYCLE (model: $DEV_MODEL) ──" # Plateau check if check_plateau; then log " PLATEAU: Last $PLATEAU_THRESHOLD cycles produced no output. Skipping LLM call." log " (Will resume when new issues appear or PRs need review)" # But still check if there's new external activity that breaks plateau local open_prs open_prs=$(curl -s -H "Authorization: token $GITEA_TOKEN" \ "$REPO_API/pulls?state=open&limit=5" 2>/dev/null | \ python3 -c "import sys,json; print(len(json.loads(sys.stdin.read())))" 2>/dev/null || echo 0) if [ "$open_prs" -gt 0 ]; then log " But $open_prs open PRs found — breaking plateau for review." else log_metric 0 0 "plateau_skip" return fi fi # Fast triage if [ -f "$TRIAGE_SCRIPT" ]; then GITEA_API="$GITEA_API" GITEA_TOKEN="$GITEA_TOKEN" \ python3 "$TRIAGE_SCRIPT" > "$QUEUE_FILE" 2>/dev/null || true fi local queue_size=0 if [ -f "$QUEUE_FILE" ]; then queue_size=$(python3 -c "import json; print(len(json.load(open('$QUEUE_FILE'))))" 2>/dev/null || echo 0) fi if [ "$queue_size" -eq 0 ]; then log " No work in queue. Skipping." log_metric 0 0 "empty_queue" return fi log " Queue: $queue_size items" local PROMPT PROMPT=$(cat "$PROMPT_FILE" 2>/dev/null) [ -z "$PROMPT" ] && { log "ERROR: No prompt file"; return; } local QUEUE_SUMMARY QUEUE_SUMMARY=$(python3 -c " import json q = json.load(open('$QUEUE_FILE')) lines = ['PRIORITIZED QUEUE ({} ready issues):'.format(len(q))] for i, item in enumerate(q[:8]): score = item.get('score', 0) title = item.get('title', '?')[:70] num = item.get('number', '?') labels = ','.join(item.get('labels', [])) files = ', '.join(item.get('files', [])[:3]) lines.append(f' {i+1}. #{num} [{labels}] score={score} — {title}') if files: lines.append(f' files: {files}') if len(q) > 8: lines.append(f' ... +{len(q)-8} more') print('\n'.join(lines)) " 2>/dev/null || echo "Queue: error") local FULL_PROMPT="TIME BUDGET: 20 minutes. Be efficient — sonnet, not opus. $QUEUE_SUMMARY Pick from the TOP of this queue. $PROMPT" local CYCLE_LOG="$LOG_DIR/cycle-$(date +%Y%m%d_%H%M%S).log" mkdir -p "$LOG_DIR" if timeout "$MAX_CYCLE_TIME" hermes chat --yolo \ --provider anthropic \ --model "$DEV_MODEL" \ -q "$FULL_PROMPT" 2>&1 | tee "$CYCLE_LOG"; then log " Cycle completed OK" # TODO: parse cycle log for prs_merged / issues_filed counts log_metric 0 0 "completed" else log " Cycle failed (exit $?)" log_metric 0 0 "failed" fi } # ── Phase 3: Philosophy (opus, daily) ───────────────────────────────── phase_philosophy() { if [ -f "$PHILOSOPHY_MARKER" ]; then local last_run now elapsed last_run=$(cat "$PHILOSOPHY_MARKER" 2>/dev/null || echo 0) now=$(date +%s) elapsed=$((now - last_run)) if [ "$elapsed" -lt 86400 ]; then return # ran today already fi fi log "── PHILOSOPHY (daily, model: $PHILOSOPHY_MODEL) ──" timeout 600 hermes chat --yolo \ --provider anthropic \ --model "$PHILOSOPHY_MODEL" \ -q "You are Hermes Agent on a philosophy loop. Study the next influence from ~/philosophy-journal.md. Search web for a real primary source. Write 300-500 word reflection on agentic architecture. File Gitea issue at $REPO_API/issues (token from ~/.hermes/gitea_token_vps). Append to ~/philosophy-journal.md. Tag: [philosophy]." \ 2>&1 | tee "$LOG_DIR/philosophy-$(date +%Y%m%d).log" || true date +%s > "$PHILOSOPHY_MARKER" log " Philosophy complete. Next: ~24h." } # ── Main (single execution) ─────────────────────────────────────────── log "=== CONSOLIDATED CYCLE START ===" phase_watchdog phase_dev phase_philosophy log "=== CONSOLIDATED CYCLE END ==="