agent-board.sh12.6 KBView on GitHub
#!/usr/bin/env bash
# agent-board.sh — fully-headless task board for coding agents, on GitHub Issues + sub-issues.
# Design: docs/agent-task-board.md
#
# One tool surface (gh) for the whole board. Sub-issue hierarchy is driven via `gh api graphql`
# because gh has no native sub-issue subcommand yet. Every verb is non-interactive and prints
# machine-parseable output so a Claude agent (interactive or a cloud routine) can drive it headlessly.
#
# Usage:
#   agent-board.sh init-labels                         # one-time: create the label schema
#   agent-board.sh epic   "<title>" [--body <md>]      # create a project (epic issue)
#   agent-board.sh task   <epic#> "<title>" [--priority p0|p1|p2] [--body <md>] [--agent claude|human]
#   agent-board.sh next                                # highest-priority status:ready agent:claude task (number)
#   agent-board.sh pickup <task#>                       # status:ready -> status:in-progress
#   agent-board.sh monitor   <pr#>                      # label PR agent:monitor; linked task -> status:in-review
#   agent-board.sh unmonitor <pr#>                      # stop monitoring a PR
#   agent-board.sh monitored                            # list open PRs currently labeled agent:monitor (JSON)
#   agent-board.sh attempt <pr#>                        # increment autofix:N on a PR; echoes new N (routine uses this)
#   agent-board.sh escalate <pr#> "<reason>"           # autofix exhausted -> needs-human + comment
#   agent-board.sh done   <task#>                       # status:done + close
#   agent-board.sh status [<epic#>]                     # epic roll-up, or whole-board summary
#
#   # Loop B (post-merge change monitoring):
#   agent-board.sh pending-merges                       # merged PRs opted-in but not yet monitored (JSON)
#   agent-board.sh changes                              # open monitor-change issues to re-check (JSON)
#   agent-board.sh init-done <pr#>                       # called after /monitor-change init authors the issue
#   agent-board.sh verified <issue#>                     # all-clear: close a clean change-monitor issue
#
#   # Loop C (perpetual error sweeper):
#   agent-board.sh error-task "<title>" --fingerprint <hash> [--body <md>]   # deduped bug task (DUP:n if exists)
#
# Env: REPO (default CedarCopilot/cedar-mail), MAX_AUTOFIX (default 3).
set -euo pipefail

REPO="${REPO:-CedarCopilot/cedar-mail}"
MAX_AUTOFIX="${MAX_AUTOFIX:-3}"

die() { echo "agent-board: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; }
need gh; need jq

# --- helpers ---------------------------------------------------------------

# node id (GraphQL global id) for an issue/PR number
node_id() { gh issue view "$1" --repo "$REPO" --json id -q .id 2>/dev/null \
            || gh pr view "$1" --repo "$REPO" --json id -q .id; }

# number from a `gh issue create`/`gh pr create` URL
num_from_url() { basename "$1"; }

# current autofix:N label value on a PR (0 if none)
autofix_level() {
  gh pr view "$1" --repo "$REPO" --json labels \
    -q '[.labels[].name | select(startswith("autofix:")) | ltrimstr("autofix:") | tonumber] | (first // 0)'
}

# --- verbs -----------------------------------------------------------------

cmd_init_labels() {
  # idempotent: `gh label create --force` upserts
  local L=(
    "type:epic|6f42c1|a project / initiative grouping tasks"
    "type:task|1d76db|a spawnable unit of work (sub-issue)"
    "status:ready|0e8a16|ready for an agent to pick up"
    "status:in-progress|fbca04|an agent is actively working it"
    "status:in-review|5319e7|PR open, under review / monitoring"
    "status:blocked|b60205|waiting on a dependency"
    "status:done|cccccc|completed"
    "agent:claude|1f6feb|owned by a coding agent"
    "agent:human|d4c5f9|owned by a human"
    "agent:monitor|c5def5|PR watched by the monitoring routine"
    "priority:p0|b60205|drop everything"
    "priority:p1|d93f0b|important"
    "priority:p2|fbca04|normal"
    "autofix:1|ededed|autonomous fix attempt 1"
    "autofix:2|ededed|autonomous fix attempt 2"
    "autofix:3|ededed|autonomous fix attempt 3 (budget)"
    "needs-human|e11d21|escalated: a human must look"
    "monitor-after-merge|0052cc|PR opted in to post-merge change monitoring"
    "type:monitor-change|0e8a16|post-merge change-verification issue (Loop B)"
    "type:error-watch|d73a4a|bug filed by the perpetual error sweeper (Loop C)"
    "verified|0e8a16|change monitored clean through its window"
  )
  for entry in "${L[@]}"; do
    IFS='|' read -r name color desc <<<"$entry"
    gh label create "$name" --repo "$REPO" --color "$color" --description "$desc" --force >/dev/null
    echo "label: $name"
  done
}

cmd_epic() {
  local title="${1:?title required}"; shift || true
  local body="Epic. Sub-issues are the tasks; progress rolls up here."
  [[ "${1:-}" == "--body" ]] && { body="$2"; shift 2; }
  local url; url=$(gh issue create --repo "$REPO" --title "$title" --body "$body" --label "type:epic")
  num_from_url "$url"
}

cmd_task() {
  local epic="${1:?epic# required}"; shift
  local title="${1:?title required}"; shift
  local priority="priority:p2" agent="agent:claude" body="Task under epic #$epic."
  while [[ $# -gt 0 ]]; do case "$1" in
    --priority) priority="priority:$2"; shift 2;;
    --agent)    agent="agent:$2"; shift 2;;
    --body)     body="$2"; shift 2;;
    *) die "unknown flag: $1";;
  esac; done
  local url; url=$(gh issue create --repo "$REPO" --title "$title" --body "$body" \
                    --label "type:task" --label "status:ready" --label "$agent" --label "$priority")
  local task; task=$(num_from_url "$url")
  # link as a native sub-issue of the epic
  local pid cid; pid=$(node_id "$epic"); cid=$(node_id "$task")
  gh api graphql -f query='mutation($p:ID!,$c:ID!){ addSubIssue(input:{issueId:$p,subIssueId:$c}){ issue{ number } } }' \
    -F p="$pid" -F c="$cid" >/dev/null
  echo "$task"
}

cmd_next() {
  # highest-priority ready task for an agent; emits the issue number or nothing
  for p in p0 p1 p2; do
    n=$(gh issue list --repo "$REPO" --state open \
          --label "status:ready" --label "agent:claude" --label "priority:$p" \
          --json number -q 'sort_by(.number) | (.[0].number // empty)')
    [[ -n "$n" ]] && { echo "$n"; return; }
  done
}

cmd_pickup() {
  local t="${1:?task# required}"
  gh issue edit "$t" --repo "$REPO" --add-label status:in-progress --remove-label status:ready >/dev/null
  echo "task #$t -> status:in-progress"
}

cmd_monitor() {
  local pr="${1:?pr# required}"
  gh pr edit "$pr" --repo "$REPO" --add-label agent:monitor >/dev/null
  # move linked task(s) to in-review
  for t in $(gh pr view "$pr" --repo "$REPO" --json closingIssuesReferences -q '.closingIssuesReferences[].number'); do
    gh issue edit "$t" --repo "$REPO" --add-label status:in-review --remove-label status:in-progress >/dev/null 2>&1 || true
    echo "task #$t -> status:in-review"
  done
  echo "PR #$pr -> agent:monitor"
}

cmd_unmonitor() {
  local pr="${1:?pr# required}"
  gh pr edit "$pr" --repo "$REPO" --remove-label agent:monitor >/dev/null
  echo "PR #$pr unmonitored"
}

cmd_monitored() {
  gh pr list --repo "$REPO" --state open --label agent:monitor \
    --json number,title,headRefName,statusCheckRollup,reviewDecision,labels
}

cmd_attempt() {
  local pr="${1:?pr# required}"
  local cur; cur=$(autofix_level "$pr")
  local next=$((cur + 1))
  [[ -n "$cur" && "$cur" -gt 0 ]] && gh pr edit "$pr" --repo "$REPO" --remove-label "autofix:$cur" >/dev/null 2>&1 || true
  if (( next > MAX_AUTOFIX )); then echo "BUDGET_EXHAUSTED:$cur"; return 3; fi
  gh pr edit "$pr" --repo "$REPO" --add-label "autofix:$next" >/dev/null
  echo "$next"
}

cmd_escalate() {
  local pr="${1:?pr# required}"; local reason="${2:?reason required}"
  gh pr edit "$pr" --repo "$REPO" --add-label needs-human --remove-label agent:monitor >/dev/null
  gh pr comment "$pr" --repo "$REPO" --body "🛑 **Autonomous fix loop gave up.** $reason

Reached the autofix budget ($MAX_AUTOFIX attempts) or detected no progress. A human needs to look."
  echo "PR #$pr -> needs-human (unmonitored)"
}

cmd_done() {
  local t="${1:?task# required}"
  gh issue edit "$t" --repo "$REPO" --add-label status:done \
    --remove-label status:in-progress --remove-label status:in-review --remove-label status:ready >/dev/null 2>&1 || true
  gh issue close "$t" --repo "$REPO" >/dev/null
  echo "task #$t -> done (closed)"
}

cmd_status() {
  if [[ $# -ge 1 ]]; then
    local epic="$1"
    gh api graphql -f query='query($o:String!,$r:String!,$n:Int!){ repository(owner:$o,name:$r){ issue(number:$n){
      title subIssuesSummary{ total completed percentCompleted }
      subIssues(first:50){ nodes{ number title state } } } } }' \
      -F o="${REPO%/*}" -F r="${REPO#*/}" -F n="$epic" \
      --jq '.data.repository.issue | "epic #'"$epic"': \(.title)\nroll-up: \(.subIssuesSummary.completed)/\(.subIssuesSummary.total) (\(.subIssuesSummary.percentCompleted)%)\n" + (.subIssues.nodes[] | "  #\(.number) [\(.state)] \(.title)")'
  else
    echo "# board: $REPO"
    for s in ready in-progress in-review blocked; do
      c=$(gh issue list --repo "$REPO" --state open --label "status:$s" --json number -q 'length')
      echo "status:$s -> $c"
    done
    m=$(gh pr list --repo "$REPO" --state open --label agent:monitor --json number -q 'length')
    h=$(gh issue list --repo "$REPO" --state open --label needs-human --json number -q 'length')
    echo "monitored PRs -> $m"
    echo "needs-human   -> $h"
  fi
}

# --- Loop B: post-merge change monitoring ---------------------------------

# merged PRs still opted-in but not yet given a monitoring issue (label removed on init)
cmd_pending_merges() {
  gh pr list --repo "$REPO" --state merged --label monitor-after-merge \
    --json number,title,mergeCommit,mergedAt,headRefName,author
}

# open change-verification issues for the routine to re-check each tick
cmd_changes() {
  gh issue list --repo "$REPO" --state open --label type:monitor-change \
    --json number,title,body,createdAt,labels
}

# all-clear: change monitored through its window with no regression
cmd_verified() {
  local i="${1:?issue# required}"
  gh issue edit "$i" --repo "$REPO" --add-label verified >/dev/null
  gh issue close "$i" --repo "$REPO" --comment "✅ Monitored clean through the verification window. Closing." >/dev/null
  echo "monitor-change #$i -> verified (closed)"
}

# called by /monitor-change init after it authors the issue body: drops the opt-in label so the
# PR isn't picked up again next tick
cmd_init_done() {
  local pr="${1:?pr# required}"
  gh pr edit "$pr" --repo "$REPO" --remove-label monitor-after-merge >/dev/null
  echo "PR #$pr monitoring issue created"
}

# --- Loop C: perpetual error sweeper --------------------------------------

# create a deduped bug task from a telemetry error signature. --fingerprint is a stable hash of the
# error (e.g. service+message); a matching open issue short-circuits so we never double-file.
cmd_error_task() {
  local title="${1:?title required}"; shift
  local fp="" body="Filed by the perpetual error sweeper (Loop C)."
  while [[ $# -gt 0 ]]; do case "$1" in
    --fingerprint) fp="$2"; shift 2;;
    --body)        body="$2"; shift 2;;
    *) die "unknown flag: $1";;
  esac; done
  [[ -n "$fp" ]] || die "--fingerprint required (stable hash of the error signature)"
  local fplabel="fp:$fp"
  gh label create "$fplabel" --repo "$REPO" --color "ededed" --description "error fingerprint" --force >/dev/null
  local existing; existing=$(gh issue list --repo "$REPO" --state open --label "$fplabel" --json number -q '(.[0].number // empty)')
  if [[ -n "$existing" ]]; then echo "DUP:$existing"; return 0; fi
  local url; url=$(gh issue create --repo "$REPO" --title "$title" --body "$body" \
                    --label type:error-watch --label agent:claude --label status:ready --label priority:p1 --label "$fplabel")
  num_from_url "$url"
}

# --- dispatch --------------------------------------------------------------
cmd="${1:-}"; shift || true
case "$cmd" in
  init-labels) cmd_init_labels "$@";;
  epic)        cmd_epic "$@";;
  task)        cmd_task "$@";;
  next)        cmd_next "$@";;
  pickup)      cmd_pickup "$@";;
  monitor)     cmd_monitor "$@";;
  unmonitor)   cmd_unmonitor "$@";;
  monitored)   cmd_monitored "$@";;
  attempt)     cmd_attempt "$@";;
  escalate)    cmd_escalate "$@";;
  done)        cmd_done "$@";;
  status)      cmd_status "$@";;
  pending-merges) cmd_pending_merges "$@";;
  changes)     cmd_changes "$@";;
  verified)    cmd_verified "$@";;
  init-done)   cmd_init_done "$@";;
  error-task)  cmd_error_task "$@";;
  *) die "usage: see header of $0 (verbs: init-labels epic task next pickup monitor unmonitor monitored attempt escalate done status pending-merges changes verified init-done error-task)";;
esac