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:
parent
4394683542
commit
01522a0573
3 changed files with 297 additions and 0 deletions
60
agent-loop/README.md
Normal file
60
agent-loop/README.md
Normal 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
188
agent-loop/nugitea-daily.sh
Executable 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"
|
||||||
49
agent-loop/opencode-agent.json
Normal file
49
agent-loop/opencode-agent.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue