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