#!/bin/sh # Runs the tasks of a plan one after another, each in its own fresh OpenCode session, and stops # at the first task that does not end cleanly. This is the owner's tool; the implementing model # never runs it. # # Usage: tools/run-plan.sh docs/plans/M2a [FIRST] FIRST is a task number such as 04 # Options (environment): # OPENCODE_MODEL model to use (default straylight/laguna-s-2.1; OpenCode's own default differs). # The part after the provider (`ornith-1.5-35b-a3b`) names the model in each # commit's `Co-Authored-By: ` trailer and in the log. # OPENCODE_FLAGS extra flags for `opencode run` # PLUGINS=1 load OpenCode's external plugins and let it read ~/.claude/CLAUDE.md. By # default each session runs with `--pure` and OPENCODE_DISABLE_CLAUDE_CODE=1: # a global plugin that rewrites the prompt forces a full re-read on a model # that cannot rewind its cache (implementer lessons, T48). # SESSION_LIMIT_MIN minutes a session may run before it is stopped (default 90) # SLOTS_URL llama-server's slots URL, for example # http://127.0.0.1:8081/slots?model=ornith-1.5-35b-a3b. When set, a session is # stopped once no slot has been busy for IDLE_LIMIT_MIN minutes (default 10). # Trial: not yet used on a run. # RESUME=1 when a session ends, or is stopped at SESSION_LIMIT_MIN, with work left # uncommitted, resume it once, in a new session with the same limit. # DRY_RUN=1 print what would be run and stop # Each session runs as the `implement` agent of ./opencode.json: its permissions deny edits to the # plans and design documents, reads outside the repository, subagents and pushes. set -eu plan="${1:?usage: tools/run-plan.sh docs/plans/ [FIRST]}" first="${2:-00}" model="${OPENCODE_MODEL:-straylight/laguna-s-2.1}" agent="${model##*/}" trailer="Co-Authored-By: $agent " limit="${SESSION_LIMIT_MIN:-90}" idle="${IDLE_LIMIT_MIN:-10}" log=docs/implementer-log.md milestone=$(basename "$plan") runs=".state/runs/$milestone" [ -d "$plan" ] || { echo "run-plan: $plan is not a directory" >&2; exit 2; } [ -f "$log" ] || { echo "run-plan: run this from the repository root" >&2; exit 2; } mkdir -p "$runs" # One OpenCode session with prompt $1, appended to the task's run log. Returns opencode's status, # 124 when the session outlived $limit minutes, or 125 when SLOTS_URL showed no busy slot for $idle # minutes. A stopped session is killed by process group (it may have started cargo), never with # `pkill`, which would match this script too. run_session() { set -m if [ "${PLUGINS:-0}" = 1 ]; then pure="" else pure="--pure" export OPENCODE_DISABLE_CLAUDE_CODE=1 fi # shellcheck disable=SC2086 opencode run $pure --agent implement -m "$model" ${OPENCODE_FLAGS:-} --title "$milestone/$name" \ "$1" >> "$runs/$name.log" 2>&1 & pid=$! set +m started=$(date +%s) busy=$started why=0 while kill -0 "$pid" 2> /dev/null; do sleep 10 now=$(date +%s) if [ $((now - started)) -ge $((limit * 60)) ]; then why=124 break fi if [ -n "${SLOTS_URL:-}" ]; then if curl -s -m 10 "$SLOTS_URL" | grep -Eq '"is_processing": *true'; then busy=$now fi if [ $((now - busy)) -ge $((idle * 60)) ]; then why=125 break fi fi done if [ "$why" != 0 ]; then kill -TERM "-$pid" 2> /dev/null || kill -TERM "$pid" 2> /dev/null || true sleep 30 kill -KILL "-$pid" 2> /dev/null || true wait "$pid" 2> /dev/null || true return "$why" fi status=0 wait "$pid" || status=$? return "$status" } for task in "$plan"/[0-9][0-9]-*.md; do name=$(basename "$task" .md) number=$(echo "$name" | cut -c1-2) [ "$number" -ge "$first" ] || continue row="Your log row's Task cell is \`$milestone/$name\`, and you are $agent: write $agent in its Model column, and end the commit message with this line, exactly: $trailer" prompt="Read \`$task\` and do exactly that task. Do its steps in order. Run step 1 (usually a cp) before you Read any crate source, tests/support, or docs/implementer-log.md. $row" if [ "${DRY_RUN:-0}" = 1 ]; then echo "opencode run --agent implement -m $model ${OPENCODE_FLAGS:-} --title $milestone/$name \"$prompt\"" continue fi if [ -n "$(git status --porcelain)" ]; then echo "run-plan: the working tree is not clean before $name; stopping" >&2 exit 1 fi before=$(git rev-parse HEAD) : > "$runs/$name.log" echo "=== $milestone/$name: started $(date '+%H:%M:%S')" # A new `opencode run` is a new session: nothing carries over from the task before. rc=0 run_session "$prompt" || rc=$? # A session that ended, or ran out of time, part-way through a task is resumed once. One stopped # for idling (125) is not: the server, not the task, is the likely cause. if { [ "$rc" = 0 ] || [ "$rc" = 124 ]; } && [ "${RESUME:-0}" = 1 ] \ && [ "$(git rev-parse HEAD)" = "$before" ] && [ -n "$(git status --porcelain)" ]; then if [ "$rc" = 124 ]; then echo "=== $milestone/$name: stopped at $limit minutes with work uncommitted" fi echo "=== $milestone/$name: work left uncommitted; resuming once $(date '+%H:%M:%S')" rc=0 run_session "The previous session on \`$task\` left uncommitted work (see \`git diff\`). Do not start over. Finish the task, run the gate, commit, and write the log row. $row" || rc=$? fi case "$rc" in 0) ;; 124) echo "run-plan: $name ran past $limit minutes and was stopped; see $runs/$name.log" >&2; exit 1 ;; 125) echo "run-plan: no llama slot was busy for $idle minutes in $name, so it was stopped; see $runs/$name.log" >&2; exit 1 ;; *) echo "run-plan: opencode exited with status $rc in $name; see $runs/$name.log" >&2; exit 1 ;; esac echo "=== $milestone/$name: session ended $(date '+%H:%M:%S')" # The task must have left exactly what AGENTS.md asks for: a commit carrying the trailer, a # clean tree, and a `done` row in the log. Anything else needs a person, so stop. if [ "$(git rev-parse HEAD)" = "$before" ]; then echo "run-plan: $name made no commit; stopping" >&2 exit 1 fi if [ -n "$(git status --porcelain)" ]; then echo "run-plan: $name left uncommitted changes; stopping" >&2 exit 1 fi if ! git log -1 --format=%B | grep -qxF "$trailer"; then echo "run-plan: $name's commit lacks the line '$trailer'; stopping" >&2 exit 1 fi if git show HEAD -- "$log" | grep -Eq "^\+\| *$milestone/$name *\|[^|]*\| *stopped *\|"; then echo "run-plan: $name stopped and said why in its log row; stopping" >&2 git show HEAD -- "$log" | grep -E "^\+\| *$milestone/$name *\|" | cut -c2- >&2 exit 1 fi if ! grep -Eq "^\| *$milestone/$name *\|[^|]*\| *done *\|" "$log"; then echo "run-plan: no 'done' row for $milestone/$name in $log; stopping" >&2 grep -E "^\| *$milestone/$name *\|" "$log" >&2 || true exit 1 fi echo "=== $milestone/$name: done ($(git log -1 --format=%h))" done echo "run-plan: every task from $first on ended cleanly. Ready for review."