#!/usr/bin/env bash # pm-task.sh / # # Webhook-triggered: an issue acquired the label "pm:plan". Reads the parent # issue body as a free-form goal, asks claude to decompose into 1–5 structured # agent-issues, creates each as a child issue with label "agent:assign", and # comments on the parent linking to the children. # # Env knobs: # MODEL default sonnet # GITEA_URL default https://gitea.dannyhaslund.dk # GITEA_TOKEN_FILE default /etc/agent/gitea-token # PM_PLAN_LABEL_ID default 16 (the pm:plan label id on the target repo) # PM_PLANNED_LABEL_ID default 17 (the pm:planned label id) # AGENT_LABEL_ID default 15 (the agent:assign label id) set -euo pipefail REPO="${1:-}" PARENT="${2:-}" [[ -z "$REPO" || -z "$PARENT" ]] && { echo "usage: $0 / " >&2; exit 64; } MODEL="${MODEL:-sonnet}" GITEA_URL="${GITEA_URL:-https://gitea.dannyhaslund.dk}" GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-/etc/agent/gitea-token}" PM_PLAN_LABEL_ID="${PM_PLAN_LABEL_ID:-16}" PM_PLANNED_LABEL_ID="${PM_PLANNED_LABEL_ID:-17}" AGENT_LABEL_ID="${AGENT_LABEL_ID:-15}" [[ -r "$GITEA_TOKEN_FILE" ]] || { echo "missing $GITEA_TOKEN_FILE" >&2; exit 65; } GITEA_TOKEN="$(cat "$GITEA_TOKEN_FILE")" # Per-issue flock so duplicate webhook deliveries (Gitea fires both 'opened' # and 'label_updated' for an issue opened with a label) don't double-plan. LOCK_DIR="${LOCK_DIR:-/var/lib/agent-pm}" mkdir -p "$LOCK_DIR" LOCK_FILE="$LOCK_DIR/${REPO//\//_}-${PARENT}.lock" exec 201>"$LOCK_FILE" if ! flock -n 201; then echo "[pm-task] another pm-task already running for $REPO#$PARENT; exiting" >&2 exit 0 fi LOG_FILE="/var/log/agent-webhook/pm-task-${REPO//\//_}-${PARENT}-$(date -u +%Y%m%dT%H%M%S).log" mkdir -p "$(dirname "$LOG_FILE")" log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" | tee -a "$LOG_FILE"; } api() { curl -fsS -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" "$@"; } # ---------- 1. Fetch parent issue + idempotency check ---------- log "Fetching parent $REPO#$PARENT" parent_json="$(api "$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT")" parent_title="$(jq -r .title <<<"$parent_json")" goal_body="$(jq -r .body <<<"$parent_json")" parent_labels="$(jq -r '.labels[].name' <<<"$parent_json" | tr '\n' ' ')" if echo "$parent_labels" | grep -qw "pm:planned"; then log "Parent already has pm:planned label — refusing to re-plan. Remove pm:planned first if you want to re-plan." exit 0 fi # ---------- 2. Probe repo state ---------- # Cheap context: list the top-level file names on the default branch so the # PM doesn't propose duplicating things that already exist. default_branch="$(api "$GITEA_URL/api/v1/repos/$REPO" | jq -r .default_branch)" contents_json="$(api "$GITEA_URL/api/v1/repos/$REPO/contents?ref=$default_branch")" file_list="$(jq -r '.[].name' <<<"$contents_json" 2>/dev/null | head -25 || echo "(empty repo)")" # Open PRs as context (may be relevant to whether work is in flight) open_prs="$(api "$GITEA_URL/api/v1/repos/$REPO/pulls?state=open&limit=10" | \ jq -r '.[] | " #\(.number) — \(.title) (branch \(.head.ref))"' || echo " (none)")" REPO_STATE="default branch: $default_branch top-level files on $default_branch: $(printf '%s\n' "$file_list" | sed 's/^/ - /') open PRs: $open_prs" # ---------- 3. Decompose via claude ---------- SCHEMA='{ "type": "object", "required": ["issues"], "properties": { "issues": { "type": "array", "minItems": 1, "maxItems": 5, "items": { "type": "object", "required": ["title", "goal", "done_criteria", "model"], "properties": { "title": { "type": "string", "maxLength": 80 }, "goal": { "type": "string", "minLength": 20 }, "done_criteria": { "type": "array", "minItems": 1, "items": { "type": "string" } }, "hints": { "type": "string" }, "model": { "type": "string", "enum": ["sonnet", "opus"] } } } } } }' PROMPT="You are the project manager for an autonomous coding empire. The human just opened parent issue #$PARENT in repo '$REPO' with the following goal. Decompose it into 1 to 5 structured agent-issues that, implemented in order, achieve the goal. # Parent issue title $parent_title # Parent issue body (the goal) $goal_body # Current repo state $REPO_STATE # Constraints on the issues you produce - Each issue must fit in <30 minutes of autonomous agent time on Sonnet. - Done criteria must be specific and mechanically verifiable (file exists, endpoint returns X, HTTP code, test passes). No taste calls. - Order issues by dependency (foundation first). - Use 'opus' only for tasks requiring design tradeoffs; routine scaffolding/CRUD/refactors are 'sonnet'. - Prefer fewer larger issues over many trivial ones, as long as each fits in 30 min. - Avoid issues that require human judgment (\"make it look good\"). - Do NOT call any tools — produce JSON from the prompt text alone. Return only the JSON matching the schema." TMP="$(mktemp)" trap 'rm -f "$TMP"' EXIT log "Decomposing via claude (model=$MODEL)" claude -p "$PROMPT" \ --model "$MODEL" \ --output-format json \ --json-schema "$SCHEMA" \ --allowedTools "" \ < /dev/null > "$TMP" 2>>"$LOG_FILE" issues_json="$(jq -c '.structured_output.issues // empty' "$TMP")" if [[ -z "$issues_json" || "$issues_json" == "null" ]]; then log "ERROR: claude did not return structured_output.issues" api -X POST -d "$(jq -nc --arg b "agent PM: failed to decompose. See log \`$LOG_FILE\` and remove + re-add the pm:plan label to retry." '{body:$b}')" \ "$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT/comments" >/dev/null exit 1 fi count="$(jq 'length' <<<"$issues_json")" log "claude proposed $count child issue(s)" # ---------- 4. Create child issues with agent:assign label ---------- created=() for i in $(seq 0 $((count - 1))); do issue="$(jq -c ".[$i]" <<<"$issues_json")" title="$(jq -r .title <<<"$issue")" goal_t="$(jq -r .goal <<<"$issue")" done_b="$(jq -r '.done_criteria | map("- [ ] " + .) | join("\n")' <<<"$issue")" hints="$(jq -r '.hints // ""' <<<"$issue")" model_t="$(jq -r '.model // "sonnet"' <<<"$issue")" body="$(printf 'Parent: #%s\n\n## Goal\n\n%s\n\n## Done criteria\n\n%s\n\n## Hints\n\n%s\n\n## Model\n\n%s\n' \ "$PARENT" "$goal_t" "$done_b" "${hints:-(none)}" "$model_t")" resp="$(api -X POST -d "$(jq -nc \ --arg t "$title" --arg b "$body" --argjson lid "$AGENT_LABEL_ID" \ '{title:$t, body:$b, labels:[$lid]}')" \ "$GITEA_URL/api/v1/repos/$REPO/issues")" num="$(jq -r .number <<<"$resp")" created+=("$num") log " created #$num — $title" done # ---------- 5. Update parent: remove pm:plan, add pm:planned, comment ---------- api -X DELETE "$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT/labels/$PM_PLAN_LABEL_ID" >/dev/null 2>&1 || true api -X POST -d "$(jq -nc --argjson lid "$PM_PLANNED_LABEL_ID" '{labels:[$lid]}')" \ "$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT/labels" >/dev/null comment_lines="" for n in "${created[@]}"; do comment_lines+="- #$n\n" done parent_comment="$(printf 'agent PM: decomposed into %d child issue(s):\n\n%b\nEach is labeled `agent:assign` and will be picked up by a dev agent (one at a time).' "${#created[@]}" "$comment_lines")" api -X POST -d "$(jq -nc --arg b "$parent_comment" '{body:$b}')" \ "$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT/comments" >/dev/null log "Done. created=${created[*]}"