2026-03-14 18:00:32 -04:00
#!/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..."
2026-03-14 18:17:44 -04:00
# 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. " )
2026-03-14 18:00:32 -04:00
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