Add the nugitea daily agent loop

nugitea-daily.sh runs opencode with qwen3-coder over elixir_nugitea's
BACKLOG.md, one fresh session per task, and keeps a change only if it
stays in elixir_nugitea/, is under 800 lines and passes format, compile
and tests; then it commits and pushes agent/daily with its own deploy
key. opencode-agent.json denies git history, network, package and
service commands. Run by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-10-11 09:23:30 -08:00
parent 4394683542
commit 01522a0573
3 changed files with 297 additions and 0 deletions

60
agent-loop/README.md Normal file
View file

@ -0,0 +1,60 @@
# nugitea daily agent
Run `./nugitea-daily.sh` when you like (say, once a day): opencode with the
local Qwen3 Coder works through `elixir_nugitea/BACKLOG.md` in the nugitea
repo unattended, and pushes what passes to the `agent/daily` branch on
Forgejo for you to review and merge. It prints as it goes, so you can glance
at it, but it never waits for you.
## What a run does (`nugitea-daily.sh`)
1. Checks Ollama is up, resets its own worktree (`~/Code/nugitea-agent`, branch
`agent/daily`), merges in the latest `elixir-prototype`, and requires the
checks to pass before it starts.
2. Takes the first `- [ ]` task in BACKLOG.md and gives it to a fresh opencode
session (`opencode run --standalone --auto`, 45 minutes max), with
`elixir_nugitea/AGENTS.md` as its instructions.
3. Keeps the result only if it:
- changed only files under `elixir_nugitea/`, and none of AGENTS.md,
BACKLOG.md, priv/schema.graphql, mix.exs, mix.lock;
- is under 800 changed lines;
- passes `mix format --check-formatted`, `mix compile --warnings-as-errors`
and `mix test`, with no fewer passing tests than before.
4. If not, a second session gets the failure output and tries to fix it. If
that fails too, the changes are thrown away and the task is marked `[!]`.
5. Commits each finished task (checking it off as `[x]`) as "nugitea agent",
and pushes `agent/daily` with its own deploy key.
6. Up to 3 tasks, or 3 hours, per run.
Output also goes to `~/Library/Logs/nugitea-agent/<date>.log`, including
each session's own summary. `MAX_TASKS=1 ./nugitea-daily.sh` does just one
task.
## Guardrails
- `opencode-agent.json` (passed as `OPENCODE_CONFIG`) denies: files outside the
worktree, web access, questions, sub-agents, and shell commands that touch
git history, the network, packages or services (`git commit/push/reset/...`,
`curl`, `ssh`, `brew`, `npm`, `launchctl`, ...). Everything else in the shell
is allowed: it runs as you, so this is a speed bump, not a sandbox.
- It pushes with a deploy key (`~/.config/nugitea-agent/deploy_key`) that can
only write to quine-global-labs/nugitea, and only ever to `agent/daily`
(though the key itself could push any branch there).
- Nothing it does reaches `elixir-prototype` or `main` until you merge it.
## Setup (once)
```sh
git -C ~/Code/nugitea worktree add -b agent/daily ~/Code/nugitea-agent origin/elixir-prototype
mkdir -p ~/.config/nugitea-agent
ssh-keygen -t ed25519 -N "" -f ~/.config/nugitea-agent/deploy_key
# add deploy_key.pub as a write deploy key on quine-global-labs/nugitea
ssh-keyscan -p 2200 forge.quinefoundation.com | grep ed25519 > ~/.config/nugitea-agent/known_hosts
```
## Steering it
Edit `elixir_nugitea/BACKLOG.md` on `elixir-prototype`: add tasks at the end,
reorder, or reset a `[!]` to `[ ]` after making it smaller. Each task should be
finishable in one sitting and say what test proves it. Review `agent/daily`,
merge what's good into `elixir-prototype`; the next run picks up from there.

188
agent-loop/nugitea-daily.sh Executable file
View file

@ -0,0 +1,188 @@
#!/bin/bash
# The nugitea daily agent, run by hand (`./nugitea-daily.sh`): opencode +
# Qwen work through elixir_nugitea/BACKLOG.md unattended, one task per fresh
# session. Each
# task's result is kept only if it passes format, compile and tests, stays
# inside elixir_nugitea/ and is a reasonable size; then it's committed and
# pushed to the agent/daily branch on Forgejo for review. See README.md.
set -uo pipefail
export PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
here=$(cd "$(dirname "$0")" && pwd)
repo=${NUGITEA_REPO:-$HOME/Code/nugitea}
work=${NUGITEA_AGENT_WORKTREE:-$HOME/Code/nugitea-agent}
base=${NUGITEA_AGENT_BASE:-elixir-prototype}
branch=agent/daily
remote_url=ssh://git@forge.quinefoundation.com:2200/quine-global-labs/nugitea.git
model=ollama/qwen3-coder:30b-128k
max_tasks=${MAX_TASKS:-3} # tasks per run
attempts=2 # sessions per task (the second one sees the failures)
session_timeout=$((45 * 60)) # seconds per opencode session
budget=$((3 * 3600)) # stop starting tasks after this many seconds
max_diff_lines=800 # added + removed lines per task
protected='^elixir_nugitea/(AGENTS\.md|BACKLOG\.md|priv/schema\.graphql|mix\.exs|mix\.lock)$'
keys=$HOME/.config/nugitea-agent
logdir=$HOME/Library/Logs/nugitea-agent
backlog=elixir_nugitea/BACKLOG.md
mkdir -p "$logdir"
log=$logdir/$(date +%F).log
# On screen, and in the day's log.
exec > >(tee -a "$log") 2>&1
say() { echo "[$(date +%T)] $*"; }
start=$(date +%s)
# One run at a time.
lock=$logdir/.lock
mkdir "$lock" 2>/dev/null || { say "another run is going (remove $lock if not)"; exit 0; }
trap 'rmdir "$lock"' EXIT
git_push() {
GIT_SSH_COMMAND="ssh -i $keys/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes -o UserKnownHostsFile=$keys/known_hosts" \
git -C "$work" push -q "$remote_url" "HEAD:refs/heads/$branch"
}
# A commit subject from a backlog task: no markdown, at most 72 characters,
# cut at a word.
subject() {
local s
s=$(echo "$1" | tr -d '`' | sed 's/:.*//')
[ ${#s} -le 72 ] && { echo "$s"; return; }
echo "${s:0:69}" | sed 's/ [^ ]*$//; s/$/.../'
}
agent_commit() {
git -C "$work" -c user.name="nugitea agent" -c user.email="agent@forge.quinefoundation.com" commit -q "$@"
}
# Runs the checks, keeping their output in $checks_out and the passing test
# count in $tests.
checks_out=$logdir/.checks.out
checks() {
(cd "$work/elixir_nugitea" && mix format --check-formatted &&
mix compile --warnings-as-errors && mix test) >"$checks_out" 2>&1
local status=$?
tests=$(grep -oE '[0-9]+ (passed|tests?,)' "$checks_out" | tail -1 | grep -oE '^[0-9]+')
return $status
}
say "=== start"
curl -fs localhost:11434/api/version >/dev/null || { say "Ollama isn't answering; stopping"; exit 1; }
# The worktree is the agent's alone: reset it, then catch up with $base so
# the agent builds on whatever has been merged since.
git -C "$repo" fetch -q origin || { say "fetch failed; stopping"; exit 1; }
[ -d "$work" ] || git -C "$repo" worktree add -q -b "$branch" "$work" "origin/$base"
git -C "$work" reset -q --hard
git -C "$work" clean -qfd
if ! git -C "$work" merge -q --no-edit "origin/$base" >/dev/null; then
git -C "$work" merge --abort
say "agent/daily conflicts with $base; merge it by hand. Stopping."
exit 1
fi
(cd "$work/elixir_nugitea" && mix deps.get >/dev/null 2>&1)
if ! checks; then
tail -30 "$checks_out"
say "the checks fail before the agent has done anything; fix $base first. Stopping."
exit 1
fi
baseline_tests=${tests:-0}
say "baseline: $baseline_tests tests pass"
done_count=0
while [ "$done_count" -lt "$max_tasks" ] && [ $(($(date +%s) - start)) -lt "$budget" ]; do
line=$(grep -m1 -E '^- \[ \] ' "$work/$backlog") || { say "backlog is empty"; break; }
task=${line#- \[ \] }
say "--- task: $task"
ok=false
failure=""
for attempt in $(seq 1 "$attempts"); do
prompt="You are working unattended; nobody will answer questions. Read elixir_nugitea/AGENTS.md first and follow it.
Your task:
$task
When mix format, mix compile --warnings-as-errors and mix test all pass in elixir_nugitea/, stop and summarize what you changed."
if [ -n "$failure" ]; then
prompt="$prompt
A previous attempt left its changes in place, but the checks failed. Fix them. The last part of the output:
$failure"
fi
say "session $attempt"
summary=$(cd "$work" && OPENCODE_CONFIG="$here/opencode-agent.json" \
perl -e 'alarm shift; exec @ARGV' "$session_timeout" \
opencode run --standalone --auto -m "$model" "$prompt" </dev/null 2>&1)
echo "$summary" | tail -40
# Only elixir_nugitea/, never the protected files, not too big.
git -C "$work" add -A
changed=$(git -C "$work" diff --cached --name-only)
if [ -z "$changed" ]; then
failure="You made no changes."
say "no changes"
continue
fi
if outside=$(echo "$changed" | grep -v '^elixir_nugitea/'); then
failure="You changed files outside elixir_nugitea/: $outside"
say "$failure"
git -C "$work" reset -q --hard
continue
fi
if bad=$(echo "$changed" | grep -E "$protected"); then
failure="You changed files you must not change: $bad"
say "$failure"
git -C "$work" reset -q --hard
continue
fi
size=$(git -C "$work" diff --cached --numstat | awk '{n += $1 + $2} END {print n + 0}')
if [ "$size" -gt "$max_diff_lines" ]; then
failure="Your change is $size lines; keep it under $max_diff_lines."
say "$failure"
git -C "$work" reset -q --hard
continue
fi
if checks; then
if [ "${tests:-0}" -ge "$baseline_tests" ]; then
ok=true
break
fi
failure="Fewer tests pass than before (${tests:-0} vs $baseline_tests): don't remove tests."
else
failure=$(tail -40 "$checks_out")
fi
say "checks failed"
echo "$failure" | tail -15
done
if $ok; then
# Check the task off and commit both together.
LINE="$line" perl -i -pe 'if (!$d && $_ eq "$ENV{LINE}\n") { s/^- \[ \]/- [x]/; $d = 1 }' "$work/$backlog"
git -C "$work" add -A
agent_commit -F - <<EOF
$(subject "$task")
Backlog task: $task
Done unattended by opencode with $model; passed mix format,
compile --warnings-as-errors and mix test ($tests tests, was $baseline_tests).
The session's own summary is in $log.
EOF
baseline_tests=$tests
say "done: $(git -C "$work" log --oneline -1)"
else
git -C "$work" reset -q --hard
git -C "$work" clean -qfd
LINE="$line" perl -i -pe 'if (!$d && $_ eq "$ENV{LINE}\n") { s/^- \[ \]/- [!]/; $d = 1 }' "$work/$backlog"
git -C "$work" add "$backlog"
agent_commit -m "Give up on a backlog task for now" -m "$task" -m "$(echo "$failure" | tail -15 | cut -c1-200)"
say "gave up on it"
fi
done_count=$((done_count + 1))
git_push || say "push failed"
done
say "=== end: $(git -C "$work" log --oneline "origin/$base..HEAD" | wc -l | tr -d ' ') commits on $branch ahead of $base"

View file

@ -0,0 +1,49 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"read": "allow",
"edit": "allow",
"glob": "allow",
"grep": "allow",
"list": "allow",
"lsp": "allow",
"todowrite": "allow",
"external_directory": "deny",
"webfetch": "deny",
"websearch": "deny",
"question": "deny",
"task": "deny",
"doom_loop": "deny",
"bash": {
"*": "allow",
"git commit*": "deny",
"git push*": "deny",
"git reset*": "deny",
"git checkout*": "deny",
"git switch*": "deny",
"git rebase*": "deny",
"git merge*": "deny",
"git branch*": "deny",
"git tag*": "deny",
"git remote*": "deny",
"git stash*": "deny",
"git clean*": "deny",
"git config*": "deny",
"git worktree*": "deny",
"curl*": "deny",
"wget*": "deny",
"ssh*": "deny",
"scp*": "deny",
"sudo*": "deny",
"brew*": "deny",
"npm*": "deny",
"npx*": "deny",
"pip*": "deny",
"launchctl*": "deny",
"ollama*": "deny",
"opencode*": "deny",
"cat ~/.*": "deny",
"cat /Users/*": "deny"
}
}
}