2026-03-13 09:17:09 +00:00
#!/usr/bin/env bash
# =============================================================================
# gardener-poll.sh — Issue backlog grooming agent
#
# Cron: daily (or 2x/day). Reads open issues, detects problems, invokes
# claude -p to fix or escalate.
#
# Problems detected (bash, zero tokens):
# - Duplicate titles / overlapping scope
# - Missing acceptance criteria
# - Missing dependencies (references other issues but no dep link)
# - Oversized issues (too many acceptance criteria or change files)
# - Stale issues (no activity > 14 days, still open)
# - Closed issues with open dependents still referencing them
#
# Actions taken (claude -p):
# - Close duplicates with cross-reference comment
# - Add acceptance criteria template
# - Set dependency labels
# - Split oversized issues (create sub-issues, close parent)
# - Escalate decisions to human via openclaw system event
#
# Escalation format (compact, decision-ready):
# 🌱 Issue Gardener — N items need attention
# 1. #123 "title" — duplicate of #456? (a) close #123 (b) close #456 (c) merge scope
# 2. #789 "title" — needs decision: (a) backlog (b) wontfix (c) split into X,Y
# =============================================================================
set -euo pipefail
SCRIPT_DIR = " $( cd " $( dirname " $0 " ) " && pwd ) "
FACTORY_ROOT = " $( dirname " $SCRIPT_DIR " ) "
# shellcheck source=../lib/env.sh
source " $FACTORY_ROOT /lib/env.sh "
LOG_FILE = " $SCRIPT_DIR /gardener.log "
LOCK_FILE = "/tmp/gardener-poll.lock"
CLAUDE_TIMEOUT = " ${ CLAUDE_TIMEOUT :- 3600 } "
log( ) { echo " [ $( date -u +%Y-%m-%dT%H:%M:%S) Z] $* " >> " $LOG_FILE " ; }
# ── Lock ──────────────────────────────────────────────────────────────────
if [ -f " $LOCK_FILE " ] ; then
LOCK_PID = $( cat " $LOCK_FILE " 2>/dev/null || true )
if [ -n " $LOCK_PID " ] && kill -0 " $LOCK_PID " 2>/dev/null; then
log " poll: gardener running (PID $LOCK_PID ) "
exit 0
fi
rm -f " $LOCK_FILE "
fi
echo $$ > " $LOCK_FILE "
trap 'rm -f "$LOCK_FILE"' EXIT
log "--- Gardener poll start ---"
2026-03-14 16:25:33 +01:00
# ── Check for escalation replies from Matrix ──────────────────────────────
ESCALATION_REPLY = ""
if [ -s /tmp/gardener-escalation-reply ] ; then
ESCALATION_REPLY = $( cat /tmp/gardener-escalation-reply)
rm -f /tmp/gardener-escalation-reply
log " Got escalation reply: $( echo " $ESCALATION_REPLY " | head -1) "
fi
2026-03-13 09:17:09 +00:00
# ── Fetch all open issues ─────────────────────────────────────────────────
ISSUES_JSON = $( codeberg_api GET "/issues?state=open&type=issues&limit=50&sort=updated&direction=desc" 2>/dev/null || true )
if [ -z " $ISSUES_JSON " ] || [ " $ISSUES_JSON " = "null" ] ; then
log "Failed to fetch issues"
exit 1
fi
ISSUE_COUNT = $( echo " $ISSUES_JSON " | jq 'length' )
log " Found $ISSUE_COUNT open issues "
if [ " $ISSUE_COUNT " -eq 0 ] ; then
log "No open issues — nothing to groom"
exit 0
fi
# ── Bash pre-checks (zero tokens) ────────────────────────────────────────
PROBLEMS = ""
# 1. Duplicate detection: issues with very similar titles
TITLES = $( echo " $ISSUES_JSON " | jq -r '.[] | "\(.number)\t\(.title)"' )
DUPES = ""
while IFS = $'\t' read -r num1 title1; do
while IFS = $'\t' read -r num2 title2; do
[ " $num1 " -ge " $num2 " ] && continue
2026-03-13 09:22:44 +00:00
# Normalize: lowercase, strip prefixes + series names, collapse whitespace
t1 = $( echo " $title1 " | tr '[:upper:]' '[:lower:]' | sed 's/^feat:\|^fix:\|^refactor://;s/llm seed[^—]*—\s*//;s/push3 evolution[^—]*—\s*//;s/[^a-z0-9 ]//g;s/ */ /g' )
t2 = $( echo " $title2 " | tr '[:upper:]' '[:lower:]' | sed 's/^feat:\|^fix:\|^refactor://;s/llm seed[^—]*—\s*//;s/push3 evolution[^—]*—\s*//;s/[^a-z0-9 ]//g;s/ */ /g' )
2026-03-13 09:17:09 +00:00
# Count shared words (>60% overlap = suspect)
WORDS1 = $( echo " $t1 " | tr ' ' '\n' | sort -u)
WORDS2 = $( echo " $t2 " | tr ' ' '\n' | sort -u)
SHARED = $( comm -12 <( echo " $WORDS1 " ) <( echo " $WORDS2 " ) | wc -l)
TOTAL1 = $( echo " $WORDS1 " | wc -l)
TOTAL2 = $( echo " $WORDS2 " | wc -l)
MIN_TOTAL = $(( TOTAL1 < TOTAL2 ? TOTAL1 : TOTAL2 ))
if [ " $MIN_TOTAL " -gt 2 ] && [ " $SHARED " -gt 0 ] ; then
OVERLAP = $(( SHARED * 100 / MIN_TOTAL ))
if [ " $OVERLAP " -ge 60 ] ; then
DUPES = " ${ DUPES } possible_dupe: # ${ num1 } vs # ${ num2 } ( ${ OVERLAP } % word overlap)\n "
fi
fi
done <<< " $TITLES "
done <<< " $TITLES "
[ -n " $DUPES " ] && PROBLEMS = " ${ PROBLEMS } ${ DUPES } "
# 2. Missing acceptance criteria: issues with short body and no checkboxes
while IFS = $'\t' read -r num body_len has_checkbox; do
if [ " $body_len " -lt 100 ] && [ " $has_checkbox " = "false" ] ; then
PROBLEMS = " ${ PROBLEMS } thin_issue: # ${ num } — body < 100 chars, no acceptance criteria\n "
fi
done < <( echo " $ISSUES_JSON " | jq -r '.[] | "\(.number)\t\(.body | length)\t\(.body | test("- \\[[ x]\\]") // false)"' )
# 3. Stale issues: no update in 14+ days
NOW_EPOCH = $( date +%s)
while IFS = $'\t' read -r num updated_at; do
UPDATED_EPOCH = $( date -d " $updated_at " +%s 2>/dev/null || echo 0)
AGE_DAYS = $(( ( NOW_EPOCH - UPDATED_EPOCH) / 86400 ))
if [ " $AGE_DAYS " -ge 14 ] ; then
PROBLEMS = " ${ PROBLEMS } stale: # ${ num } — no activity for ${ AGE_DAYS } days\n "
fi
done < <( echo " $ISSUES_JSON " | jq -r '.[] | "\(.number)\t\(.updated_at)"' )
# 4. Issues referencing closed deps
while IFS = $'\t' read -r num body; do
REFS = $( echo " $body " | grep -oP '#\d+' | grep -oP '\d+' | sort -u || true )
for ref in $REFS ; do
[ " $ref " = " $num " ] && continue
REF_STATE = $( echo " $ISSUES_JSON " | jq -r --arg n " $ref " '.[] | select(.number == ($n | tonumber)) | .state' 2>/dev/null || true )
# If ref not in our open set, check if it's closed
if [ -z " $REF_STATE " ] ; then
REF_STATE = $( codeberg_api GET " /issues/ $ref " 2>/dev/null | jq -r '.state // "unknown"' 2>/dev/null || true )
# Rate limit protection
sleep 0.5
fi
done
done < <( echo " $ISSUES_JSON " | jq -r '.[] | "\(.number)\t\(.body // "")"' | head -20)
2026-03-13 20:50:16 +00:00
# 5. Tech-debt issues needing promotion to backlog (primary mission)
TECH_DEBT_ISSUES = $( echo " $ISSUES_JSON " | jq -r '.[] | select(.labels | map(.name) | index("tech-debt")) | "#\(.number) \(.title)"' | head -10)
if [ -n " $TECH_DEBT_ISSUES " ] ; then
TECH_DEBT_COUNT = $( echo " $TECH_DEBT_ISSUES " | wc -l)
PROBLEMS = " ${ PROBLEMS } tech_debt_promotion: ${ TECH_DEBT_COUNT } tech-debt issues need promotion to backlog (max 10 per run):\n ${ TECH_DEBT_ISSUES } \n "
fi
2026-03-13 09:17:09 +00:00
PROBLEM_COUNT = $( echo -e " $PROBLEMS " | grep -c '.' || true )
log " Detected $PROBLEM_COUNT potential problems "
if [ " $PROBLEM_COUNT " -eq 0 ] ; then
log "Backlog is clean — nothing to groom"
exit 0
fi
# ── Invoke claude -p ──────────────────────────────────────────────────────
log "Invoking claude -p for grooming"
# Build issue summary for context (titles + labels + deps)
ISSUE_SUMMARY = $( echo " $ISSUES_JSON " | jq -r '.[] | "#\(.number) [\(.labels | map(.name) | join(","))] \(.title)"' )
2026-03-14 13:49:09 +01:00
PROMPT = " You are the issue gardener for ${ CODEBERG_REPO } . Your job: keep the backlog clean, well-structured, and actionable.
2026-03-13 09:17:09 +00:00
## Current open issues
$ISSUE_SUMMARY
## Problems detected
$( echo -e " $PROBLEMS " )
## Tools available
2026-03-13 22:35:30 +00:00
- Codeberg API: use curl with the CODEBERG_TOKEN env var ( already set in your environment)
2026-03-14 13:49:09 +01:00
- Base URL: ${ CODEBERG_API }
- Read issue: \` curl -sf -H \" Authorization: token \$ CODEBERG_TOKEN\" '${CODEBERG_API}/issues/{number}' | jq '.body' \`
- Relabel: \` curl -sf -H \" Authorization: token \$ CODEBERG_TOKEN\" -X PUT -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}/labels' -d '{\"labels\":[LABEL_ID]}' \`
- Comment: \` curl -sf -H \" Authorization: token \$ CODEBERG_TOKEN\" -X POST -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}/comments' -d '{\"body\":\"...\"}' \`
- Close: \` curl -sf -H \" Authorization: token \$ CODEBERG_TOKEN\" -X PATCH -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}' -d '{\"state\":\"closed\"}' \`
- Edit body: \` curl -sf -H \" Authorization: token \$ CODEBERG_TOKEN\" -X PATCH -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}' -d '{\"body\":\"new body\"}' \`
- List labels: \` curl -sf -H \" Authorization: token \$ CODEBERG_TOKEN\" '${CODEBERG_API}/labels' \` ( to find label IDs)
2026-03-13 22:35:30 +00:00
- NEVER echo, log, or include the actual token value in any output — always reference \$ CODEBERG_TOKEN
2026-03-14 13:49:09 +01:00
- You' re running in the project repo root. Read README.md and any docs/ files before making decisions.
2026-03-13 09:17:09 +00:00
2026-03-13 09:32:39 +00:00
## Primary mission: promote tech-debt → backlog
Most open issues are raw review-bot findings labeled \` tech-debt\` . Your main job is to convert them into well-structured \` backlog\` items the dev-agent can execute. For each tech-debt issue:
1. Read the issue body + referenced source files to understand the real problem
2026-03-15 17:41:10 +01:00
2. Check AGENTS.md ( and sub-directory AGENTS.md files) for architecture context
2026-03-13 09:32:39 +00:00
3. Add missing sections: \` ## Affected files\`, \`## Acceptance criteria\` (checkboxes, max 5), \`## Dependencies\`
4. If the issue is clear and actionable → relabel: remove \` tech-debt\` , add \` backlog\`
5. If scope is ambiguous or needs a design decision → ESCALATE with options
6. If superseded by a merged PR or another issue → close with explanation
Process up to 10 tech-debt issues per run ( stay within API rate limits) .
## Other rules
2026-03-13 09:17:09 +00:00
1. **Duplicates**: If confident ( >80% overlap + same scope after reading bodies) , close the newer one with a comment referencing the older. If unsure, ESCALATE.
2026-03-13 09:32:39 +00:00
2. **Thin issues** ( non-tech-debt) : Add acceptance criteria. Read the body first.
2026-03-13 09:17:09 +00:00
3. **Stale issues**: If clearly superseded or no longer relevant, close with explanation. If unclear, ESCALATE.
2026-03-13 09:32:39 +00:00
4. **Oversized issues**: If >5 acceptance criteria touching different files/concerns, ESCALATE with suggested split.
2026-03-13 09:17:09 +00:00
5. **Dependencies**: If an issue references another that must land first, add a \` ## Dependencies\n- #NNN\` section if missing.
## Escalation format
For anything needing human decision, output EXACTLY this format ( one block, all items) :
\` \` \`
ESCALATE
1. #NNN \"title\" — reason (a) option1 (b) option2 (c) option3
2. #NNN \"title\" — reason (a) option1 (b) option2
\` \` \`
2026-03-14 08:40:19 +00:00
## Output format (MANDATORY — the script parses these exact prefixes)
- After EVERY action you take, print exactly: ACTION: <description>
- For issues needing human decision, output EXACTLY:
ESCALATE
1. #NNN \"title\" — reason (a) option1 (b) option2
- If truly nothing to do , print: CLEAN
## Important
- You MUST process the tech_debt_promotion items listed above. Read each issue, add acceptance criteria + affected files, then relabel to backlog.
- If an issue is ambiguous or needs a design decision, ESCALATE it — don' t skip it silently.
2026-03-14 16:25:33 +01:00
- Every tech-debt issue in the list above should result in either an ACTION ( promoted) or an ESCALATE ( needs decision) . Never skip silently.
$( if [ -n " $ESCALATION_REPLY " ] ; then echo "
## Human Response to Previous Escalation
The human replied with shorthand choices keyed to the previous ESCALATE block.
Format: '1a 2c 3b' means question 1→option ( a) , question 2→option ( c) , question 3→option ( b) .
Raw reply:
${ ESCALATION_REPLY }
Execute each chosen option NOW via the Codeberg API before processing new items.
If a choice is unclear, re-escalate that single item with a clarifying question."; fi)"
2026-03-13 09:17:09 +00:00
2026-03-14 13:49:09 +01:00
CLAUDE_OUTPUT = $( cd " ${ PROJECT_REPO_ROOT } " && CODEBERG_TOKEN = " $CODEBERG_TOKEN " timeout " $CLAUDE_TIMEOUT " \
2026-03-13 09:17:09 +00:00
claude -p " $PROMPT " \
--model sonnet \
--dangerously-skip-permissions \
2026-03-13 20:50:16 +00:00
--max-turns 30 \
2026-03-13 09:17:09 +00:00
2>/dev/null) || true
log " claude finished ( $( echo " $CLAUDE_OUTPUT " | wc -c) bytes) "
# ── Parse escalations ────────────────────────────────────────────────────
ESCALATION = $( echo " $CLAUDE_OUTPUT " | sed -n '/^ESCALATE$/,/^```$/p' | grep -v '^ESCALATE$\|^```$' || true )
if [ -z " $ESCALATION " ] ; then
ESCALATION = $( echo " $CLAUDE_OUTPUT " | grep -A50 "^ESCALATE" | grep '^\d' || true )
fi
if [ -n " $ESCALATION " ] ; then
ITEM_COUNT = $( echo " $ESCALATION " | grep -c '.' || true )
log " Escalating $ITEM_COUNT items to human "
2026-03-14 16:25:33 +01:00
# Send via Matrix (threaded — replies route back via listener)
matrix_send "gardener" " 🌱 Issue Gardener — ${ ITEM_COUNT } item(s) need attention
2026-03-13 09:17:09 +00:00
${ ESCALATION }
Reply with numbers+letters ( e.g. 1a 2c) to decide." 2>/dev/null || true
fi
# ── Log actions taken ─────────────────────────────────────────────────────
ACTIONS = $( echo " $CLAUDE_OUTPUT " | grep "^ACTION:" || true )
if [ -n " $ACTIONS " ] ; then
echo " $ACTIONS " | while read -r line; do
log " $line "
done
fi
log "--- Gardener poll done ---"