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