diff --git a/.woodpecker/agent-smoke.sh b/.woodpecker/agent-smoke.sh
index daccde7..7aeaa9c 100644
--- a/.woodpecker/agent-smoke.sh
+++ b/.woodpecker/agent-smoke.sh
@@ -192,8 +192,6 @@ check_script dev/dev-agent.sh dev/phase-handler.sh
check_script dev/phase-handler.sh dev/dev-agent.sh lib/secret-scan.sh
check_script dev/dev-poll.sh
check_script dev/phase-test.sh
-check_script gardener/gardener-agent.sh lib/agent-session.sh
-check_script gardener/gardener-poll.sh
check_script gardener/gardener-run.sh
check_script review/review-pr.sh
check_script review/review-poll.sh
diff --git a/AGENTS.md b/AGENTS.md
index 8dbc47e..b6c25e7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,8 +16,7 @@ See `README.md` for the full architecture and `BOOTSTRAP.md` for setup.
disinto/
├── dev/ dev-poll.sh, dev-agent.sh, phase-handler.sh — issue implementation
├── review/ review-poll.sh, review-pr.sh — PR review
-├── gardener/ gardener-run.sh — files action issue for run-gardener formula
-│ gardener-poll.sh, gardener-agent.sh — grooming
+├── gardener/ gardener-run.sh — direct cron executor for run-gardener formula
├── predictor/ predictor-run.sh — daily cron executor for run-predictor formula
├── planner/ planner-run.sh — direct cron executor for run-planner formula
│ planner/journal/ — daily raw logs from each planner run
diff --git a/dev/phase-handler.sh b/dev/phase-handler.sh
index c10d628..53b74e6 100644
--- a/dev/phase-handler.sh
+++ b/dev/phase-handler.sh
@@ -627,7 +627,7 @@ Instructions:
"⚠️ Issue #${ISSUE} (PR #${PR_NUMBER:-none}) needs human input.${HUMAN_REASON:+ Reason: ${HUMAN_REASON}}" \
"⚠️ Issue #${ISSUE}${_pr_link} needs human input.${HUMAN_REASON:+ Reason: ${HUMAN_REASON}}
Reply in this thread to send guidance to the dev agent."
log "phase: needs_human — notified via Matrix, waiting for external injection"
- # Don't inject anything — supervisor-poll.sh (#81) injects human replies, gardener-poll.sh as backup
+ # Don't inject anything — supervisor-run.sh (#81) injects human replies
# ── PHASE: done ─────────────────────────────────────────────────────────────
# PR merged and issue closed (by orchestrator or Claude). Just clean up local state.
diff --git a/formulas/run-gardener.toml b/formulas/run-gardener.toml
index 9c4869b..55f4b4e 100644
--- a/formulas/run-gardener.toml
+++ b/formulas/run-gardener.toml
@@ -1,7 +1,7 @@
# formulas/run-gardener.toml — Gardener housekeeping formula
#
# Defines the gardener's complete run: grooming (Claude session via
-# gardener-agent.sh) + blocked-review + AGENTS.md maintenance + final
+# gardener-run.sh) + blocked-review + AGENTS.md maintenance + final
# commit-and-pr.
#
# No memory, no journal. The gardener does mechanical housekeeping
@@ -48,7 +48,7 @@ id = "grooming"
title = "Backlog grooming — triage all open issues"
description = """
Groom the open issue backlog. This step is the core Claude-driven analysis
-(currently implemented in gardener-agent.sh with bash pre-checks).
+(Claude performs pre-checks inline before deeper analysis).
Pre-checks (bash, zero tokens — detect problems before invoking Claude):
diff --git a/gardener/AGENTS.md b/gardener/AGENTS.md
index b704c57..47e8449 100644
--- a/gardener/AGENTS.md
+++ b/gardener/AGENTS.md
@@ -5,19 +5,25 @@
criteria, oversized issues, stale issues, and circular dependencies. Invoke
Claude to fix or escalate to a human via Matrix.
-**Trigger**: `gardener-run.sh` runs 2x/day via cron. It files an `action`
-issue referencing `formulas/run-gardener.toml`; the action-agent picks it up
-and executes the gardener steps in an interactive Claude tmux session. Accepts
-an optional project TOML argument (configures which project the action issue is
-filed against).
+**Trigger**: `gardener-run.sh` runs 4x/day via cron. It creates a tmux
+session with `claude --model sonnet`, injects `formulas/run-gardener.toml`
+with escalation replies as context, monitors the phase file, and cleans up
+on completion or timeout (2h max session). No action issues — the gardener
+runs directly from cron like the planner, predictor, and supervisor.
**Key files**:
-- `gardener/gardener-run.sh` — Cron wrapper: lock, memory guard, dedup check, files action issue
-- `gardener/gardener-poll.sh` — Escalation-reply injection for dev sessions, invokes gardener-agent.sh for grooming
-- `gardener/gardener-agent.sh` — Orchestrator: bash pre-analysis, creates tmux session (`gardener-{project}`) with interactive `claude`, monitors phase file, parses result file (ACTION:/DUST:/ESCALATE)
+- `gardener/gardener-run.sh` — Cron wrapper + orchestrator: lock, memory guard,
+ consumes escalation replies, sources disinto project config, creates tmux session,
+ injects formula prompt, monitors phase file, handles crash recovery via
+ `run_formula_and_monitor`
- `formulas/run-gardener.toml` — Execution spec: preflight, grooming, dust-bundling, blocked-review, agents-update, commit-and-pr
**Environment variables consumed**:
- `CODEBERG_TOKEN`, `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
-- `CLAUDE_TIMEOUT`
+- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by gardener-run.sh)
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER`
+
+**Lifecycle**: gardener-run.sh (cron 0,6,12,18) → lock + memory guard →
+consume escalation replies → load formula + context → create tmux session →
+Claude grooms backlog, bundles dust, reviews blocked issues, updates AGENTS.md,
+commits and creates PR → `PHASE:done`.
diff --git a/gardener/gardener-agent.sh b/gardener/gardener-agent.sh
deleted file mode 100644
index e517cb4..0000000
--- a/gardener/gardener-agent.sh
+++ /dev/null
@@ -1,359 +0,0 @@
-#!/usr/bin/env bash
-# gardener-agent.sh — tmux + Claude interactive gardener session manager
-#
-# Usage: ./gardener-agent.sh [project-toml]
-# Called by: gardener-poll.sh
-#
-# Lifecycle:
-# 1. Read escalation reply (from ESCALATION_REPLY env var)
-# 2. Fetch open issues + bash pre-checks (zero tokens)
-# 3. If no problems detected, exit 0
-# 4. Build prompt with result-file output + phase protocol instructions
-# 5. Create tmux session: gardener-{project} with interactive claude
-# 6. Inject prompt via tmux
-# 7. Monitor phase file — Claude writes PHASE:done when finished
-# 8. Parse result file (ACTION:/DUST:/ESCALATE) → Matrix + dust.jsonl
-# 9. Dust bundling: groups with 3+ items → one backlog issue
-#
-# Phase file: /tmp/gardener-session-{project}.phase
-# Result file: /tmp/gardener-result-{project}.txt
-# Session: gardener-{project} (tmux)
-
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
-
-export PROJECT_TOML="${1:-}"
-# shellcheck source=../lib/env.sh
-source "$FACTORY_ROOT/lib/env.sh"
-# shellcheck source=../lib/agent-session.sh
-source "$FACTORY_ROOT/lib/agent-session.sh"
-# shellcheck source=../lib/formula-session.sh
-source "$FACTORY_ROOT/lib/formula-session.sh"
-
-LOG_FILE="$SCRIPT_DIR/gardener.log"
-SESSION_NAME="gardener-${PROJECT_NAME}"
-PHASE_FILE="/tmp/gardener-session-${PROJECT_NAME}.phase"
-RESULT_FILE="/tmp/gardener-result-${PROJECT_NAME}.txt"
-SCRATCH_FILE="/tmp/gardener-${PROJECT_NAME}-scratch.md"
-
-# shellcheck disable=SC2034 # read by monitor_phase_loop in lib/agent-session.sh
-PHASE_POLL_INTERVAL=15
-
-log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
-
-# Gitea labels API requires []int64 — look up the "backlog" label ID once
-# Falls back to the known Codeberg repo ID if the API call fails
-BACKLOG_LABEL_ID=$(codeberg_api GET "/labels" 2>/dev/null \
- | jq -r '.[] | select(.name == "backlog") | .id' 2>/dev/null || true)
-BACKLOG_LABEL_ID="${BACKLOG_LABEL_ID:-1300815}"
-
-log "--- gardener-agent start ---"
-
-# ── Read escalation reply (passed via env by gardener-poll.sh) ────────────
-ESCALATION_REPLY="${ESCALATION_REPLY:-}"
-
-# ── 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
- # 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')
- # 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)"')
-
-# 5. Blocker detection: find issues blocking backlog items that aren't themselves backlog
-# This is the HIGHEST PRIORITY — a non-backlog blocker starves the entire factory
-BACKLOG_ISSUES=$(echo "$ISSUES_JSON" | jq -r '.[] | select(.labels | map(.name) | index("backlog")) | .number')
-BLOCKER_NUMS=""
-for BNUM in $BACKLOG_ISSUES; do
- BBODY=$(echo "$ISSUES_JSON" | jq -r --arg n "$BNUM" '.[] | select(.number == ($n | tonumber)) | .body // ""')
- # Extract deps from ## Dependencies / ## Depends on / ## Blocked by
- IN_SECTION=false
- while IFS= read -r line; do
- if echo "$line" | grep -qiP '^##?\s*(Dependencies|Depends on|Blocked by)'; then IN_SECTION=true; continue; fi
- if echo "$line" | grep -qP '^##?\s' && [ "$IN_SECTION" = true ]; then IN_SECTION=false; fi
- if [ "$IN_SECTION" = true ]; then
- for dep in $(echo "$line" | grep -oP '#\d+' | grep -oP '\d+'); do
- [ "$dep" = "$BNUM" ] && continue
- # Check if dep is open but NOT backlog-labeled
- DEP_STATE=$(echo "$ISSUES_JSON" | jq -r --arg n "$dep" '.[] | select(.number == ($n | tonumber)) | .state' 2>/dev/null || true)
- DEP_LABELS=$(echo "$ISSUES_JSON" | jq -r --arg n "$dep" '.[] | select(.number == ($n | tonumber)) | [.labels[].name] | join(",")' 2>/dev/null || true)
- if [ "$DEP_STATE" = "open" ] && ! echo ",$DEP_LABELS," | grep -q ',backlog,'; then
- BLOCKER_NUMS="${BLOCKER_NUMS} ${dep}"
- fi
- done
- fi
- done <<< "$BBODY"
-done
-# Deduplicate blockers
-BLOCKER_NUMS=$(echo "$BLOCKER_NUMS" | tr ' ' '\n' | sort -un | head -10)
-if [ -n "$BLOCKER_NUMS" ]; then
- BLOCKER_LIST=""
- for bnum in $BLOCKER_NUMS; do
- BTITLE=$(echo "$ISSUES_JSON" | jq -r --arg n "$bnum" '.[] | select(.number == ($n | tonumber)) | .title' 2>/dev/null || true)
- BLABELS=$(echo "$ISSUES_JSON" | jq -r --arg n "$bnum" '.[] | select(.number == ($n | tonumber)) | [.labels[].name] | join(",")' 2>/dev/null || true)
- BLOCKER_LIST="${BLOCKER_LIST}#${bnum} [${BLABELS:-unlabeled}] ${BTITLE}\n"
- done
- PROBLEMS="${PROBLEMS}PRIORITY_blockers_starving_factory: these issues block backlog items but are NOT labeled backlog — promote them FIRST:\n${BLOCKER_LIST}\n"
-fi
-
-# 6. Tech-debt issues needing promotion to backlog (secondary to blockers)
-TECH_DEBT_ISSUES=$(echo "$ISSUES_JSON" | jq -r '.[] | select(.labels | map(.name) | index("tech-debt")) | "#\(.number) \(.title)"')
-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 processing (goal: zero tech-debt):\n$(echo "$TECH_DEBT_ISSUES" | head -50)\n"
-fi
-
-PROBLEM_COUNT=$(echo -e "$PROBLEMS" | grep -c '.' || true)
-log "Detected $PROBLEM_COUNT potential problems"
-
-if [ "$PROBLEM_COUNT" -eq 0 ] && [ -z "$ESCALATION_REPLY" ]; then
- log "Backlog is clean — nothing to groom"
- exit 0
-fi
-
-# ── Load formula ─────────────────────────────────────────────────────────
-log "Loading groom-backlog formula"
-FORMULA_FILE="$FACTORY_ROOT/formulas/groom-backlog.toml"
-if [ ! -f "$FORMULA_FILE" ]; then
- log "ERROR: formula not found: $FORMULA_FILE"
- exit 1
-fi
-FORMULA_CONTENT=$(cat "$FORMULA_FILE")
-
-# ── Read context files from project root ──────────────────────────────────
-CONTEXT_BLOCK=""
-for ctx in README.md AGENTS.md VISION.md; do
- ctx_path="${PROJECT_REPO_ROOT}/${ctx}"
- if [ -f "$ctx_path" ]; then
- CONTEXT_BLOCK="${CONTEXT_BLOCK}
-### ${ctx}
-$(cat "$ctx_path")
-"
- fi
-done
-
-# ── Build issue context ────────────────────────────────────────────────────
-ISSUE_SUMMARY=$(echo "$ISSUES_JSON" | jq -r '.[] | "#\(.number) [\(.labels | map(.name) | join(","))] \(.title)"')
-
-# ── Build optional prompt sections ────────────────────────────────────────
-CONTEXT_SECTION=""
-if [ -n "$CONTEXT_BLOCK" ]; then
- CONTEXT_SECTION="## Project context
-${CONTEXT_BLOCK}"
-fi
-
-ESCALATION_SECTION=""
-if [ -n "$ESCALATION_REPLY" ]; then
- ESCALATION_SECTION="
-### Human response to previous escalation
-Format: '1a 2c 3b' means question 1→option (a), 2→option (c), 3→option (b).
-Execute each chosen option via the Codeberg API FIRST, before processing new items.
-If a choice is unclear, re-escalate that single item with a clarifying question.
-
-${ESCALATION_REPLY}"
-fi
-
-# ── Read scratch file (compaction survival) ───────────────────────────────
-SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
-SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
-
-# ── Build prompt from formula + dynamic context ────────────────────────────
-log "Building gardener prompt from formula"
-
-PROMPT="You are the issue gardener for ${CODEBERG_REPO}. Work through the formula below. You MUST write PHASE:done to '${PHASE_FILE}' when finished — the orchestrator will time you out if you return to the prompt without signalling.
-
-${CONTEXT_SECTION}
-${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
-}## Formula
-${FORMULA_CONTENT}
-
-## Runtime context (bash pre-analysis)
-### All open issues
-${ISSUE_SUMMARY}
-
-### Problems detected
-$(echo -e "$PROBLEMS")${ESCALATION_SECTION}
-## Codeberg API reference
-Base URL: ${CODEBERG_API}
-Auth header: -H \"Authorization: token \$CODEBERG_TOKEN\"
- 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'
-NEVER echo or include the actual token value in output — always reference \$CODEBERG_TOKEN.
-
-## Output format (MANDATORY — write each line to result file using bash)
- echo \"ACTION: description of what you did\" >> '${RESULT_FILE}'
- echo 'DUST: {\"issue\": NNN, \"group\": \"...\", \"title\": \"...\", \"reason\": \"...\"}' >> '${RESULT_FILE}'
- printf 'ESCALATE\n1. #NNN \"title\" — reason (a) option1 (b) option2\n' >> '${RESULT_FILE}'
- echo 'CLEAN' >> '${RESULT_FILE}' # only if truly nothing to do
-
-${SCRATCH_INSTRUCTION}
-
-## Phase protocol (REQUIRED)
-When all work is done and verify confirms zero tech-debt:
- echo 'PHASE:done' > '${PHASE_FILE}'
-On unrecoverable error:
- printf 'PHASE:failed\nReason: %s\n' 'describe error' > '${PHASE_FILE}'"
-
-# ── Reset phase + result files ────────────────────────────────────────────
-agent_kill_session "$SESSION_NAME"
-rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$RESULT_FILE"
-touch "$RESULT_FILE"
-
-# ── Create tmux session ───────────────────────────────────────────────────
-log "Creating tmux session: ${SESSION_NAME}"
-if ! create_agent_session "$SESSION_NAME" "$PROJECT_REPO_ROOT" "$PHASE_FILE"; then
- log "ERROR: failed to create tmux session ${SESSION_NAME}"
- exit 1
-fi
-
-# Write phase protocol to context file for compaction survival
-write_compact_context "$PHASE_FILE" "## Phase protocol (REQUIRED)
-When all work is done and verify confirms zero tech-debt:
- echo 'PHASE:done' > '${PHASE_FILE}'
-On unrecoverable error:
- printf 'PHASE:failed\nReason: %s\n' 'describe error' > '${PHASE_FILE}'"
-
-agent_inject_into_session "$SESSION_NAME" "$PROMPT"
-log "Prompt sent to tmux session"
-matrix_send "gardener" "🌱 Gardener session started for ${CODEBERG_REPO}" 2>/dev/null || true
-
-# ── Phase monitoring loop ─────────────────────────────────────────────────
-log "Monitoring phase file: ${PHASE_FILE}"
-_FORMULA_CRASH_COUNT=0
-
-gardener_phase_callback() {
- # Gardener-specific cleanup before shared crash recovery
- if [ "$1" = "PHASE:crashed" ]; then
- rm -f "$RESULT_FILE"
- touch "$RESULT_FILE"
- fi
- formula_phase_callback "$1"
-}
-
-monitor_phase_loop "$PHASE_FILE" 7200 "gardener_phase_callback"
-
-FINAL_PHASE=$(read_phase)
-log "Final phase: ${FINAL_PHASE:-none}"
-
-if [ "$FINAL_PHASE" != "PHASE:done" ]; then
- case "${_MONITOR_LOOP_EXIT:-}" in
- idle_prompt)
- log "gardener-agent: Claude returned to prompt without writing phase signal — no phase file written"
- ;;
- idle_timeout)
- log "gardener-agent: timed out after 2h with no phase signal"
- ;;
- *)
- log "gardener-agent finished without PHASE:done (phase: ${FINAL_PHASE:-none}, exit: ${_MONITOR_LOOP_EXIT:-})"
- ;;
- esac
- exit 0
-fi
-
-log "claude finished — parsing result file"
-
-# ── Parse result file ─────────────────────────────────────────────────────
-CLAUDE_OUTPUT=""
-if [ -s "$RESULT_FILE" ]; then
- CLAUDE_OUTPUT=$(cat "$RESULT_FILE")
-fi
-
-# ── Parse escalations ─────────────────────────────────────────────────────
-ESCALATION=$(echo "$CLAUDE_OUTPUT" | awk '/^ESCALATE$/{found=1;next} found && /^(ACTION:|DUST:|CLEAN|PHASE:)/{found=0} found{print}' || true)
-if [ -z "$ESCALATION" ]; then
- ESCALATION=$(echo "$CLAUDE_OUTPUT" | grep -A50 "^ESCALATE" | grep -E '^[0-9]' || true)
-fi
-
-if [ -n "$ESCALATION" ]; then
- ITEM_COUNT=$(echo "$ESCALATION" | grep -c '.' || true)
- log "Escalating $ITEM_COUNT items to human"
-
- # Send via Matrix (threaded — replies route back via listener)
- matrix_send "gardener" "🌱 Issue Gardener — ${ITEM_COUNT} item(s) need attention
-
-${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 dust items (bundling handled by run-gardener formula step) ────
-DUST_LINES=$(echo "$CLAUDE_OUTPUT" | grep "^DUST: " || true)
-if [ -n "$DUST_LINES" ]; then
- DUST_COUNT=$(echo "$DUST_LINES" | grep -c '.' || true)
- log "Dust items reported: $DUST_COUNT (bundling handled by run-gardener formula)"
-fi
-
-# ── Cleanup scratch file on normal exit ──────────────────────────────────
-if [ "$FINAL_PHASE" = "PHASE:done" ]; then
- rm -f "$SCRATCH_FILE"
-fi
-
-log "--- gardener-agent done ---"
diff --git a/gardener/gardener-poll.sh b/gardener/gardener-poll.sh
deleted file mode 100755
index c218ce1..0000000
--- a/gardener/gardener-poll.sh
+++ /dev/null
@@ -1,138 +0,0 @@
-#!/usr/bin/env bash
-# =============================================================================
-# gardener-poll.sh — Cron wrapper for the gardener agent
-#
-# Cron: daily (or 2x/day). Handles lock management, escalation reply
-# injection, and delegates backlog grooming to gardener-agent.sh.
-#
-# Grooming (delegated to gardener-agent.sh):
-# - Duplicate titles / overlapping scope
-# - Missing acceptance criteria
-# - Stale issues (no activity > 14 days)
-# - Blockers starving the factory
-# - Tech-debt promotion / dust bundling
-# =============================================================================
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
-
-# Load shared environment (with optional project TOML override)
-# Usage: gardener-poll.sh [projects/harb.toml]
-export PROJECT_TOML="${1:-}"
-# shellcheck source=../lib/env.sh
-source "$FACTORY_ROOT/lib/env.sh"
-
-LOG_FILE="$SCRIPT_DIR/gardener.log"
-LOCK_FILE="/tmp/gardener-poll.lock"
-
-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 ---"
-
-# Gitea labels API requires []int64 — look up the "backlog" label ID once
-# Falls back to the known Codeberg repo ID if the API call fails
-BACKLOG_LABEL_ID=$(codeberg_api GET "/labels" 2>/dev/null \
- | jq -r '.[] | select(.name == "backlog") | .id' 2>/dev/null || true)
-BACKLOG_LABEL_ID="${BACKLOG_LABEL_ID:-1300815}"
-
-# ── Check for escalation replies from Matrix ──────────────────────────────
-ESCALATION_REPLY=""
-if [ -s /tmp/gardener-escalation-reply ]; then
- _raw_reply=$(cat /tmp/gardener-escalation-reply)
- rm -f /tmp/gardener-escalation-reply
- log "Got escalation reply: $(echo "$_raw_reply" | head -1)"
-
- # Filter stale escalation entries referencing already-closed issues (#289).
- # Escalation records can persist after the underlying issue resolves; acting
- # on them wastes cycles (e.g. creating investigation issues for merged PRs).
- while IFS= read -r _reply_line; do
- [ -z "$_reply_line" ] && continue
- _esc_nums=$(echo "$_reply_line" | grep -oP '#\K\d+' | sort -u || true)
- if [ -n "$_esc_nums" ]; then
- _any_open=false
- for _esc_n in $_esc_nums; do
- _esc_st=$(codeberg_api GET "/issues/${_esc_n}" 2>/dev/null \
- | jq -r '.state // "open"' 2>/dev/null || echo "open")
- if [ "$_esc_st" != "closed" ]; then
- _any_open=true
- break
- fi
- done
- if [ "$_any_open" = false ]; then
- log "Discarding stale escalation (all referenced issues closed): $(echo "$_reply_line" | head -c 120)"
- continue
- fi
- fi
- ESCALATION_REPLY="${ESCALATION_REPLY}${_reply_line}
-"
- done <<< "$_raw_reply"
-
- if [ -n "$ESCALATION_REPLY" ]; then
- log "Escalation reply after filtering: $(echo "$ESCALATION_REPLY" | grep -c '.' || echo 0) line(s)"
- else
- log "All escalation entries were stale — discarded"
- fi
-fi
-export ESCALATION_REPLY
-
-# ── Inject human replies into needs_human dev sessions (backup to supervisor) ─
-HUMAN_REPLY_FILE="/tmp/dev-escalation-reply"
-for _gr_phase_file in /tmp/dev-session-"${PROJECT_NAME}"-*.phase; do
- [ -f "$_gr_phase_file" ] || continue
- _gr_phase=$(head -1 "$_gr_phase_file" 2>/dev/null | tr -d '[:space:]' || true)
- [ "$_gr_phase" = "PHASE:needs_human" ] || continue
-
- _gr_issue=$(basename "$_gr_phase_file" .phase)
- _gr_issue="${_gr_issue#dev-session-${PROJECT_NAME}-}"
- [ -z "$_gr_issue" ] && continue
- _gr_session="dev-${PROJECT_NAME}-${_gr_issue}"
-
- tmux has-session -t "$_gr_session" 2>/dev/null || continue
-
- # Atomic claim — only take the file once we know a session needs it
- _gr_claimed="/tmp/dev-escalation-reply.gardener.$$"
- [ -s "$HUMAN_REPLY_FILE" ] && mv "$HUMAN_REPLY_FILE" "$_gr_claimed" 2>/dev/null || continue
- _gr_reply=$(cat "$_gr_claimed")
-
- _gr_inject_msg="Human reply received for issue #${_gr_issue}:
-
-${_gr_reply}
-
-Instructions:
-1. Read the human's guidance carefully.
-2. Continue your work based on their input.
-3. When done, push your changes and write the appropriate phase."
-
- _gr_tmpfile=$(mktemp /tmp/human-inject-XXXXXX)
- printf '%s' "$_gr_inject_msg" > "$_gr_tmpfile"
- tmux load-buffer -b "human-inject-${_gr_issue}" "$_gr_tmpfile" || true
- tmux paste-buffer -t "$_gr_session" -b "human-inject-${_gr_issue}" || true
- sleep 0.5
- tmux send-keys -t "$_gr_session" "" Enter || true
- tmux delete-buffer -b "human-inject-${_gr_issue}" 2>/dev/null || true
- rm -f "$_gr_tmpfile" "$_gr_claimed"
-
- rm -f "/tmp/dev-renotify-${PROJECT_NAME}-${_gr_issue}"
- log "${PROJECT_NAME}: #${_gr_issue} human reply injected into session ${_gr_session} (gardener)"
- break # only one reply to deliver
-done
-
-# ── Backlog grooming (delegated to gardener-agent.sh) ────────────────────
-log "Invoking gardener-agent.sh for backlog grooming"
-bash "$SCRIPT_DIR/gardener-agent.sh" "${1:-}" || log "WARNING: gardener-agent.sh exited with error"
-
-
-log "--- Gardener poll done ---"
diff --git a/gardener/gardener-run.sh b/gardener/gardener-run.sh
index 8ee6493..a4216a6 100755
--- a/gardener/gardener-run.sh
+++ b/gardener/gardener-run.sh
@@ -1,75 +1,103 @@
#!/usr/bin/env bash
# =============================================================================
-# gardener-run.sh — Cron wrapper: files action issue for run-gardener formula
+# gardener-run.sh — Cron wrapper: gardener execution via Claude + formula
#
-# Runs 2x/day (or on-demand). Guards against concurrent runs and low memory.
-# Files an action issue referencing formulas/run-gardener.toml; the action-agent
-# picks it up and executes the gardener steps in an interactive Claude session.
+# Runs 4x/day (or on-demand). Guards against concurrent runs and low memory.
+# Creates a tmux session with Claude (sonnet) reading formulas/run-gardener.toml.
+# No action issues — the gardener is a nervous system component, not work (AD-001).
+#
+# Usage:
+# gardener-run.sh [projects/disinto.toml] # project config (default: disinto)
+#
+# Cron: 0 0,6,12,18 * * * cd /home/debian/dark-factory && bash gardener/gardener-run.sh projects/disinto.toml
# =============================================================================
set -euo pipefail
-FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
-# Load shared environment (with optional project TOML override)
-# Usage: gardener-run.sh [projects/harb.toml]
-export PROJECT_TOML="${1:-}"
+# Accept project config from argument; default to disinto
+export PROJECT_TOML="${1:-$FACTORY_ROOT/projects/disinto.toml}"
# shellcheck source=../lib/env.sh
source "$FACTORY_ROOT/lib/env.sh"
-# shellcheck source=../lib/file-action-issue.sh
-source "$FACTORY_ROOT/lib/file-action-issue.sh"
+# shellcheck source=../lib/agent-session.sh
+source "$FACTORY_ROOT/lib/agent-session.sh"
+# shellcheck source=../lib/formula-session.sh
+source "$FACTORY_ROOT/lib/formula-session.sh"
-LOG_FILE="$FACTORY_ROOT/gardener/gardener.log"
-LOCK_FILE="/tmp/gardener-run.lock"
+LOG_FILE="$SCRIPT_DIR/gardener.log"
+# shellcheck disable=SC2034 # consumed by run_formula_and_monitor
+SESSION_NAME="gardener-${PROJECT_NAME}"
+PHASE_FILE="/tmp/gardener-session-${PROJECT_NAME}.phase"
+
+# shellcheck disable=SC2034 # read by monitor_phase_loop in lib/agent-session.sh
+PHASE_POLL_INTERVAL=15
+
+SCRATCH_FILE="/tmp/gardener-${PROJECT_NAME}-scratch.md"
+RESULT_FILE="/tmp/gardener-result-${PROJECT_NAME}.txt"
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 "run: gardener-run running (PID $LOCK_PID)"
- exit 0
- fi
- rm -f "$LOCK_FILE"
-fi
-echo $$ > "$LOCK_FILE"
-trap 'rm -f "$LOCK_FILE"' EXIT
-
-# ── Memory guard ──────────────────────────────────────────────────────────
-AVAIL_MB=$(free -m | awk '/Mem:/{print $7}')
-if [ "${AVAIL_MB:-0}" -lt 2000 ]; then
- log "run: skipping — only ${AVAIL_MB}MB available (need 2000)"
- exit 0
-fi
+# ── Guards ────────────────────────────────────────────────────────────────
+acquire_cron_lock "/tmp/gardener-run.lock"
+check_memory 2000
log "--- Gardener run start ---"
-# ── File action issue for run-gardener formula ────────────────────────────
-ISSUE_BODY="---
-formula: run-gardener
-model: opus
----
+# ── Consume escalation replies ────────────────────────────────────────────
+consume_escalation_reply "gardener"
-Periodic gardener housekeeping run. The action-agent reads \`formulas/run-gardener.toml\`
-and executes the steps: preflight, grooming, blocked-review,
-AGENTS.md update, and commit-and-pr.
+# ── Load formula + context ───────────────────────────────────────────────
+load_formula "$FACTORY_ROOT/formulas/run-gardener.toml"
+build_context_block AGENTS.md
-Filed automatically by \`gardener-run.sh\`."
+# ── Read scratch file (compaction survival) ───────────────────────────────
+SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
+SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
-_rc=0
-file_action_issue "run-gardener" "action: run-gardener — periodic housekeeping" "$ISSUE_BODY" || _rc=$?
-case "$_rc" in
- 0) ;;
- 1) log "run: open run-gardener action issue already exists — skipping"
- log "--- Gardener run done ---"
- exit 0 ;;
- 2) log "ERROR: 'action' label not found — cannot file gardener issue"
- exit 1 ;;
- *) log "ERROR: failed to create action issue for run-gardener"
- exit 1 ;;
-esac
+# ── Build prompt (gardener needs extra API endpoints for issue management) ─
+GARDENER_API_EXTRA="
+ 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\"}'
+"
+build_prompt_footer "$GARDENER_API_EXTRA"
-log "Filed action issue #${FILED_ISSUE_NUM} for run-gardener formula"
-matrix_send "gardener" "Filed action #${FILED_ISSUE_NUM}: run-gardener — periodic housekeeping" 2>/dev/null || true
+# shellcheck disable=SC2034 # consumed by run_formula_and_monitor
+PROMPT="You are the issue gardener for ${CODEBERG_REPO}. Work through the formula below. You MUST write PHASE:done to '${PHASE_FILE}' when finished — the orchestrator will time you out if you return to the prompt without signalling.
-log "--- Gardener run done ---"
+You have full shell access and --dangerously-skip-permissions.
+Fix what you can. Escalate what you cannot. Do NOT ask permission — act first, report after.
+${ESCALATION_REPLY:+
+## Escalation Reply (from Matrix — human message)
+${ESCALATION_REPLY}
+
+Act on this reply during the grooming step.
+}
+## Project context
+${CONTEXT_BLOCK}
+${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
+}
+## Result file
+Write actions and dust items to: ${RESULT_FILE}
+
+## Formula
+${FORMULA_CONTENT}
+
+${SCRATCH_INSTRUCTION}
+${PROMPT_FOOTER}"
+
+# ── Reset result file ────────────────────────────────────────────────────
+rm -f "$RESULT_FILE"
+touch "$RESULT_FILE"
+
+# ── Run session ──────────────────────────────────────────────────────────
+export CLAUDE_MODEL="sonnet"
+run_formula_and_monitor "gardener" 7200
+
+# ── Cleanup scratch file on normal exit ──────────────────────────────────
+# FINAL_PHASE already set by run_formula_and_monitor
+if [ "${FINAL_PHASE:-}" = "PHASE:done" ]; then
+ rm -f "$SCRATCH_FILE"
+fi
diff --git a/lib/AGENTS.md b/lib/AGENTS.md
index 6586590..f6d1b08 100644
--- a/lib/AGENTS.md
+++ b/lib/AGENTS.md
@@ -12,7 +12,7 @@ sourced as needed.
| `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `CODEBERG_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, Matrix config, etc.). | env.sh (when `PROJECT_TOML` is set), supervisor-poll (per-project iteration) |
| `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` patterns. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll, supervisor-poll |
| `lib/matrix_listener.sh` | Long-poll Matrix sync daemon. Dispatches thread replies to the correct agent via well-known files (`/tmp/{agent}-escalation-reply`). Handles supervisor, gardener, dev, review, vault, and action reply routing. Run as systemd service. | Standalone daemon |
-| `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `build_context_block()`, `start_formula_session()`, `formula_phase_callback()`, `build_prompt_footer()`, `run_formula_and_monitor()` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, prompt assembly, tmux session, monitor loop, crash recovery). | planner-run.sh, predictor-run.sh |
+| `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `build_context_block()`, `consume_escalation_reply()`, `start_formula_session()`, `formula_phase_callback()`, `build_prompt_footer()`, `run_formula_and_monitor()` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, prompt assembly, tmux session, monitor loop, crash recovery). | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh |
| `lib/secret-scan.sh` | `scan_for_secrets()` — detects potential secrets (API keys, bearer tokens, private keys, URLs with embedded credentials) in text; returns 1 if secrets found. `redact_secrets()` — replaces detected secret patterns with `[REDACTED]`. | file-action-issue.sh, phase-handler.sh |
-| `lib/file-action-issue.sh` | `file_action_issue()` — dedup check, secret scan, label lookup, and issue creation for formula-driven cron wrappers. Sets `FILED_ISSUE_NUM` on success. Returns 4 if secrets detected in body. | gardener-run.sh |
-| `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`, `write_compact_context()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. Also installs a SessionStart hook (matcher `compact`) that re-injects phase protocol instructions after context compaction — callers write the context file via `write_compact_context(phase_file, content)`, and the hook (`on-compact-reinject.sh`) outputs the file content to stdout so Claude retains critical instructions. When `MATRIX_THREAD_ID` is exported, also installs a Stop hook (`on-stop-matrix.sh`) that streams each Claude response to the Matrix thread. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `>` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or a `PHASE:*` string. **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh, gardener-agent.sh, action-agent.sh |
+| `lib/file-action-issue.sh` | `file_action_issue()` — dedup check, secret scan, label lookup, and issue creation for formula-driven cron wrappers. Sets `FILED_ISSUE_NUM` on success. Returns 4 if secrets detected in body. | (available for future use) |
+| `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`, `write_compact_context()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. Also installs a SessionStart hook (matcher `compact`) that re-injects phase protocol instructions after context compaction — callers write the context file via `write_compact_context(phase_file, content)`, and the hook (`on-compact-reinject.sh`) outputs the file content to stdout so Claude retains critical instructions. When `MATRIX_THREAD_ID` is exported, also installs a Stop hook (`on-stop-matrix.sh`) that streams each Claude response to the Matrix thread. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `>` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or a `PHASE:*` string. **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh, action-agent.sh |
diff --git a/lib/formula-session.sh b/lib/formula-session.sh
index 9f18c79..5940482 100644
--- a/lib/formula-session.sh
+++ b/lib/formula-session.sh
@@ -2,7 +2,7 @@
# formula-session.sh — Shared helpers for formula-driven cron agents
#
# Provides reusable functions for the common cron-wrapper + tmux-session
-# pattern used by planner-run.sh and gardener-agent.sh.
+# pattern used by planner-run.sh, predictor-run.sh, gardener-run.sh, and supervisor-run.sh.
#
# Functions:
# acquire_cron_lock LOCK_FILE — PID lock with stale cleanup
@@ -81,6 +81,25 @@ $(cat "$ctx_path")
done
}
+# ── Escalation reply consumption ─────────────────────────────────────────
+
+# consume_escalation_reply AGENT_NAME
+# Atomically consumes /tmp/{agent}-escalation-reply if it exists.
+# Sets ESCALATION_REPLY to the file contents (empty string if no reply).
+consume_escalation_reply() {
+ local agent="$1"
+ local reply_file="/tmp/${agent}-escalation-reply"
+ ESCALATION_REPLY=""
+ if [ -s "$reply_file" ]; then
+ local tmp_file="${reply_file}.consumed.$$"
+ if mv "$reply_file" "$tmp_file" 2>/dev/null; then
+ ESCALATION_REPLY=$(cat "$tmp_file")
+ rm -f "$tmp_file"
+ log "Consumed escalation reply: $(echo "$ESCALATION_REPLY" | head -1)"
+ fi
+ fi
+}
+
# ── Session management ───────────────────────────────────────────────────
# start_formula_session SESSION WORKDIR PHASE_FILE
diff --git a/supervisor/supervisor-run.sh b/supervisor/supervisor-run.sh
index 736d565..fb60f04 100755
--- a/supervisor/supervisor-run.sh
+++ b/supervisor/supervisor-run.sh
@@ -57,16 +57,7 @@ else
fi
# ── Consume escalation replies ────────────────────────────────────────────
-# Move the file atomically so matrix_listener can write a new one
-ESCALATION_REPLY=""
-if [ -s /tmp/supervisor-escalation-reply ]; then
- _reply_tmp="/tmp/supervisor-escalation-reply.consumed.$$"
- if mv /tmp/supervisor-escalation-reply "$_reply_tmp" 2>/dev/null; then
- ESCALATION_REPLY=$(cat "$_reply_tmp")
- rm -f "$_reply_tmp"
- log "Consumed escalation reply: $(echo "$ESCALATION_REPLY" | head -1)"
- fi
-fi
+consume_escalation_reply "supervisor"
# ── Load formula + context ───────────────────────────────────────────────
load_formula "$FACTORY_ROOT/formulas/run-supervisor.toml"