Compare commits
1 Commits
fix/562
...
burn/602-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57d6950d0 |
199
scripts/sprint/skill/SKILL.md
Normal file
199
scripts/sprint/skill/SKILL.md
Normal file
@@ -0,0 +1,199 @@
|
||||
---
|
||||
name: sprint-backlog-burner
|
||||
description: "Autonomous sprint system for burning Gitea backlog. Picks issues, implements, commits, pushes, PRs. High-frequency, isolated workspaces. Dual-path: system crontab + Hermes cron."
|
||||
version: 1.1.0
|
||||
author: Timmy Time
|
||||
tags: [sprint, gitea, backlog, autonomous, burn, crontab, ollama]
|
||||
related_skills: [gitea-workflow-automation, gitea-forge-migration, hermes-profile-fenrir]
|
||||
---
|
||||
|
||||
# Sprint Backlog Burner
|
||||
|
||||
## When To Use
|
||||
- User wants autonomous issue implementation against Gitea repos
|
||||
- User wants to burn through a backlog with high-frequency parallel workers
|
||||
- User wants a system that survives gateway outages (system crontab fallback)
|
||||
|
||||
## Architecture — Dual Path
|
||||
|
||||
```
|
||||
PATH 1: System Crontab (ALWAYS works, no gateway dependency)
|
||||
└─→ sprint-runner.py (direct OpenAI SDK → LLM API)
|
||||
└─→ picks issue → clones → branches → implements → PR
|
||||
|
||||
PATH 2: Hermes Cron (full agent loop with tools, when gateway healthy)
|
||||
└─→ cron job with sprint prompt
|
||||
└─→ full tool access, session memory, skill loading
|
||||
|
||||
PATH 1 is the safety net. PATH 2 is the preferred path when available.
|
||||
Both can run simultaneously — overlapping workspaces prevent conflicts.
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
~/.hermes/bin/sprint-runner.py # Standalone Python runner (direct LLM API)
|
||||
~/.hermes/bin/sprint-launcher.sh # Shell launcher (gateway API path)
|
||||
~/.hermes/bin/sprint-monitor.sh # Health monitor with Gitea issue counts
|
||||
~/.hermes/logs/sprint/ # Logs + results.csv
|
||||
~/.hermes/logs/sprint/results.csv # Track record: timestamp|repo|#issue|exit_code
|
||||
~/.hermes/profiles/timmy-sprint/ # Sprint profile (mimo-v2-pro, port 8655)
|
||||
```
|
||||
|
||||
## Provider Auto-Detection (Critical)
|
||||
|
||||
sprint-runner.py reads `~/.hermes/config.yaml` and `~/.hermes/auth.json` to auto-detect the active provider. This is essential because the model config changes frequently.
|
||||
|
||||
```python
|
||||
# Reads from config.yaml
|
||||
MODEL = cfg["model"]["default"] # e.g. "gpt-5.4"
|
||||
PROVIDER = cfg["model"]["provider"] # e.g. "openai-codex"
|
||||
BASE_URL = cfg["model"]["base_url"]
|
||||
|
||||
# Reads auth from auth.json by provider name
|
||||
auth = json.load(open("~/.hermes/auth.json"))
|
||||
provider_auth = auth["providers"][PROVIDER]
|
||||
```
|
||||
|
||||
### Provider Fallback Chain (learned 2026-04-12)
|
||||
|
||||
| Provider | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| `openai-codex` | Cloudflare-blocked | Cannot call directly via SDK. Gateway handles auth/challenges. |
|
||||
| `nous` | Works via SDK | Uses `agent_key` from auth.json. 24hr expiry. |
|
||||
| `ollama` | Works via SDK | Use `api_key="ollama"`. gemma4:latest is slow (~10s/turn) for tool calling. |
|
||||
| `openrouter` | Needs API key | `OPENROUTER_API_KEY` env var must be set. |
|
||||
|
||||
**Rule:** If `openai-codex` is the active provider, sprint-runner.py must fall back to Ollama or another direct-callable provider. The runner implements this:
|
||||
|
||||
```python
|
||||
if PROVIDER == "openai-codex":
|
||||
# Fall back to local Ollama
|
||||
PROVIDER = "ollama"
|
||||
BASE_URL = "http://localhost:11434/v1"
|
||||
MODEL = "gemma4:latest"
|
||||
API_KEY = "ollama"
|
||||
```
|
||||
|
||||
## Crontab Setup
|
||||
|
||||
```cron
|
||||
# Sprint: timmy-home (225 issues) — every 10 min, 10min timeout
|
||||
*/10 * * * * timeout 600 ~/.hermes/hermes-agent/venv/bin/python3 ~/.hermes/bin/sprint-runner.py Timmy_Foundation/timmy-home >> ~/.hermes/logs/sprint/cron.log 2>&1
|
||||
|
||||
# Sprint: the-beacon (15 issues) — every 15 min, 10min timeout
|
||||
*/15 * * * * timeout 600 ~/.hermes/hermes-agent/venv/bin/python3 ~/.hermes/bin/sprint-runner.py Timmy_Foundation/the-beacon >> ~/.hermes/logs/sprint/cron-beacon.log 2>&1
|
||||
|
||||
# Sprint: timmy-config (126 issues) — every 20 min, 10min timeout
|
||||
*/20 * * * * timeout 600 ~/.hermes/hermes-agent/venv/bin/python3 ~/.hermes/bin/sprint-runner.py Timmy_Foundation/timmy-config >> ~/.hermes/logs/sprint/cron-config.log 2>&1
|
||||
|
||||
# Monitor — every 30 min
|
||||
*/30 * * * * ~/.hermes/bin/sprint-monitor.sh >> ~/.hermes/logs/sprint/monitor.log 2>&1
|
||||
```
|
||||
|
||||
**Always use `timeout 600`** — local models (gemma4) need ~3min for 20 tool-calling turns. Without timeout, stuck processes pile up.
|
||||
|
||||
## Hermes Cron Job IDs
|
||||
|
||||
- `7501f1dba180` — timmy-home every 10m
|
||||
- `f965f91a1dfc` — the-beacon every 15m
|
||||
- `b65c57054257` — timmy-config every 20m
|
||||
|
||||
These use `provider: nous` — if the `nous` provider is removed from config.yaml, these jobs fall through to fallbacks and fail.
|
||||
|
||||
## Tool Calling with Local Models
|
||||
|
||||
The Python runner implements 4 tools locally:
|
||||
- `run_command` — shell execution (subprocess)
|
||||
- `read_file` — file reading
|
||||
- `write_file` — file writing
|
||||
- `gitea_api` — Gitea REST API calls (urllib)
|
||||
|
||||
Ollama's gemma4:latest supports tool calling via OpenAI SDK but is slow (~10s per turn, ~20 turns = ~3min). hermes4:14b is an alternative but also slow.
|
||||
|
||||
**Gateway vs Standalone:** The gateway enforces a 64K minimum context window. gemma4:latest (8K) fails this check. The standalone Python runner has no such restriction.
|
||||
|
||||
## Pre-Implementation Scoping (CRITICAL — saves wasted work)
|
||||
|
||||
Before writing ANY code, verify the bug isn't already fixed and there's no stale branch:
|
||||
|
||||
**1. Check git blame for the affected lines.** If someone already fixed the bug on main, don't reimplement it:
|
||||
```bash
|
||||
git blame -L <line>,<line> <file>
|
||||
```
|
||||
If the blame shows a recent commit that changed the problematic text/code, the fix is already on main.
|
||||
|
||||
**2. Check all branches for prior fix attempts:**
|
||||
```bash
|
||||
git log --all --oneline --grep="issue-101"
|
||||
git branch -a --contains <commit>
|
||||
```
|
||||
If a commit message references the issue but the branch isn't merged to main, read the diff to understand why it failed.
|
||||
|
||||
**3. Compare the issue's claims against the actual code.** QA reports are point-in-time snapshots. If BUG-06 says "toast says 'trust draining'" but the current code says "compute draining", the bug was already fixed between the QA report and now.
|
||||
|
||||
**4. Read failed branches to learn from their mistakes.** If `sprint/issue-101` exists but wasn't merged, `git diff main..origin/sprint/issue-101` shows what they tried. Common failure modes:
|
||||
- Wrong CSS classes (element IDs don't match what JS expects)
|
||||
- Destructive changes (deletes unrelated code)
|
||||
- Incomplete implementation (only fixes 1 of 3 bugs)
|
||||
|
||||
**5. Scope down to what's actually left.** Multi-bug issues (like "3 UI bugs") may have 2 already fixed. Only implement the remainder. Document which bugs were already fixed and which PR/commit did it.
|
||||
|
||||
**Decision tree:**
|
||||
```
|
||||
Is the bug fixed on main? (git blame + read file)
|
||||
├── YES → Skip, document which commit fixed it
|
||||
├── PARTIALLY → Fix only the remainder
|
||||
└── NO → Check for failed branches
|
||||
├── Found failed branch → Learn from it, start fresh
|
||||
└── No prior work → Implement from scratch
|
||||
```
|
||||
|
||||
## Sprint Flow
|
||||
|
||||
1. **FETCH**: `GET /api/v1/repos/{repo}/issues?state=open&limit=15&sort=oldest`
|
||||
2. **PICK**: First non-epic, non-study issue
|
||||
3. **SCOPE**: Run the Pre-Implementation Scoping checks above
|
||||
4. **CLONE**: `git clone https://timmy:$TOKEN@forge.alexanderwhitestone.com/{repo}.git`
|
||||
5. **BRANCH**: `git checkout -b feat/issue-{N}` (use `feat/` prefix, not `burn/`)
|
||||
6. **IMPLEMENT**: Real code changes via tool calls
|
||||
7. **VERIFY**: Tests/lint/build if they exist
|
||||
8. **COMMIT+PUSH**: `git commit -m "fix: {title} (closes #{N})"`
|
||||
9. **PR**: `POST /api/v1/repos/{repo}/pulls`
|
||||
10. **COMMENT**: `POST /api/v1/repos/{repo}/issues/{N}/comments`
|
||||
|
||||
## Monitor Output
|
||||
|
||||
```bash
|
||||
bash ~/.hermes/bin/sprint-monitor.sh
|
||||
```
|
||||
|
||||
Shows: active workspace count, per-repo pass/fail rates, open issue counts from Gitea, gateway status, all-time results from results.csv.
|
||||
|
||||
## Pitfalls (Hard-Won)
|
||||
|
||||
1. **openai-codex is Cloudflare-blocked** — cannot call `chatgpt.com/backend-api/codex` directly from scripts. Must use gateway or fall back to another provider.
|
||||
|
||||
2. **Gateway API accepts connections but never responds** when the agent can't initialize (bad provider config, missing model). Health check (`/health`) still returns OK. Check `gateway.error.log` for actual errors.
|
||||
|
||||
3. **Profile `--clone` copies stale auth.json** — Nous tokens expire in 24hrs. Cloned profiles get yesterday's tokens. Either re-auth (`hermes model`) or copy fresh auth.json.
|
||||
|
||||
4. **Gateway cron `tool_choice` bug** (scheduler.py:731) — `AIAgent.__init__() got an unexpected keyword argument 'tool_choice'`. Affects ALL cron jobs when present. System crontab (PATH 1) bypasses this entirely.
|
||||
|
||||
5. **`providers` section in config.yaml** — if a cron job specifies `provider: nous` but `nous` isn't in the `providers:` dict, the gateway falls through to fallbacks. All fallbacks must also be configured or they fail sequentially.
|
||||
|
||||
6. **Overlapping workspaces** — unique `/tmp/sprint-{timestamp}-{pid}` per run prevents conflicts. But stale workspaces cause git clone failures ("destination path already exists"). The auto-cleanup keeps last 24.
|
||||
|
||||
7. **Ollama 64K context minimum** — gateway rejects gemma4:latest (8K context). Standalone runner doesn't enforce this. Use `hermes4:14b` or `qwen2.5:7b` as alternatives (check context with `curl localhost:11434/api/show`).
|
||||
|
||||
8. **Gitea branch naming** — use `feat/` prefix for PRs, NOT `burn/` (burn/ branches fail PR on the-nexus per memory).
|
||||
|
||||
9. **Profile provider isolation is PARTIAL (2026-04-12)** — A profile's `config.yaml` can specify `model.provider: nous` and `model.base_url: https://inference-api.nousresearch.com/v1`, but the gateway resolves the actual provider client from the **GLOBAL** `~/.hermes/config.yaml` `providers:` section. If `nous` is missing from global `providers:`, ALL cron jobs (even with `profile: timmy-sprint`) fall through to fallbacks. The profile's `model.base_url` alone is NOT enough — the provider must exist in both the profile config AND the global providers dict. This is a gap in the profile isolation implementation (fleet-ops#95). Workaround: ensure every provider used by any profile is also defined in the global `providers:` section.
|
||||
|
||||
10. **`hermes chat -q` is too slow for agent loops** — creating a fresh agent session via CLI takes 30-60+ seconds before the first tool call. Not viable for cron-triggered work. Use `sprint-runner.py` (direct API) or the gateway API instead.
|
||||
|
||||
11. **Config drift detection** — if someone changes `model.default` or `model.provider` in the global config (e.g., switching from `nous` to `openai-codex`), ALL cron jobs that don't specify their own `provider` will silently switch models. Sprint jobs that hardcode `provider: nous` in the job dict will fall through to fallbacks if the provider isn't in the global `providers:` section. Monitor `gateway.error.log` for `\"model X is not supported when using Codex\"` as a symptom.
|
||||
|
||||
12. **Stale branches lie.** A branch named `sprint/issue-101` or `fix/issue-101` doesn't mean the fix is correct. Always `git diff main..origin/branchname` before trusting it. Common failures: wrong element IDs, deleted unrelated code, CSS classes that don't match the JS querySelector. Read the code, not the branch name.
|
||||
|
||||
13. **Clone with token in URL.** Standard pattern for authenticated clones: `git clone https://timmy:$TOKEN@forge.alexanderwhitestone.com/org/repo.git`. Token comes from `~/.config/gitea/` (multiple tokens exist — `token` is Rockachopa, `timmy-token` is Timmy, etc.).
|
||||
131
scripts/sprint/sprint-launcher.sh
Executable file
131
scripts/sprint/sprint-launcher.sh
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/bin/bash
|
||||
# ══════════════════════════════════════════════
|
||||
# Timmy-Sprint Launcher — Autonomous Backlog Burner
|
||||
# Launched by system crontab every 10 minutes.
|
||||
# Falls back to direct API if gateway is up,
|
||||
# or spawns hermes chat if not.
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Args: repo to target (default: timmy-home)
|
||||
TARGET_REPO="${1:-Timmy_Foundation/timmy-home}"
|
||||
|
||||
# Unique workspace per run
|
||||
WORKSPACE="/tmp/sprint-$(date +%s)-$$"
|
||||
mkdir -p "$WORKSPACE"
|
||||
|
||||
# Log file
|
||||
LOG_DIR="$HOME/.hermes/logs/sprint"
|
||||
mkdir -p "$LOG_DIR"
|
||||
LOG="$LOG_DIR/$(date +%Y%m%d-%H%M%S)-$(echo "$TARGET_REPO" | tr '/' '-').log"
|
||||
|
||||
# Load env vars
|
||||
export GITEA_TOKEN="${GITEA_TOKEN:-$(cat "$HOME/.hermes/gitea_token_vps" 2>/dev/null)}"
|
||||
export GITEA_URL="https://forge.alexanderwhitestone.com/api/v1"
|
||||
GITEA="https://forge.alexanderwhitestone.com"
|
||||
|
||||
echo "[SPRINT] $(date) — Starting sprint for $TARGET_REPO in $WORKSPACE" | tee "$LOG"
|
||||
|
||||
# Preflight: fetch open issues and log what we find
|
||||
ISSUES=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA/api/v1/repos/$TARGET_REPO/issues?state=open&limit=15&sort=oldest" 2>/dev/null || echo "[]")
|
||||
ISSUE_COUNT=$(echo "$ISSUES" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
|
||||
echo "[SPRINT] Found $ISSUE_COUNT open issues on $TARGET_REPO" | tee -a "$LOG"
|
||||
|
||||
if [ "$ISSUE_COUNT" = "0" ]; then
|
||||
echo "[SPRINT] No issues found or API error, aborting" | tee -a "$LOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Pick the first non-epic issue
|
||||
TARGET_ISSUE=$(echo "$ISSUES" | python3 -c "
|
||||
import sys, json
|
||||
issues = json.load(sys.stdin)
|
||||
for i in issues:
|
||||
labels = [l['name'].lower() for l in i.get('labels', [])]
|
||||
if 'epic' not in labels and 'study' not in labels:
|
||||
print(f\"#{i['number']}|{i['title']}\")
|
||||
break
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$TARGET_ISSUE" ]; then
|
||||
echo "[SPRINT] All issues are epics/studies, aborting" | tee -a "$LOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ISSUE_NUM=$(echo "$TARGET_ISSUE" | cut -d'|' -f1 | tr -d '#')
|
||||
ISSUE_TITLE=$(echo "$TARGET_ISSUE" | cut -d'|' -f2)
|
||||
echo "[SPRINT] Targeting: #$ISSUE_NUM — $ISSUE_TITLE" | tee -a "$LOG"
|
||||
|
||||
# Write the prompt to a file
|
||||
PROMPT_FILE="$WORKSPACE/prompt.md"
|
||||
cat > "$PROMPT_FILE" <<PROMPT
|
||||
You are Timmy-Sprint. Your ONLY job: implement Gitea issue $TARGET_REPO#$ISSUE_NUM.
|
||||
|
||||
ISSUE: #$ISSUE_NUM — $ISSUE_TITLE
|
||||
|
||||
STEPS:
|
||||
1. Read the issue: curl -s -H "Authorization: token \$GITEA_TOKEN" "$GITEA/api/v1/repos/$TARGET_REPO/issues/$ISSUE_NUM"
|
||||
2. Read the issue body fully. Understand what's needed.
|
||||
3. cd $WORKSPACE
|
||||
4. Clone: git clone https://oauth2:\$GITEA_TOKEN@forge.alexanderwhitestone.com/$TARGET_REPO.git
|
||||
5. cd into the repo
|
||||
6. Branch: git checkout -b sprint/issue-$ISSUE_NUM
|
||||
7. Implement the fix/feature. Real code, real files.
|
||||
8. Verify: run tests, lint, build if available. Check files exist and are correct.
|
||||
9. Commit: git add -A && git commit -m "fix: $ISSUE_TITLE (closes #$ISSUE_NUM)"
|
||||
10. Push: git push origin sprint/issue-$ISSUE_NUM
|
||||
11. Create PR: curl -s -X POST -H "Authorization: token \$GITEA_TOKEN" -H "Content-Type: application/json" -d '{"title":"fix: $ISSUE_TITLE","body":"Closes #$ISSUE_NUM\n\nAutomated sprint implementation.","base":"main","head":"sprint/issue-$ISSUE_NUM"}' "$GITEA/api/v1/repos/$TARGET_REPO/pulls"
|
||||
12. Comment on issue: curl -s -X POST -H "Authorization: token \$GITEA_TOKEN" -H "Content-Type: application/json" -d '{"body":"PR submitted via automated sprint session."}' "$GITEA/api/v1/repos/$TARGET_REPO/issues/$ISSUE_NUM/comments"
|
||||
|
||||
RULES: Terse. Verify before done. One issue only. Commit early.
|
||||
PROMPT
|
||||
|
||||
echo "[SPRINT] Prompt written to $PROMPT_FILE" | tee -a "$LOG"
|
||||
|
||||
# Try gateway API first (fastest path)
|
||||
if curl -sf http://localhost:8642/health > /dev/null 2>&1; then
|
||||
echo "[SPRINT] Gateway up, using API" | tee -a "$LOG"
|
||||
|
||||
PROMPT_ESCAPED=$(python3 -c "import json; print(json.dumps(open('$PROMPT_FILE').read()))")
|
||||
|
||||
RESPONSE=$(curl -sf -X POST http://localhost:8642/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"model\":\"hermes-agent\",\"messages\":[{\"role\":\"user\",\"content\":$PROMPT_ESCAPED}],\"max_tokens\":8000}" \
|
||||
--max-time 600 2>&1) || true
|
||||
|
||||
if [ -n "$RESPONSE" ]; then
|
||||
echo "$RESPONSE" >> "$LOG"
|
||||
CONTENT=$(echo "$RESPONSE" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get('choices',[{}])[0].get('message',{}).get('content','NO CONTENT')[:2000])
|
||||
except: print('PARSE ERROR')
|
||||
" 2>&1)
|
||||
echo "[SPRINT] Response: $CONTENT" | tee -a "$LOG"
|
||||
else
|
||||
echo "[SPRINT] Gateway returned empty, falling back to CLI" | tee -a "$LOG"
|
||||
cd "$WORKSPACE"
|
||||
hermes chat --yolo --quiet -q "$(cat "$PROMPT_FILE")" 2>&1 | tee -a "$LOG"
|
||||
fi
|
||||
else
|
||||
echo "[SPRINT] Gateway down, using CLI" | tee -a "$LOG"
|
||||
cd "$WORKSPACE"
|
||||
hermes chat --yolo --quiet -q "$(cat "$PROMPT_FILE")" 2>&1 | tee -a "$LOG"
|
||||
fi
|
||||
|
||||
EXIT_CODE=${PIPESTATUS[0]:-$?}
|
||||
echo "[SPRINT] $(date) — Exit code: $EXIT_CODE" | tee -a "$LOG"
|
||||
|
||||
# Record result to a summary file
|
||||
echo "$(date +%s)|$TARGET_REPO|#$ISSUE_NUM|$EXIT_CODE" >> "$LOG_DIR/results.csv"
|
||||
|
||||
# Cleanup old workspaces (keep last 24)
|
||||
ls -dt /tmp/sprint-* 2>/dev/null | tail -n +25 | xargs rm -rf 2>/dev/null || true
|
||||
|
||||
# Cleanup old logs (keep last 100)
|
||||
ls -t "$LOG_DIR"/*.log 2>/dev/null | tail -n +101 | xargs rm -f 2>/dev/null || true
|
||||
|
||||
exit $EXIT_CODE
|
||||
85
scripts/sprint/sprint-monitor.sh
Executable file
85
scripts/sprint/sprint-monitor.sh
Executable file
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
# ══════════════════════════════════════════════
|
||||
# Sprint Monitor — Watch all sprint runners
|
||||
# Checks logs, active workspaces, and results.
|
||||
# Run every 30 min via crontab or manually.
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
LOG_DIR="$HOME/.hermes/logs/sprint"
|
||||
GITEA="https://forge.alexanderwhitestone.com"
|
||||
GITEA_TOKEN=$(cat "$HOME/.hermes/gitea_token_vps" 2>/dev/null)
|
||||
|
||||
echo "========================================"
|
||||
echo " TIMMY SPRINT MONITOR"
|
||||
echo " $(date)"
|
||||
echo "========================================"
|
||||
|
||||
# Active workspaces
|
||||
ACTIVE=$(ls -d /tmp/sprint-* 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo ""
|
||||
echo "ACTIVE WORKSPACES: $ACTIVE"
|
||||
if [ "$ACTIVE" -gt 8 ]; then
|
||||
echo " WARNING: $ACTIVE workspaces (possible stuck sessions)"
|
||||
ls -dt /tmp/sprint-* 2>/dev/null | head -5
|
||||
elif [ "$ACTIVE" -gt 0 ]; then
|
||||
ls -dt /tmp/sprint-* 2>/dev/null | head -3
|
||||
fi
|
||||
|
||||
# Check each target repo
|
||||
for REPO in "timmy-home" "the-beacon" "timmy-config"; do
|
||||
echo ""
|
||||
echo "--- $REPO ---"
|
||||
|
||||
# Count recent sprint logs for this repo
|
||||
LOG_PATTERN="$LOG_DIR/*${REPO}*.log"
|
||||
RECENT=$(ls -t $LOG_PATTERN 2>/dev/null | head -6)
|
||||
PASS=0
|
||||
FAIL=0
|
||||
TOTAL=0
|
||||
|
||||
for log in $RECENT; do
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if grep -qi "exit code: [^0]" "$log" 2>/dev/null; then
|
||||
FAIL=$((FAIL + 1))
|
||||
elif grep -q "PR submitted\|pulls\|git push" "$log" 2>/dev/null; then
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo " Last $TOTAL runs: $PASS work submitted, $FAIL failed"
|
||||
|
||||
# Show latest activity
|
||||
LATEST=$(ls -t $LOG_PATTERN 2>/dev/null | head -1)
|
||||
if [ -n "$LATEST" ]; then
|
||||
LAST_TIME=$(stat -f "%Sm" -t "%H:%M" "$LATEST" 2>/dev/null || echo "unknown")
|
||||
LAST_TARGET=$(grep "Targeting:" "$LATEST" 2>/dev/null | tail -1)
|
||||
echo " Latest: $LAST_TIME — ${LAST_TARGET:-no target selected}"
|
||||
else
|
||||
echo " No runs yet"
|
||||
fi
|
||||
|
||||
# Count open issues on the repo
|
||||
OPEN=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA/api/v1/repos/Timmy_Foundation/$REPO" 2>/dev/null | \
|
||||
python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('open_issues_count','?'))" 2>/dev/null || echo "?")
|
||||
echo " Open issues on Gitea: $OPEN"
|
||||
done
|
||||
|
||||
# Check results CSV
|
||||
if [ -f "$LOG_DIR/results.csv" ]; then
|
||||
TOTAL_RUNS=$(wc -l < "$LOG_DIR/results.csv" | tr -d ' ')
|
||||
OK_RUNS=$(grep '|0$' "$LOG_DIR/results.csv" 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo ""
|
||||
echo "ALL-TIME: $TOTAL_RUNS total runs, $OK_RUNS completed OK"
|
||||
fi
|
||||
|
||||
# Check gateway
|
||||
echo ""
|
||||
if curl -sf http://localhost:8642/health > /dev/null 2>&1; then
|
||||
echo "GATEWAY: UP (port 8642)"
|
||||
else
|
||||
echo "GATEWAY: DOWN (port 8642) — sprints use CLI fallback"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
301
scripts/sprint/sprint-runner.py
Normal file
301
scripts/sprint/sprint-runner.py
Normal file
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Timmy-Sprint Runner — Standalone Backlog Burner
|
||||
Calls Nous API directly via OpenAI SDK. No gateway needed.
|
||||
Each run: picks one Gitea issue, implements it, commits, pushes, PRs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# ── Config ──────────────────────────────────────────────
|
||||
GITEA = "https://forge.alexanderwhitestone.com"
|
||||
GITEA_TOKEN = open(os.path.expanduser("~/.hermes/gitea_token_vps")).read().strip()
|
||||
|
||||
# Read model config from hermes config
|
||||
import yaml
|
||||
HERMES_CONFIG = os.path.expanduser("~/.hermes/config.yaml")
|
||||
with open(HERMES_CONFIG) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
|
||||
MODEL = cfg.get("model", {}).get("default", "gpt-5.4")
|
||||
PROVIDER = cfg.get("model", {}).get("provider", "openai-codex")
|
||||
BASE_URL = cfg.get("model", {}).get("base_url", "https://chatgpt.com/backend-api/codex")
|
||||
|
||||
# Load auth for the active provider
|
||||
AUTH_FILE = os.path.expanduser("~/.hermes/auth.json")
|
||||
auth = json.load(open(AUTH_FILE))
|
||||
provider_auth = auth.get("providers", {}).get(PROVIDER, {})
|
||||
|
||||
# Extract access token based on provider type
|
||||
if PROVIDER == "openai-codex":
|
||||
tokens = provider_auth.get("tokens", {})
|
||||
API_KEY = tokens.get("access_token", "")
|
||||
# openai-codex goes through Cloudflare — not usable standalone
|
||||
# Fall back to local Ollama
|
||||
print(f"[WARN] openai-codex provider is Cloudflare-protected. Falling back to local Ollama.")
|
||||
PROVIDER = "ollama"
|
||||
BASE_URL = "http://localhost:11434/v1"
|
||||
MODEL = "gemma4:latest"
|
||||
API_KEY = "ollama"
|
||||
elif PROVIDER == "nous":
|
||||
API_KEY = provider_auth.get("agent_key", "")
|
||||
BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||
else:
|
||||
API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
||||
|
||||
print(f"[CONFIG] Model: {MODEL}, Provider: {PROVIDER}, URL: {BASE_URL}")
|
||||
|
||||
# ── Tools (local implementations) ──────────────────────
|
||||
def run_command(cmd, cwd=None, timeout=120):
|
||||
"""Run a shell command and return output."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, cwd=cwd, capture_output=True,
|
||||
text=True, timeout=timeout
|
||||
)
|
||||
return {"stdout": result.stdout[-3000:], "stderr": result.stderr[-1000:], "exit_code": result.returncode}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"stdout": "", "stderr": "Command timed out", "exit_code": -1}
|
||||
except Exception as e:
|
||||
return {"stdout": "", "stderr": str(e), "exit_code": -1}
|
||||
|
||||
def read_file(path):
|
||||
"""Read a file."""
|
||||
try:
|
||||
content = Path(path).read_text()
|
||||
return content[:5000]
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def write_file(path, content):
|
||||
"""Write a file."""
|
||||
try:
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(path).write_text(content)
|
||||
return f"Written {len(content)} bytes to {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
def gitea_api(method, endpoint, data=None):
|
||||
"""Call Gitea API."""
|
||||
url = f"{GITEA}/api/v1/{endpoint}"
|
||||
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
||||
|
||||
if data:
|
||||
body = json.dumps(data).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
else:
|
||||
req = urllib.request.Request(url, headers=headers, method=method)
|
||||
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
return json.loads(resp.read()) if resp.status != 204 else {"status": "ok"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# ── Tool definitions for the LLM ───────────────────────
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_command",
|
||||
"description": "Run a shell command in the workspace. Use for git, curl, ls, tests, etc.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "Shell command to run"}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file's contents",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path to read"}
|
||||
},
|
||||
"required": ["path"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_file",
|
||||
"description": "Write content to a file (creates dirs)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path"},
|
||||
"content": {"type": "string", "description": "File content"}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "gitea_api",
|
||||
"description": "Call the Gitea API (GET/POST/PATCH). Endpoint is relative to /api/v1/",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": {"type": "string", "enum": ["GET", "POST", "PATCH", "DELETE"]},
|
||||
"endpoint": {"type": "string", "description": "API endpoint, e.g. repos/Owner/repo/issues"},
|
||||
"data": {"type": "object", "description": "JSON body for POST/PATCH"}
|
||||
},
|
||||
"required": ["method", "endpoint"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
DISPATCH = {
|
||||
"run_command": lambda args: run_command(args["command"]),
|
||||
"read_file": lambda args: read_file(args["path"]),
|
||||
"write_file": lambda args: write_file(args["path"], args["content"]),
|
||||
"gitea_api": lambda args: gitea_api(args["method"], args["endpoint"], args.get("data")),
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────
|
||||
def main():
|
||||
repo = sys.argv[1] if len(sys.argv) > 1 else "Timmy_Foundation/timmy-home"
|
||||
workspace = tempfile.mkdtemp(prefix=f"sprint-{int(time.time())}-")
|
||||
log_dir = os.path.expanduser("~/.hermes/logs/sprint")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_file = os.path.join(log_dir, f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{repo.replace('/','-')}.log")
|
||||
|
||||
def log(msg):
|
||||
line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}"
|
||||
print(line)
|
||||
with open(log_file, "a") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
log(f"Sprint starting for {repo} in {workspace}")
|
||||
|
||||
# Fetch issues
|
||||
issues = gitea_api("GET", f"repos/{repo}/issues?state=open&limit=15&sort=oldest")
|
||||
if not isinstance(issues, list) or not issues:
|
||||
log(f"No issues found: {issues}")
|
||||
return 0
|
||||
|
||||
# Pick first non-epic
|
||||
target = None
|
||||
for issue in issues:
|
||||
labels = [l["name"].lower() for l in issue.get("labels", [])]
|
||||
if "epic" not in labels and "study" not in labels:
|
||||
target = issue
|
||||
break
|
||||
|
||||
if not target:
|
||||
log("All issues are epics/studies")
|
||||
return 0
|
||||
|
||||
issue_num = target["number"]
|
||||
issue_title = target["title"]
|
||||
log(f"Targeting: #{issue_num} — {issue_title}")
|
||||
|
||||
# Fetch full issue body
|
||||
issue_detail = gitea_api("GET", f"repos/{repo}/issues/{issue_num}")
|
||||
issue_body = issue_detail.get("body", "(no description)")[:2000]
|
||||
|
||||
# Build prompt
|
||||
system_prompt = f"""You are Timmy-Sprint. Implement ONE Gitea issue. Terse. Verify before done.
|
||||
|
||||
Your workspace: {workspace}
|
||||
Target: {repo} #{issue_num}
|
||||
Title: {issue_title}
|
||||
|
||||
Issue body:
|
||||
{issue_body}
|
||||
|
||||
Steps:
|
||||
1. Read the issue body above carefully
|
||||
2. cd {workspace}
|
||||
3. Clone the repo: git clone https://oauth2:{GITEA_TOKEN}@forge.alexanderwhitestone.com/{repo}.git
|
||||
4. cd into repo, branch: git checkout -b sprint/issue-{issue_num}
|
||||
5. Make the changes (use run_command, read_file, write_file)
|
||||
6. Verify (tests/lint/build)
|
||||
7. git add -A && git commit -m "fix: {issue_title} (closes #{issue_num})"
|
||||
8. git push origin sprint/issue-{issue_num}
|
||||
9. Create PR via gitea_api (POST repos/{repo}/pulls)
|
||||
10. Comment on issue via gitea_api (POST repos/{repo}/issues/{issue_num}/comments)
|
||||
|
||||
Work fast. One issue. Commit early."""
|
||||
|
||||
# Call LLM API (auto-detect provider)
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
|
||||
|
||||
messages = [{"role": "user", "content": f"Implement issue #{issue_num}: {issue_title}\n\n{issue_body}"}]
|
||||
|
||||
for turn in range(20): # Max 20 tool-calling turns
|
||||
log(f"Turn {turn+1}...")
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=MODEL,
|
||||
messages=[{"role": "system", "content": system_prompt}] + messages,
|
||||
tools=TOOLS,
|
||||
max_tokens=2000,
|
||||
timeout=180, # 3min per turn for slow local models
|
||||
)
|
||||
|
||||
msg = response.choices[0].message
|
||||
messages.append(msg.model_dump())
|
||||
|
||||
# Check if done (no tool calls)
|
||||
if not msg.tool_calls:
|
||||
log(f"Agent finished: {msg.content[:200] if msg.content else '(no content)'}")
|
||||
break
|
||||
|
||||
# Execute tool calls
|
||||
for tc in msg.tool_calls:
|
||||
func_name = tc.function.name
|
||||
func_args = json.loads(tc.function.arguments)
|
||||
log(f" Tool: {func_name}({json.dumps(func_args)[:100]})")
|
||||
|
||||
if func_name in DISPATCH:
|
||||
result = DISPATCH[func_name](func_args)
|
||||
else:
|
||||
result = {"error": f"Unknown tool: {func_name}"}
|
||||
|
||||
result_str = json.dumps(result) if isinstance(result, dict) else str(result)
|
||||
log(f" Result: {result_str[:150]}")
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": result_str[:3000]
|
||||
})
|
||||
|
||||
# Record result
|
||||
with open(os.path.join(log_dir, "results.csv"), "a") as f:
|
||||
f.write(f"{int(time.time())}|{repo}|#{issue_num}|0\n")
|
||||
|
||||
log(f"Sprint complete for #{issue_num}")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
log(f"Error: {e}")
|
||||
with open(os.path.join(log_dir, "results.csv"), "a") as f:
|
||||
f.write(f"{int(time.time())}|{repo}|#{issue_num}|1\n")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user