2026-03-14 18:00:32 -04:00
|
|
|
#!/usr/bin/env bash
|
2026-03-15 11:38:17 -04:00
|
|
|
# ── 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
|
2026-03-14 18:00:32 -04:00
|
|
|
# ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
set -uo pipefail
|
|
|
|
|
|
|
|
|
|
export PATH="$HOME/.local/bin:$HOME/.hermes/bin:/usr/local/bin:$PATH"
|
|
|
|
|
|
2026-03-27 22:00:16 -04:00
|
|
|
REPO="$HOME/.hermes"
|
|
|
|
|
STATE="$REPO/loop/state.json"
|
|
|
|
|
LOG_DIR="$REPO/loop/logs"
|
|
|
|
|
CLAIMS="$REPO/loop/claims.json"
|
2026-03-14 18:00:32 -04:00
|
|
|
PROMPT_FILE="$HOME/.hermes/bin/timmy-loop-prompt.md"
|
|
|
|
|
LOCKFILE="/tmp/timmy-loop.lock"
|
2026-03-15 10:11:46 -04:00
|
|
|
COOLDOWN=3
|
2026-03-21 12:00:18 -04:00
|
|
|
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
|
2026-03-14 18:00:32 -04:00
|
|
|
MAX_CYCLE_TIME=1200 # 20 min — enough for complex issues
|
|
|
|
|
CLAIM_TTL_SECONDS=3600 # 1 hour — stale claims auto-expire
|
2026-03-15 11:38:17 -04:00
|
|
|
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"
|
2026-03-27 22:00:16 -04:00
|
|
|
QUEUE_FILE="$REPO/loop/queue.json"
|
2026-03-14 18:00:32 -04:00
|
|
|
# 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
|
|
|
|
|
}
|
2026-03-15 11:38:17 -04:00
|
|
|
# ── 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
|
|
|
|
|
}
|
2026-03-14 18:00:32 -04:00
|
|
|
|
2026-03-15 11:38:17 -04:00
|
|
|
# ── 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
|
|
|
|
|
}
|
2026-03-15 10:11:46 -04:00
|
|
|
|
2026-03-15 11:38:17 -04:00
|
|
|
# ── Cycle retrospective ─────────────────────────────────────────────
|
|
|
|
|
log_retro() {
|
|
|
|
|
# Usage: log_retro <success|failure> [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
|
|
|
|
|
}
|
2026-03-14 18:00:32 -04:00
|
|
|
|
|
|
|
|
# ── Main Loop ─────────────────────────────────────────────────────────
|
2026-03-15 11:38:17 -04:00
|
|
|
log "Timmy development loop v3 starting. PID $$"
|
2026-03-14 18:00:32 -04:00
|
|
|
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
|
2026-03-27 22:00:16 -04:00
|
|
|
if [ -f "$REPO/loop/STOP" ]; then
|
2026-03-14 18:00:32 -04:00
|
|
|
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"'
|
|
|
|
|
|
2026-03-15 11:38:17 -04:00
|
|
|
CYCLE_START=$(date +%s)
|
|
|
|
|
|
2026-03-14 18:00:32 -04:00
|
|
|
# ── Pre-cycle housekeeping ────────────────────────────────────────
|
|
|
|
|
expire_claims
|
2026-03-15 11:38:17 -04:00
|
|
|
|
|
|
|
|
# ── 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)
|
2026-03-14 18:00:32 -04:00
|
|
|
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.
|
|
|
|
|
|
2026-03-15 11:38:17 -04:00
|
|
|
$QUEUE_CONTEXT
|
|
|
|
|
|
2026-03-14 18:00:32 -04:00
|
|
|
$PROMPT"
|
|
|
|
|
|
2026-03-21 12:00:18 -04:00
|
|
|
# ── 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
|
|
|
|
|
|
2026-03-14 18:00:32 -04:00
|
|
|
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)\""
|
|
|
|
|
|
2026-03-15 11:38:17 -04:00
|
|
|
# ── Cycle retro (success) ────────────────────────────────────
|
|
|
|
|
log_retro success
|
2026-03-15 10:11:46 -04:00
|
|
|
|
2026-03-14 18:00:32 -04:00
|
|
|
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)
|
|
|
|
|
"
|
2026-03-15 11:38:17 -04:00
|
|
|
# ── Cycle retro (failure) ────────────────────────────────────
|
|
|
|
|
log_retro failure --reason "exit code $EXIT_CODE"
|
|
|
|
|
|
|
|
|
|
# ── Cleanup on failure ───────────────────────────────────────
|
2026-03-14 18:00:32 -04:00
|
|
|
cleanup_cycle "$CYCLE"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
log "Cooling down ${COOLDOWN}s before next cycle..."
|
|
|
|
|
sleep "$COOLDOWN"
|
|
|
|
|
done
|