#!/usr/bin/env bash # Route agent + prepare branch. # Reads the event context from env (set by the calling step), writes the agent registry to # /tmp/agents.json, picks which agent to run, emits step outputs (name/model/vision/mode/branch/new) # to $GITHUB_OUTPUT, configures git identity, and prepares/publishes the working branch. # # Required env (all provided by the workflow step): BODY IBODY CID IS_PR NUM GT # TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA # GITHUB_SERVER_URL GITHUB_REPOSITORY GITHUB_OUTPUT set -eu # --- agent registry: model + capabilities + mode + role + skills --- # The registry is the SINGLE SOURCE OF TRUTH, kept in agents.json next to this # script. install-opencode.sh derives its ollama-cloud provider `models:` map # from the same file, so an agent's model can never be missing from the provider # config — drift is impossible by construction. See issue #31. # `skills` is the allow-list of opencode Skills each agent may load. It scopes the # `permission.skill` block written into opencode.json (see install-opencode.sh) so an agent only # ever sees (and can load) the skills relevant to its role. Skills NOT listed here are hidden from # that agent entirely — not even the one-line summary appears in its , so the # full API/how-to detail never reaches an agent that shouldn't act on it. A teammate can still learn # *that* another agent has a capability from the roster and ask them to use it. AGENTS_JSON="${SCRIPTS:-$(dirname -- "$0")}/agents.json" cp "$AGENTS_JSON" /tmp/agents.json # On a new issue, @pm auto-assesses. On a comment, route by the @mention. # A comment event has a comment id (CID); an issue-opened event does not. (event_name is unreliable # here — see agent.yml: this reusable workflow sees it as 'workflow_call'.) if [ -n "$CID" ]; then scan="$BODY"; else scan="$IBODY"; fi name="" # FIRST MATCH IN THIS LIST ORDER WINS when a comment mentions several agents. The order is # load-bearing for the flow's trigger comments: "@pm — @qa approved …" must route to @pm (pm is # checked first), while "@junior please address @qa's review …" must route to the dev (devs are # checked before qa). If you add an agent or reword a trigger in publish.sh, re-check this order. # WORD-BOUNDARY match, not substring: "@internal" or "x@internet.com" must NOT route to @intern # (the workflow gate can only do contains(), so this is where its false positives get filtered). for a in pm junior senior lead qa ops intern; do if printf '%s' "$scan" | grep -qE "(^|[^[:alnum:]_])@$a([^[:alnum:]_-]|\$)"; then name=$a; break; fi done if [ -z "$name" ]; then if [ -z "$CID" ]; then name=pm else # Not an agent task (e.g. the gate's contains() matched "@internal"). Skip GRACEFULLY: emit # mode=skip so every later step no-ops — a red run for a non-agent comment is just noise. echo "no known agent mentioned (word-boundary) — skipping run" { echo "name=none"; echo "model=none"; echo "fallback="; echo "vision=false"; echo "mode=skip"; echo "skills=[]"; echo "branch=main"; echo "new=false"; echo "autopilot=false"; echo "issnum=$NUM"; } >> "$GITHUB_OUTPUT" exit 0 fi fi model=$(jq -r --arg a "$name" '.[$a].model' /tmp/agents.json) # Optional provider failover when primary is quota/network-dead (e.g. Ollama Cloud → xAI). fallback=$(jq -r --arg a "$name" '.[$a].fallback // empty' /tmp/agents.json) vision=$(jq -r --arg a "$name" '.[$a].vision' /tmp/agents.json) mode=$(jq -r --arg a "$name" '.[$a].mode' /tmp/agents.json) # Compact JSON array of the skills this agent may load (scopes permission.skill in install-opencode.sh). skills=$(jq -c --arg a "$name" '.[$a].skills // []' /tmp/agents.json) # --- WORKMODE for dev (mode=pr) agents: build vs DISCUSS. --- # Mentioning a dev is a CONVERSATION by default — it replies in the thread without creating a # branch or PR. Actual building starts ONLY on the explicit signals: # - a comment on a PR thread (resuming existing work), or # - the pm delegation template ".. please proceed with issue .." (also usable by a human), or # - the qa bounce template ".. please address my review ..". # This lets @pm (via its ASK marker) and the maintainer consult devs to gather information first, # and explicitly start the build later — see publish.sh / run-agent.sh. workmode=build if [ "$mode" = "pr" ] && [ -z "$IS_PR" ]; then if printf '%s' "$scan" | grep -qiE 'please (proceed with issue|address my review)'; then workmode=build else workmode=discuss fi fi echo "Routing to @$name (model=$model fallback=${fallback:-none} vision=$vision mode=$mode workmode=$workmode skills=$skills)" { echo "name=$name"; echo "model=$model"; echo "fallback=$fallback"; echo "vision=$vision"; echo "mode=$mode"; echo "workmode=$workmode"; echo "skills=$skills"; } >> "$GITHUB_OUTPUT" # Act as the agent's own Gitea user when its token is set; else the built-in bot. case "$name" in pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";; lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; intern) TOK="$TOKEN_INTERN";; *) TOK="";; esac [ -z "$TOK" ] && TOK="$GT" git config user.name "$name" git config user.email "$name@ffaerber.duckdns.org" API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json") branch_ref="" if [ "$workmode" = "discuss" ]; then # conversation only — no branch, no PR machinery echo "discussion mode — staying on main, no branch prep" { echo "branch=main"; echo "new=false"; } >> "$GITHUB_OUTPUT" elif [ -n "$IS_PR" ]; then # comment on a PR -> resume its branch ref=$(curl -s -H "Authorization: token $GT" "$API/pulls/$NUM" | jq -r .head.ref) branch_ref="$ref" git fetch origin "$ref" && git checkout "$ref" { echo "branch=$ref"; echo "new=false"; } >> "$GITHUB_OUTPUT" elif git ls-remote --exit-code --heads origin "ai/issue-$NUM" >/dev/null 2>&1; then # comment on an issue whose branch ALREADY exists (a prior run / open PR) -> RESUME it, so new # commits fast-forward onto the same branch and update its PR. Branching fresh from main here would # be rejected on push as non-fast-forward and the new work would be silently lost (see issue #17). git fetch origin "ai/issue-$NUM" && git checkout "ai/issue-$NUM" { echo "branch=ai/issue-$NUM"; echo "new=false"; } >> "$GITHUB_OUTPUT" else # comment on an issue, no branch yet -> new branch git checkout -b "ai/issue-$NUM" { echo "branch=ai/issue-$NUM"; echo "new=true"; } >> "$GITHUB_OUTPUT" # For dev agents, publish the branch immediately and tell the maintainer where to watch. if [ "$mode" = "pr" ]; then git push -u origin "HEAD:ai/issue-$NUM" || true url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/src/branch/ai/issue-$NUM" curl -sS -X POST "${hdr[@]}" "$API/issues/$NUM/comments" \ -d "$(jq -nc --arg b "🔨 Building on branch [\`ai/issue-$NUM\`]($url) — I'll open a PR when it's ready. " '{body:$b}')" >/dev/null || true fi fi # --- Autopilot gate: read the `autopilot` label FRESH every run. --- # Presence of this label is the opt-in switch (and the kill switch: remove it mid-flight and the # next run reverts to normal human-approval behavior). When @qa is triggered on a PR thread, the # label lives on the ORIGIN issue (ai/issue-N), so resolve N from the branch name. issnum="$NUM" case "$IS_PR" in ?*) issnum=$(printf '%s' "$branch_ref" | sed -nE 's,^ai/issue-([0-9]+).*,\1,p');; esac [ -z "$issnum" ] && issnum="$NUM" autopilot=false if curl -sS -H "Authorization: token $GT" "$API/issues/$issnum/labels" 2>/dev/null \ | jq -e 'any(.[]?; .name=="autopilot")' >/dev/null 2>&1; then autopilot=true fi echo "autopilot (autopilot label on #$issnum)=$autopilot" { echo "autopilot=$autopilot"; echo "issnum=$issnum"; } >> "$GITHUB_OUTPUT"