diff --git a/.gitignore b/.gitignore index 7be33a3..44e4d31 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ bin/* !bin/start-dashboard.sh !bin/gemini-loop.sh !bin/loop-watchdog.sh +!bin/timmy-orchestrator.sh # ── Queue (transient task queue) ───────────────────────────────────── queue/ diff --git a/bin/ops-helpers.sh b/bin/ops-helpers.sh index c608861..b0ec54f 100755 --- a/bin/ops-helpers.sh +++ b/bin/ops-helpers.sh @@ -35,6 +35,10 @@ ops-help() { echo " ops-kill-gemini Stop Gemini loop" echo " ops-kill-zombies Kill stuck git/pytest" echo "" + echo -e " \033[1mOrchestrator\033[0m" + echo " ops-wake-timmy Start Timmy (Ollama)" + echo " ops-kill-timmy Stop Timmy" + echo "" echo -e " \033[1mWatchdog\033[0m" echo " ops-wake-watchdog Start loop watchdog" echo " ops-kill-watchdog Stop loop watchdog" @@ -201,6 +205,20 @@ ops-kill-zombies() { echo " Killed $killed zombie processes" } +ops-wake-timmy() { + pkill -f "timmy-orchestrator.sh" 2>/dev/null + rm -f ~/.hermes/logs/timmy-orchestrator.pid + sleep 1 + nohup bash ~/.hermes/bin/timmy-orchestrator.sh >> ~/.hermes/logs/timmy-orchestrator.log 2>&1 & + echo " Timmy orchestrator started (PID $!)" +} + +ops-kill-timmy() { + pkill -f "timmy-orchestrator.sh" 2>/dev/null + rm -f ~/.hermes/logs/timmy-orchestrator.pid + echo " Timmy stopped" +} + ops-wake-watchdog() { pkill -f "loop-watchdog.sh" 2>/dev/null sleep 1 diff --git a/bin/ops-panel.sh b/bin/ops-panel.sh index 4ce8505..c137273 100755 --- a/bin/ops-panel.sh +++ b/bin/ops-panel.sh @@ -62,6 +62,15 @@ else echo -e " ${FAIL} Gemini Loop ${RD}DOWN — run: ops-wake-gemini${R}" fi +# Timmy Orchestrator +TIMMY_PID=$(pgrep -f "timmy-orchestrator.sh" 2>/dev/null | head -1) +if [ -n "$TIMMY_PID" ]; then + TIMMY_LAST=$(tail -1 "$HOME/.hermes/logs/timmy-orchestrator.log" 2>/dev/null | sed 's/.*TIMMY: //') + echo -e " ${OK} Timmy (Ollama) ${D}pid $TIMMY_PID ${G}${TIMMY_LAST:0:30}${R}" +else + echo -e " ${FAIL} Timmy ${RD}DOWN — run: ops-wake-timmy${R}" +fi + # Gitea VPS if curl -s --max-time 3 "http://143.198.27.163:3000/api/v1/version" >/dev/null 2>&1; then echo -e " ${OK} Gitea VPS ${D}143.198.27.163:3000${R}" diff --git a/bin/timmy-orchestrator.sh b/bin/timmy-orchestrator.sh new file mode 100755 index 0000000..3b68083 --- /dev/null +++ b/bin/timmy-orchestrator.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# timmy-orchestrator.sh — Timmy's orchestration loop +# Uses hermes (local Ollama) to triage, assign, review, and merge. +# Timmy is the brain. Claude/Gemini/Kimi are the hands. + +set -uo pipefail + +LOG_DIR="$HOME/.hermes/logs" +LOG="$LOG_DIR/timmy-orchestrator.log" +PIDFILE="$LOG_DIR/timmy-orchestrator.pid" +GITEA_URL="http://143.198.27.163:3000" +GITEA_TOKEN=$(cat "$HOME/.config/gitea/token" 2>/dev/null) +CYCLE_INTERVAL=300 +HERMES_TIMEOUT=180 + +mkdir -p "$LOG_DIR" + +# Single instance guard +if [ -f "$PIDFILE" ]; then + old_pid=$(cat "$PIDFILE") + if kill -0 "$old_pid" 2>/dev/null; then + echo "Timmy already running (PID $old_pid)" >&2 + exit 0 + fi +fi +echo $$ > "$PIDFILE" +trap 'rm -f "$PIDFILE"' EXIT + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] TIMMY: $*" >> "$LOG" +} + +REPOS="rockachopa/Timmy-time-dashboard rockachopa/alexanderwhitestone.com rockachopa/hermes-agent replit/timmy-tower replit/token-gated-economy" + +gather_state() { + local state_dir="/tmp/timmy-state-$$" + mkdir -p "$state_dir" + + > "$state_dir/unassigned.txt" + > "$state_dir/open_prs.txt" + > "$state_dir/agent_status.txt" + + for repo in $REPOS; do + local short=$(echo "$repo" | cut -d/ -f2) + + # Unassigned issues + curl -sf -H "Authorization: token $GITEA_TOKEN" \ + "$GITEA_URL/api/v1/repos/$repo/issues?state=open&type=issues&limit=50" 2>/dev/null | \ + python3 -c " +import sys,json +for i in json.load(sys.stdin): + if not i.get('assignees'): + print(f'REPO={\"$repo\"} NUM={i[\"number\"]} TITLE={i[\"title\"]}')" >> "$state_dir/unassigned.txt" 2>/dev/null + + # Open PRs + curl -sf -H "Authorization: token $GITEA_TOKEN" \ + "$GITEA_URL/api/v1/repos/$repo/pulls?state=open&limit=30" 2>/dev/null | \ + python3 -c " +import sys,json +for p in json.load(sys.stdin): + print(f'REPO={\"$repo\"} PR={p[\"number\"]} BY={p[\"user\"][\"login\"]} TITLE={p[\"title\"]}')" >> "$state_dir/open_prs.txt" 2>/dev/null + done + + echo "Claude workers: $(pgrep -f 'claude.*--print.*--dangerously' 2>/dev/null | wc -l | tr -d ' ')" >> "$state_dir/agent_status.txt" + echo "Claude loop: $(pgrep -f 'claude-loop.sh' 2>/dev/null | wc -l | tr -d ' ') procs" >> "$state_dir/agent_status.txt" + tail -50 "$LOG_DIR/claude-loop.log" 2>/dev/null | grep -c "SUCCESS" | xargs -I{} echo "Recent successes: {}" >> "$state_dir/agent_status.txt" + tail -50 "$LOG_DIR/claude-loop.log" 2>/dev/null | grep -c "FAILED" | xargs -I{} echo "Recent failures: {}" >> "$state_dir/agent_status.txt" + + echo "$state_dir" +} + +run_triage() { + local state_dir="$1" + local unassigned_count=$(wc -l < "$state_dir/unassigned.txt" | tr -d ' ') + local pr_count=$(wc -l < "$state_dir/open_prs.txt" | tr -d ' ') + + log "Cycle: $unassigned_count unassigned, $pr_count open PRs" + + # If nothing to do, skip the LLM call + if [ "$unassigned_count" -eq 0 ] && [ "$pr_count" -eq 0 ]; then + log "Nothing to triage" + return + fi + + # Phase 1: Bulk-assign unassigned issues to claude (no LLM needed) + if [ "$unassigned_count" -gt 0 ]; then + log "Assigning $unassigned_count issues to claude..." + while IFS= read -r line; do + local repo=$(echo "$line" | sed 's/.*REPO=\([^ ]*\).*/\1/') + local num=$(echo "$line" | sed 's/.*NUM=\([^ ]*\).*/\1/') + curl -sf -X PATCH "$GITEA_URL/api/v1/repos/$repo/issues/$num" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"assignees":["claude"]}' >/dev/null 2>&1 && \ + log " Assigned #$num ($repo) to claude" + done < "$state_dir/unassigned.txt" + fi + + # Phase 2: PR review via Timmy (LLM) + if [ "$pr_count" -gt 0 ]; then + run_pr_review "$state_dir" + fi +} + +run_pr_review() { + local state_dir="$1" + local prompt_file="/tmp/timmy-prompt-$$.txt" + + # Build a review prompt listing all open PRs + cat > "$prompt_file" <<'HEADER' +You are Timmy, the orchestrator. Review these open PRs from AI agents. + +For each PR, you will see the diff. Your job: +- MERGE if changes look reasonable (most agent PRs are good, merge aggressively) +- COMMENT if there is a clear problem +- CLOSE if it is a duplicate or garbage + +Use these exact curl patterns (replace REPO, NUM): + Merge: curl -sf -X POST "GITEA/api/v1/repos/REPO/pulls/NUM/merge" -H "Authorization: token TOKEN" -H "Content-Type: application/json" -d '{"Do":"squash"}' + Comment: curl -sf -X POST "GITEA/api/v1/repos/REPO/pulls/NUM/comments" -H "Authorization: token TOKEN" -H "Content-Type: application/json" -d '{"body":"feedback"}' + Close: curl -sf -X PATCH "GITEA/api/v1/repos/REPO/pulls/NUM" -H "Authorization: token TOKEN" -H "Content-Type: application/json" -d '{"state":"closed"}' + +HEADER + + # Replace placeholders + sed -i '' "s|GITEA|$GITEA_URL|g; s|TOKEN|$GITEA_TOKEN|g" "$prompt_file" + + # Add each PR with its diff (up to 10 PRs per cycle) + local count=0 + while IFS= read -r line && [ "$count" -lt 10 ]; do + local repo=$(echo "$line" | sed 's/.*REPO=\([^ ]*\).*/\1/') + local pr_num=$(echo "$line" | sed 's/.*PR=\([^ ]*\).*/\1/') + local by=$(echo "$line" | sed 's/.*BY=\([^ ]*\).*/\1/') + local title=$(echo "$line" | sed 's/.*TITLE=//') + + [ -z "$pr_num" ] && continue + + local diff + diff=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \ + -H "Accept: application/diff" \ + "$GITEA_URL/api/v1/repos/$repo/pulls/$pr_num" 2>/dev/null | head -150) + + [ -z "$diff" ] && continue + + echo "" >> "$prompt_file" + echo "=== PR #$pr_num in $repo by $by ===" >> "$prompt_file" + echo "Title: $title" >> "$prompt_file" + echo "Diff (first 150 lines):" >> "$prompt_file" + echo "$diff" >> "$prompt_file" + echo "=== END PR #$pr_num ===" >> "$prompt_file" + + count=$((count + 1)) + done < "$state_dir/open_prs.txt" + + if [ "$count" -eq 0 ]; then + rm -f "$prompt_file" + return + fi + + echo "" >> "$prompt_file" + echo "Review each PR above. Execute curl commands for your decisions. Be brief." >> "$prompt_file" + + local prompt_text + prompt_text=$(cat "$prompt_file") + rm -f "$prompt_file" + + log "Reviewing $count PRs..." + local result + result=$(timeout "$HERMES_TIMEOUT" hermes chat -q "$prompt_text" -Q --yolo 2>&1) + local exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + log "PR review complete" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $result" >> "$LOG_DIR/timmy-reviews.log" + else + log "PR review failed (exit $exit_code)" + fi +} + +# === MAIN LOOP === +log "=== Timmy Orchestrator Started (PID $$) ===" +log "Model: qwen3:30b via Ollama | Cycle: ${CYCLE_INTERVAL}s" + +while true; do + state_dir=$(gather_state) + run_triage "$state_dir" + rm -rf "$state_dir" + log "Sleeping ${CYCLE_INTERVAL}s" + sleep "$CYCLE_INTERVAL" +done