#!/usr/bin/env bash # ── Timmy Development Loop v2 ────────────────────────────────────────── # Runs forever. Each cycle: Timmy triages → Hermes picks work → execute # → Timmy reviews → merge. State in .loop/state.json. # ─────────────────────────────────────────────────────────────────────── 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" 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=30 MAX_CYCLE_TIME=1200 # 20 min — enough for complex issues CLAIM_TTL_SECONDS=3600 # 1 hour — stale claims auto-expire TIMMY="$REPO/.venv/bin/timmy" # 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 } # ── Ask Timmy for triage (non-blocking) ─────────────────────────────── ask_timmy() { local question="$1" local result # 45s timeout — if Timmy hangs, skip gracefully result=$(timeout 45 "$TIMMY" chat --session-id loop "$question" 2>/dev/null | grep -v "^WARNING" | grep -v "^$" | head -20) if [ -n "$result" ]; then echo "$result" else echo "(Timmy unavailable)" fi } # ── Main Loop ───────────────────────────────────────────────────────── log "Timmy development loop v2 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"' # ── Pre-cycle housekeeping ──────────────────────────────────────── expire_claims # ── Ask Timmy for input (if available) ──────────────────────────── log "Asking Timmy for triage input..." TIMMY_INPUT=$(ask_timmy "The development loop is starting cycle $CYCLE. Look at the open issues on Gitea and tell me: which issue should we work on next and why? Consider priority, dependencies, and what would help you most. Be brief — two sentences max.") log "Timmy says: $TIMMY_INPUT" # ── Build the prompt with time budget and Timmy's input ─────────── 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. TIMMY'S TRIAGE INPUT (from Timmy himself): $TIMMY_INPUT $PROMPT" 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)\"" # ── Post-cycle: Ask Timmy to review ─────────────────────────── log "Asking Timmy to review cycle output..." # Get the latest PR diff and feed it to Timmy directly LATEST_PR=$(curl -s "http://localhost:3000/api/v1/repos/rockachopa/Timmy-time-dashboard/pulls?state=closed&sort=created&limit=1" \ -H "Authorization: token $(cat ~/.hermes/gitea_token)" 2>/dev/null | python3 -c " import json,sys prs=json.load(sys.stdin) if prs: print(f'PR #{prs[0][\"number\"]}: {prs[0][\"title\"]}') else: print('none') " 2>/dev/null) DIFF_SUMMARY=$(cd "$REPO" && git log --oneline -1 2>/dev/null) REVIEW=$(ask_timmy "The dev loop just merged $LATEST_PR (commit: $DIFF_SUMMARY). Based on what you know about your own architecture, does this sound like a good change? Any concerns? Two sentences max.") log "Timmy's review: $REVIEW" 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) " # ── Cleanup on failure ──────────────────────────────────────── cleanup_cycle "$CYCLE" fi log "Cooling down ${COOLDOWN}s before next cycle..." sleep "$COOLDOWN" done