#!/usr/bin/env bash # ── Timmy Development Loop v3 ────────────────────────────────────────── # Runs forever. Each cycle: triage → Hermes executes → retro → repeat. # # TRIAGE (two tiers): # Fast (every 5 cycles): mechanical scoring → .loop/queue.json # Deep (every 20 cycles): Hermes reads code + consults Timmy → refined queue # # RETRO (every cycle): structured log → .loop/retro/cycles.jsonl # ─────────────────────────────────────────────────────────────────────── set -uo pipefail export PATH="$HOME/.local/bin:$HOME/.hermes/bin:/usr/local/bin:$PATH" REPO="$HOME/.hermes" STATE="$REPO/loop/state.json" LOG_DIR="$REPO/loop/logs" CLAIMS="$REPO/loop/claims.json" PROMPT_FILE="$HOME/.hermes/bin/timmy-loop-prompt.md" LOCKFILE="/tmp/timmy-loop.lock" COOLDOWN=3 IDLE_COOLDOWN=60 # 1 min base when idle (no queue / no work) IDLE_MAX_COOLDOWN=600 # 10 min max backoff when idle IDLE_STREAK=0 # consecutive idle cycles MAX_CYCLE_TIME=1200 # 20 min — enough for complex issues CLAIM_TTL_SECONDS=3600 # 1 hour — stale claims auto-expire FAST_TRIAGE_INTERVAL=5 # mechanical scoring every N cycles DEEP_TRIAGE_INTERVAL=20 # Hermes+Timmy deep triage every N cycles TRIAGE_SCRIPT="$REPO/scripts/triage_score.py" RETRO_SCRIPT="$REPO/scripts/cycle_retro.py" DEEP_TRIAGE_SCRIPT="$REPO/scripts/deep_triage.sh" QUEUE_FILE="$REPO/loop/queue.json" # macOS doesn't have timeout; use perl fallback if ! command -v timeout &>/dev/null; then timeout() { local duration="$1"; shift perl -e "alarm $duration; exec @ARGV" -- "$@" } fi mkdir -p "$LOG_DIR" # ── Single-instance lock ────────────────────────────────────────────── if [ -f "$LOCKFILE" ]; then PID=$(cat "$LOCKFILE" 2>/dev/null) if kill -0 "$PID" 2>/dev/null; then echo "[loop] Already running (PID $PID). Exiting." exit 0 fi rm -f "$LOCKFILE" fi echo $$ > "$LOCKFILE" trap 'rm -f "$LOCKFILE"' EXIT # ── Helpers ─────────────────────────────────────────────────────────── update_state() { local key="$1" value="$2" python3 -c " import json with open('$STATE') as f: s = json.load(f) s['$key'] = $value with open('$STATE', 'w') as f: json.dump(s, f, indent=2) " } log() { echo "[$(date '+%H:%M:%S')] $*" } # ── Expire stale claims ────────────────────────────────────────────── expire_claims() { python3 -c " import json from datetime import datetime, timezone, timedelta TTL = timedelta(seconds=$CLAIM_TTL_SECONDS) now = datetime.now(timezone.utc) try: with open('$CLAIMS') as f: claims = json.load(f) except: claims = {} expired = [] active = {} for issue, info in claims.items(): if isinstance(info, dict): claimed_at = info.get('at', '') try: dt = datetime.fromisoformat(claimed_at.replace('Z', '+00:00')) if now - dt > TTL: expired.append(issue) continue except: pass active[issue] = info if expired: print(f'[claims] Expired {len(expired)} stale claims: {expired}') with open('$CLAIMS', 'w') as f: json.dump(active, f, indent=2) else: print('[claims] No stale claims') " 2>&1 } # ── Cleanup after timeout/failure ───────────────────────────────────── cleanup_cycle() { local cycle_num="$1" local worktree="/tmp/timmy-cycle-${cycle_num}" # Remove worktree if it exists if [ -d "$worktree" ]; then log "Cleaning up worktree $worktree" cd "$REPO" && git worktree remove "$worktree" --force 2>/dev/null || true fi # Release any claims held by this cycle python3 -c " import json try: with open('$CLAIMS') as f: claims = json.load(f) released = [] active = {} for issue, info in claims.items(): agent = info.get('by', '') if isinstance(info, dict) else '' if 'loop' in agent or 'hermes' in agent.lower(): released.append(issue) else: active[issue] = info if released: with open('$CLAIMS', 'w') as f: json.dump(active, f, indent=2) print(f'[cleanup] Released claims: {released}') except Exception as e: print(f'[cleanup] Claim cleanup failed: {e}') " 2>&1 } # ── Fast triage (mechanical scoring) ────────────────────────────────── run_fast_triage() { if [ -f "$TRIAGE_SCRIPT" ]; then log "Running fast triage..." python3 "$TRIAGE_SCRIPT" 2>&1 else log "WARNING: triage script not found at $TRIAGE_SCRIPT" fi } # ── Deep triage (Hermes + Timmy) ───────────────────────────────────── run_deep_triage() { if [ -f "$DEEP_TRIAGE_SCRIPT" ]; then log "Running deep triage (Hermes + Timmy)..." timeout 600 bash "$DEEP_TRIAGE_SCRIPT" 2>&1 else log "WARNING: deep triage script not found at $DEEP_TRIAGE_SCRIPT" fi } # ── Cycle retrospective ───────────────────────────────────────────── log_retro() { # Usage: log_retro [extra args for cycle_retro.py] local outcome="$1"; shift if [ -f "$RETRO_SCRIPT" ]; then local duration=$(( $(date +%s) - CYCLE_START )) if [ "$outcome" = "success" ]; then python3 "$RETRO_SCRIPT" --cycle "$CYCLE" --success \ --duration "$duration" "$@" 2>&1 else python3 "$RETRO_SCRIPT" --cycle "$CYCLE" --failure \ --duration "$duration" "$@" 2>&1 fi fi } # ── Inject queue context into prompt ───────────────────────────────── get_queue_context() { if [ -f "$QUEUE_FILE" ]; then python3 -c " import json with open('$QUEUE_FILE') as f: q = json.load(f) if not q: print('Queue is empty. Check open issues on Gitea and pick the highest-priority one.') else: print(f'PRIORITIZED QUEUE ({len(q)} ready issues):') for i, item in enumerate(q[:8]): flag = 'BUG' if item.get('type') == 'bug' else item.get('type', '?').upper() print(f' {i+1}. #{item[\"issue\"]} [{flag}] score={item[\"score\"]} — {item.get(\"title\",\"?\")[:60]}') if item.get('files'): print(f' files: {\", \".join(item[\"files\"][:3])}') if len(q) > 8: print(f' ... +{len(q)-8} more') print() print(f'Pick from the TOP of this queue. Issue #{q[0][\"issue\"]} is highest priority.') " 2>/dev/null else echo "No queue file. Check open issues on Gitea and pick the highest-priority one." fi } # ── Main Loop ───────────────────────────────────────────────────────── log "Timmy development loop v3 starting. PID $$" log "Timeout: ${MAX_CYCLE_TIME}s | Cooldown: ${COOLDOWN}s | Claim TTL: ${CLAIM_TTL_SECONDS}s" log "Repo: $REPO" update_state "started_at" "\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"" update_state "status" '"running"' CYCLE=$(python3 -c "import json; print(json.load(open('$STATE'))['cycle'])") while true; do # Check for stop file if [ -f "$REPO/loop/STOP" ]; then log "STOP file found. Halting loop." update_state "status" '"stopped"' break fi CYCLE=$((CYCLE + 1)) CYCLE_LOG="$LOG_DIR/cycle-$(printf '%04d' $CYCLE).log" log "━━━ Cycle $CYCLE ━━━" update_state "cycle" "$CYCLE" update_state "status" '"working"' CYCLE_START=$(date +%s) # ── Pre-cycle housekeeping ──────────────────────────────────────── expire_claims # ── Triage (fast every 5, deep every 20) ───────────────────────── if (( CYCLE % DEEP_TRIAGE_INTERVAL == 0 )); then run_deep_triage elif (( CYCLE % FAST_TRIAGE_INTERVAL == 0 )); then run_fast_triage fi # ── Build the prompt with time budget + queue ──────────────────── QUEUE_CONTEXT=$(get_queue_context) PROMPT=$(cat "$PROMPT_FILE") PROMPT="TIME BUDGET: You have $((MAX_CYCLE_TIME / 60)) minutes for this cycle. Plan accordingly — do not start work you cannot finish. $QUEUE_CONTEXT $PROMPT" # ── Idle detection: skip cycle if queue is empty ────────────────── QUEUE_EMPTY=false if [ -f "$QUEUE_FILE" ]; then QUEUE_LEN=$(python3 -c "import json; q=json.load(open('$QUEUE_FILE')); print(len(q))" 2>/dev/null || echo "0") if [ "$QUEUE_LEN" = "0" ]; then QUEUE_EMPTY=true fi else QUEUE_EMPTY=true fi if [ "$QUEUE_EMPTY" = "true" ]; then IDLE_STREAK=$((IDLE_STREAK + 1)) # Exponential backoff: 60s, 120s, 240s, ... capped at 600s WAIT=$((IDLE_COOLDOWN * (2 ** (IDLE_STREAK - 1)))) if [ "$WAIT" -gt "$IDLE_MAX_COOLDOWN" ]; then WAIT=$IDLE_MAX_COOLDOWN fi log "Queue empty (idle streak: $IDLE_STREAK). Sleeping ${WAIT}s before next triage." update_state "status" '"idle"' # Do NOT log a retro entry — empty cycles are not cycles sleep "$WAIT" continue fi # Reset idle streak — we have work IDLE_STREAK=0 log "Spawning hermes for cycle $CYCLE..." # Run hermes with timeout — tee to log AND stdout for live visibility if timeout "$MAX_CYCLE_TIME" hermes chat --yolo -q "$PROMPT" 2>&1 | tee "$CYCLE_LOG"; then log "Cycle $CYCLE completed successfully" update_state "status" '"idle"' update_state "last_completed" "\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"" # ── Cycle retro (success) ──────────────────────────────────── log_retro success else EXIT_CODE=$? log "Cycle $CYCLE exited with code $EXIT_CODE" update_state "status" '"error"' python3 -c " import json with open('$STATE') as f: s = json.load(f) errs = s.get('errors', []) errs.append({'cycle': $CYCLE, 'code': $EXIT_CODE, 'time': '$(date -u +%Y-%m-%dT%H:%M:%SZ)'}) s['errors'] = errs[-10:] with open('$STATE', 'w') as f: json.dump(s, f, indent=2) " # ── Cycle retro (failure) ──────────────────────────────────── log_retro failure --reason "exit code $EXIT_CODE" # ── Cleanup on failure ─────────────────────────────────────── cleanup_cycle "$CYCLE" fi log "Cooling down ${COOLDOWN}s before next cycle..." sleep "$COOLDOWN" done