Files
hermes-config/bin/timmy-loop.sh
Alexander Whitestone 7102ee695a fix: feed Timmy the PR info directly instead of asking him to query Gitea
Timmy doesn't have Gitea access in CLI chat mode. Instead of asking
him to check for PRs (which he can't), we fetch the latest merged PR
and commit summary ourselves and ask for his opinion on the change.
2026-03-14 18:17:44 -04:00

225 lines
8.4 KiB
Bash
Executable File

#!/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