From de7a1d3bc64756e28f75df43f7952b5e28e7aa18 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 07:45:14 +0000 Subject: [PATCH 01/17] fix: feat: extend edge container with Playwright and docker compose for bug reproduction (#256) Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 17 + docker/edge/dispatcher.sh | 133 ++++++++ docker/reproduce/Dockerfile | 11 + docker/reproduce/entrypoint-reproduce.sh | 404 +++++++++++++++++++++++ formulas/reproduce.toml | 23 ++ 5 files changed, 588 insertions(+) create mode 100644 docker/reproduce/Dockerfile create mode 100644 docker/reproduce/entrypoint-reproduce.sh create mode 100644 formulas/reproduce.toml diff --git a/docker-compose.yml b/docker-compose.yml index 33c121e..aeec67d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,23 @@ services: depends_on: - forgejo + reproduce: + build: + context: . + dockerfile: docker/reproduce/Dockerfile + image: disinto-reproduce:latest + network_mode: host + profiles: ["reproduce"] + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - agent-data:/home/agent/data + - project-repos:/home/agent/repos + - ${HOME}/.claude:/home/agent/.claude + - /usr/local/bin/claude:/usr/local/bin/claude:ro + - ${HOME}/.ssh:/home/agent/.ssh:ro + env_file: + - .env + forgejo: image: codeberg.org/forgejo/forgejo:1 container_name: disinto-forgejo diff --git a/docker/edge/dispatcher.sh b/docker/edge/dispatcher.sh index 8b56343..932bd97 100755 --- a/docker/edge/dispatcher.sh +++ b/docker/edge/dispatcher.sh @@ -451,6 +451,129 @@ launch_runner() { return $exit_code } +# ----------------------------------------------------------------------------- +# Reproduce dispatch — launch sidecar for bug-report issues +# ----------------------------------------------------------------------------- + +# Check if a reproduce run is already in-flight for a given issue. +# Uses a simple pid-file in /tmp so we don't double-launch per dispatcher cycle. +_reproduce_lockfile() { + local issue="$1" + echo "/tmp/reproduce-inflight-${issue}.pid" +} + +is_reproduce_running() { + local issue="$1" + local pidfile + pidfile=$(_reproduce_lockfile "$issue") + [ -f "$pidfile" ] || return 1 + local pid + pid=$(cat "$pidfile" 2>/dev/null || echo "") + [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null +} + +# Fetch open issues labelled bug-report that have no outcome label yet. +# Returns a newline-separated list of "issue_number:project_toml" pairs. +fetch_reproduce_candidates() { + # Require FORGE_TOKEN, FORGE_URL, FORGE_REPO + [ -n "${FORGE_TOKEN:-}" ] || return 0 + [ -n "${FORGE_URL:-}" ] || return 0 + [ -n "${FORGE_REPO:-}" ] || return 0 + + local api="${FORGE_URL}/api/v1/repos/${FORGE_REPO}" + + local issues_json + issues_json=$(curl -sf \ + -H "Authorization: token ${FORGE_TOKEN}" \ + "${api}/issues?type=issues&state=open&labels=bug-report&limit=20" 2>/dev/null) || return 0 + + # Filter out issues that already carry an outcome label. + # Write JSON to a temp file so python3 can read from stdin (heredoc) and + # still receive the JSON as an argument (avoids SC2259: pipe vs heredoc). + local tmpjson + tmpjson=$(mktemp) + echo "$issues_json" > "$tmpjson" + python3 - "$tmpjson" <<'PYEOF' +import sys, json +data = json.load(open(sys.argv[1])) +skip = {"reproduced", "cannot-reproduce", "needs-triage"} +for issue in data: + labels = {l["name"] for l in (issue.get("labels") or [])} + if labels & skip: + continue + print(issue["number"]) +PYEOF + rm -f "$tmpjson" +} + +# Launch one reproduce container per candidate issue. +# project_toml is resolved from FACTORY_ROOT/projects/*.toml (first match). +dispatch_reproduce() { + local issue_number="$1" + + if is_reproduce_running "$issue_number"; then + log "Reproduce already running for issue #${issue_number}, skipping" + return 0 + fi + + # Find first project TOML available (same convention as dev-poll) + local project_toml="" + for toml in "${FACTORY_ROOT}"/projects/*.toml; do + [ -f "$toml" ] && { project_toml="$toml"; break; } + done + + if [ -z "$project_toml" ]; then + log "WARNING: no project TOML found under ${FACTORY_ROOT}/projects/ — skipping reproduce for #${issue_number}" + return 0 + fi + + log "Dispatching reproduce-agent for issue #${issue_number} (project: ${project_toml})" + + # Build docker run command using array (safe from injection) + local -a cmd=(docker run --rm + --name "disinto-reproduce-${issue_number}" + --network host + -v /var/run/docker.sock:/var/run/docker.sock + -v agent-data:/home/agent/data + -v project-repos:/home/agent/repos + -e "FORGE_URL=${FORGE_URL}" + -e "FORGE_TOKEN=${FORGE_TOKEN}" + -e "FORGE_REPO=${FORGE_REPO}" + -e "PRIMARY_BRANCH=${PRIMARY_BRANCH:-main}" + -e DISINTO_CONTAINER=1 + ) + + # Pass through ANTHROPIC_API_KEY if set + if [ -n "${ANTHROPIC_API_KEY:-}" ]; then + cmd+=(-e "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}") + fi + + # Mount ~/.claude and ~/.ssh from the runtime user's home if available + local runtime_home="${HOME:-/home/debian}" + if [ -d "${runtime_home}/.claude" ]; then + cmd+=(-v "${runtime_home}/.claude:/home/agent/.claude") + fi + if [ -d "${runtime_home}/.ssh" ]; then + cmd+=(-v "${runtime_home}/.ssh:/home/agent/.ssh:ro") + fi + # Mount claude CLI binary if present on host + if [ -f /usr/local/bin/claude ]; then + cmd+=(-v /usr/local/bin/claude:/usr/local/bin/claude:ro) + fi + + # Mount the project TOML into the container at a stable path + local container_toml="/home/agent/project.toml" + cmd+=(-v "${project_toml}:${container_toml}:ro") + + cmd+=(disinto-reproduce:latest "$container_toml" "$issue_number") + + # Launch in background; write pid-file so we don't double-launch + "${cmd[@]}" & + local bg_pid=$! + echo "$bg_pid" > "$(_reproduce_lockfile "$issue_number")" + log "Reproduce container launched (pid ${bg_pid}) for issue #${issue_number}" +} + # ----------------------------------------------------------------------------- # Main dispatcher loop # ----------------------------------------------------------------------------- @@ -501,6 +624,16 @@ main() { launch_runner "$toml_file" || true done + # Reproduce dispatch: check for bug-report issues needing reproduction + local candidate_issues + candidate_issues=$(fetch_reproduce_candidates) || true + if [ -n "$candidate_issues" ]; then + while IFS= read -r issue_num; do + [ -n "$issue_num" ] || continue + dispatch_reproduce "$issue_num" || true + done <<< "$candidate_issues" + fi + # Wait before next poll sleep 60 done diff --git a/docker/reproduce/Dockerfile b/docker/reproduce/Dockerfile new file mode 100644 index 0000000..3192744 --- /dev/null +++ b/docker/reproduce/Dockerfile @@ -0,0 +1,11 @@ +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash curl git jq docker.io docker-compose-plugin \ + nodejs npm chromium \ + && npm install -g @anthropic-ai/mcp-playwright \ + && rm -rf /var/lib/apt/lists/* +RUN useradd -m -u 1000 -s /bin/bash agent +COPY docker/reproduce/entrypoint-reproduce.sh /entrypoint-reproduce.sh +RUN chmod +x /entrypoint-reproduce.sh +WORKDIR /home/agent +ENTRYPOINT ["/entrypoint-reproduce.sh"] diff --git a/docker/reproduce/entrypoint-reproduce.sh b/docker/reproduce/entrypoint-reproduce.sh new file mode 100644 index 0000000..45b97d1 --- /dev/null +++ b/docker/reproduce/entrypoint-reproduce.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# entrypoint-reproduce.sh — Reproduce-agent sidecar entrypoint +# +# Acquires the stack lock, boots the project stack (if formula declares +# stack_script), then drives Claude + Playwright MCP to follow the bug +# report's repro steps. Labels the issue based on outcome and posts +# findings + screenshots. +# +# Usage (launched by dispatcher.sh): +# entrypoint-reproduce.sh +# +# Environment (injected by dispatcher via docker run -e): +# FORGE_URL, FORGE_TOKEN, FORGE_REPO, PRIMARY_BRANCH, DISINTO_CONTAINER=1 +# +# Volumes expected: +# /home/agent/data — agent-data volume (stack-lock files go here) +# /home/agent/repos — project-repos volume +# /home/agent/.claude — host ~/.claude (OAuth credentials) +# /home/agent/.ssh — host ~/.ssh (read-only) +# /usr/local/bin/claude — host claude CLI binary (read-only) +# /var/run/docker.sock — host docker socket + +set -euo pipefail + +DISINTO_DIR="${DISINTO_DIR:-/home/agent/disinto}" +REPRODUCE_FORMULA="${DISINTO_DIR}/formulas/reproduce.toml" +REPRODUCE_TIMEOUT="${REPRODUCE_TIMEOUT_MINUTES:-15}" +LOGFILE="/home/agent/data/logs/reproduce.log" +SCREENSHOT_DIR="/home/agent/data/screenshots" + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +log() { + printf '[%s] reproduce: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" | tee -a "$LOGFILE" +} + +# --------------------------------------------------------------------------- +# Argument validation +# --------------------------------------------------------------------------- +PROJECT_TOML="${1:-}" +ISSUE_NUMBER="${2:-}" + +if [ -z "$PROJECT_TOML" ] || [ -z "$ISSUE_NUMBER" ]; then + log "FATAL: usage: entrypoint-reproduce.sh " + exit 1 +fi + +if [ ! -f "$PROJECT_TOML" ]; then + log "FATAL: project TOML not found: ${PROJECT_TOML}" + exit 1 +fi + +# --------------------------------------------------------------------------- +# Bootstrap: directories, env +# --------------------------------------------------------------------------- +mkdir -p /home/agent/data/logs /home/agent/data/locks "$SCREENSHOT_DIR" + +export DISINTO_CONTAINER=1 +export HOME="${HOME:-/home/agent}" +export USER="${USER:-agent}" + +FORGE_API="${FORGE_URL}/api/v1/repos/${FORGE_REPO}" + +# Load project name from TOML +PROJECT_NAME=$(python3 -c " +import sys, tomllib +with open(sys.argv[1], 'rb') as f: + print(tomllib.load(f)['name']) +" "$PROJECT_TOML" 2>/dev/null) || { + log "FATAL: could not read project name from ${PROJECT_TOML}" + exit 1 +} +export PROJECT_NAME + +PROJECT_REPO_ROOT="/home/agent/repos/${PROJECT_NAME}" + +log "Starting reproduce-agent for issue #${ISSUE_NUMBER} (project: ${PROJECT_NAME})" + +# --------------------------------------------------------------------------- +# Verify claude CLI is available (mounted from host) +# --------------------------------------------------------------------------- +if ! command -v claude &>/dev/null; then + log "FATAL: claude CLI not found. Mount the host binary at /usr/local/bin/claude" + exit 1 +fi + +# --------------------------------------------------------------------------- +# Source stack-lock library +# --------------------------------------------------------------------------- +# shellcheck source=/home/agent/disinto/lib/stack-lock.sh +source "${DISINTO_DIR}/lib/stack-lock.sh" + +LOCK_HOLDER="reproduce-agent-${ISSUE_NUMBER}" + +# --------------------------------------------------------------------------- +# Read formula config +# --------------------------------------------------------------------------- +FORMULA_STACK_SCRIPT="" +FORMULA_TIMEOUT_MINUTES="${REPRODUCE_TIMEOUT}" + +if [ -f "$REPRODUCE_FORMULA" ]; then + FORMULA_STACK_SCRIPT=$(python3 -c " +import sys, tomllib +with open(sys.argv[1], 'rb') as f: + d = tomllib.load(f) +print(d.get('stack_script', '')) +" "$REPRODUCE_FORMULA" 2>/dev/null || echo "") + + _tm=$(python3 -c " +import sys, tomllib +with open(sys.argv[1], 'rb') as f: + d = tomllib.load(f) +print(d.get('timeout_minutes', '${REPRODUCE_TIMEOUT}')) +" "$REPRODUCE_FORMULA" 2>/dev/null || echo "${REPRODUCE_TIMEOUT}") + FORMULA_TIMEOUT_MINUTES="$_tm" +fi + +log "Formula stack_script: '${FORMULA_STACK_SCRIPT}'" +log "Formula timeout: ${FORMULA_TIMEOUT_MINUTES}m" + +# --------------------------------------------------------------------------- +# Fetch issue details for repro steps +# --------------------------------------------------------------------------- +log "Fetching issue #${ISSUE_NUMBER} from ${FORGE_API}..." +ISSUE_JSON=$(curl -sf \ + -H "Authorization: token ${FORGE_TOKEN}" \ + "${FORGE_API}/issues/${ISSUE_NUMBER}" 2>/dev/null) || { + log "ERROR: failed to fetch issue #${ISSUE_NUMBER}" + exit 1 +} + +ISSUE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.title // "unknown"') +ISSUE_BODY=$(echo "$ISSUE_JSON" | jq -r '.body // ""') + +log "Issue: ${ISSUE_TITLE}" + +# --------------------------------------------------------------------------- +# Acquire stack lock +# --------------------------------------------------------------------------- +log "Acquiring stack lock for project ${PROJECT_NAME}..." +stack_lock_acquire "$LOCK_HOLDER" "$PROJECT_NAME" 900 +trap 'stack_lock_release "$PROJECT_NAME" "$LOCK_HOLDER"; log "Stack lock released (trap)"' EXIT +log "Stack lock acquired." + +# --------------------------------------------------------------------------- +# Start heartbeat in background (every 2 minutes) +# --------------------------------------------------------------------------- +heartbeat_loop() { + while true; do + sleep 120 + stack_lock_heartbeat "$LOCK_HOLDER" "$PROJECT_NAME" 2>/dev/null || true + done +} +heartbeat_loop & +HEARTBEAT_PID=$! +trap 'kill "$HEARTBEAT_PID" 2>/dev/null; stack_lock_release "$PROJECT_NAME" "$LOCK_HOLDER"; log "Stack lock released (trap)"' EXIT + +# --------------------------------------------------------------------------- +# Boot the project stack if formula declares stack_script +# --------------------------------------------------------------------------- +if [ -n "$FORMULA_STACK_SCRIPT" ] && [ -d "$PROJECT_REPO_ROOT" ]; then + log "Running stack_script: ${FORMULA_STACK_SCRIPT}" + # Run in project repo root; script path is relative to project repo. + # Read stack_script into array to allow arguments (e.g. "scripts/dev.sh restart --full"). + read -ra _stack_cmd <<< "$FORMULA_STACK_SCRIPT" + (cd "$PROJECT_REPO_ROOT" && bash "${_stack_cmd[@]}") || { + log "WARNING: stack_script exited non-zero — continuing anyway" + } + # Give the stack a moment to stabilise + sleep 5 +elif [ -n "$FORMULA_STACK_SCRIPT" ]; then + log "WARNING: PROJECT_REPO_ROOT not found at ${PROJECT_REPO_ROOT} — skipping stack_script" +fi + +# --------------------------------------------------------------------------- +# Build Claude prompt for reproduction +# --------------------------------------------------------------------------- +TIMESTAMP=$(date -u '+%Y%m%d-%H%M%S') +SCREENSHOT_PREFIX="${SCREENSHOT_DIR}/issue-${ISSUE_NUMBER}-${TIMESTAMP}" + +CLAUDE_PROMPT=$(cat < + - INCONCLUSIVE: Write OUTCOME=needs-triage + +5. **Write findings** — Write a markdown report to: /tmp/reproduce-findings-${ISSUE_NUMBER}.md + Include: + - Steps you followed + - What you observed (screenshots referenced by path) + - Log excerpts (truncated to relevant lines) + - OUTCOME line (one of: reproduced, cannot-reproduce, needs-triage) + - ROOT_CAUSE line (if outcome is reproduced) + +6. **Write outcome file** — Write ONLY the outcome word to: /tmp/reproduce-outcome-${ISSUE_NUMBER}.txt + (one of: reproduced, cannot-reproduce, needs-triage) + +## Notes +- The application is accessible at localhost (network_mode: host) +- Take screenshots liberally — they are evidence +- If the app is not running or not reachable, write outcome: cannot-reproduce with reason "stack not reachable" +- Timeout: ${FORMULA_TIMEOUT_MINUTES} minutes total + +Begin now. +PROMPT +) + +# --------------------------------------------------------------------------- +# Run Claude with Playwright MCP +# --------------------------------------------------------------------------- +log "Starting Claude reproduction session (timeout: ${FORMULA_TIMEOUT_MINUTES}m)..." + +CLAUDE_EXIT=0 +timeout "$(( FORMULA_TIMEOUT_MINUTES * 60 ))" \ + claude -p "$CLAUDE_PROMPT" \ + --mcp-server playwright \ + --output-format text \ + --max-turns 40 \ + > "/tmp/reproduce-claude-output-${ISSUE_NUMBER}.txt" 2>&1 || CLAUDE_EXIT=$? + +if [ $CLAUDE_EXIT -eq 124 ]; then + log "WARNING: Claude session timed out after ${FORMULA_TIMEOUT_MINUTES}m" +fi + +# --------------------------------------------------------------------------- +# Read outcome +# --------------------------------------------------------------------------- +OUTCOME="needs-triage" +if [ -f "/tmp/reproduce-outcome-${ISSUE_NUMBER}.txt" ]; then + _raw=$(tr -d '[:space:]' < "/tmp/reproduce-outcome-${ISSUE_NUMBER}.txt" | tr '[:upper:]' '[:lower:]') + case "$_raw" in + reproduced|cannot-reproduce|needs-triage) + OUTCOME="$_raw" + ;; + *) + log "WARNING: unexpected outcome '${_raw}' — defaulting to needs-triage" + ;; + esac +else + log "WARNING: outcome file not found — defaulting to needs-triage" +fi + +log "Outcome: ${OUTCOME}" + +# --------------------------------------------------------------------------- +# Read findings +# --------------------------------------------------------------------------- +FINDINGS="" +if [ -f "/tmp/reproduce-findings-${ISSUE_NUMBER}.md" ]; then + FINDINGS=$(cat "/tmp/reproduce-findings-${ISSUE_NUMBER}.md") +else + FINDINGS="Reproduce-agent completed but did not write a findings report. Claude output:\n\`\`\`\n$(tail -100 "/tmp/reproduce-claude-output-${ISSUE_NUMBER}.txt" 2>/dev/null || echo '(no output)')\n\`\`\`" +fi + +# --------------------------------------------------------------------------- +# Collect screenshot paths for comment +# --------------------------------------------------------------------------- +SCREENSHOT_LIST="" +if find "$(dirname "${SCREENSHOT_PREFIX}")" -name "$(basename "${SCREENSHOT_PREFIX}")-*.png" -maxdepth 1 2>/dev/null | grep -q .; then + SCREENSHOT_LIST="\n\n**Screenshots taken:**\n" + for f in "${SCREENSHOT_PREFIX}"-*.png; do + SCREENSHOT_LIST="${SCREENSHOT_LIST}- \`$(basename "$f")\`\n" + done +fi + +# --------------------------------------------------------------------------- +# Label helpers +# --------------------------------------------------------------------------- +_label_id() { + local name="$1" color="$2" + local id + id=$(curl -sf \ + -H "Authorization: token ${FORGE_TOKEN}" \ + "${FORGE_API}/labels" 2>/dev/null \ + | jq -r --arg n "$name" '.[] | select(.name == $n) | .id' 2>/dev/null || echo "") + if [ -z "$id" ]; then + id=$(curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${FORGE_API}/labels" \ + -d "{\"name\":\"${name}\",\"color\":\"${color}\"}" 2>/dev/null \ + | jq -r '.id // empty' 2>/dev/null || echo "") + fi + echo "$id" +} + +_add_label() { + local issue="$1" label_id="$2" + [ -z "$label_id" ] && return 0 + curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${FORGE_API}/issues/${issue}/labels" \ + -d "{\"labels\":[${label_id}]}" >/dev/null 2>&1 || true +} + +_remove_label() { + local issue="$1" label_id="$2" + [ -z "$label_id" ] && return 0 + curl -sf -X DELETE \ + -H "Authorization: token ${FORGE_TOKEN}" \ + "${FORGE_API}/issues/${issue}/labels/${label_id}" >/dev/null 2>&1 || true +} + +_post_comment() { + local issue="$1" body="$2" + curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${FORGE_API}/issues/${issue}/comments" \ + -d "$(jq -nc --arg b "$body" '{body:$b}')" >/dev/null 2>&1 || true +} + +# --------------------------------------------------------------------------- +# Apply labels and post findings +# --------------------------------------------------------------------------- + +# Remove bug-report label (we are resolving it) +BUG_REPORT_ID=$(_label_id "bug-report" "#e4e669") +_remove_label "$ISSUE_NUMBER" "$BUG_REPORT_ID" + +case "$OUTCOME" in + reproduced) + LABEL_NAME="reproduced" + LABEL_COLOR="#0075ca" + COMMENT_HEADER="## Reproduce-agent: **Reproduced** :white_check_mark:" + + # Create a backlog issue for the triage/dev agents + ROOT_CAUSE=$(grep -m1 "^ROOT_CAUSE=" "/tmp/reproduce-findings-${ISSUE_NUMBER}.md" 2>/dev/null \ + | sed 's/^ROOT_CAUSE=//' || echo "See findings on issue #${ISSUE_NUMBER}") + BACKLOG_BODY="## Summary +Bug reproduced from issue #${ISSUE_NUMBER}: ${ISSUE_TITLE} + +Root cause (quick log analysis): ${ROOT_CAUSE} + +## Dependencies +- #${ISSUE_NUMBER} + +## Affected files +- (see findings on issue #${ISSUE_NUMBER}) + +## Acceptance criteria +- [ ] Root cause confirmed and fixed +- [ ] Issue #${ISSUE_NUMBER} no longer reproducible" + + log "Creating backlog issue for reproduced bug..." + curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${FORGE_API}/issues" \ + -d "$(jq -nc \ + --arg t "fix: $(echo "$ISSUE_TITLE" | sed 's/^bug:/fix:/' | sed 's/^feat:/fix:/')" \ + --arg b "$BACKLOG_BODY" \ + '{title:$t, body:$b}')" >/dev/null 2>&1 || \ + log "WARNING: failed to create backlog issue" + ;; + + cannot-reproduce) + LABEL_NAME="cannot-reproduce" + LABEL_COLOR="#e4e669" + COMMENT_HEADER="## Reproduce-agent: **Cannot reproduce** :x:" + ;; + + needs-triage) + LABEL_NAME="needs-triage" + LABEL_COLOR="#d93f0b" + COMMENT_HEADER="## Reproduce-agent: **Needs triage** :mag:" + ;; +esac + +OUTCOME_LABEL_ID=$(_label_id "$LABEL_NAME" "$LABEL_COLOR") +_add_label "$ISSUE_NUMBER" "$OUTCOME_LABEL_ID" +log "Applied label '${LABEL_NAME}' to issue #${ISSUE_NUMBER}" + +COMMENT_BODY="${COMMENT_HEADER} + +${FINDINGS}${SCREENSHOT_LIST} + +--- +*Reproduce-agent run at $(date -u '+%Y-%m-%d %H:%M:%S UTC') — project: ${PROJECT_NAME}*" + +_post_comment "$ISSUE_NUMBER" "$COMMENT_BODY" +log "Posted findings to issue #${ISSUE_NUMBER}" + +log "Reproduce-agent done. Outcome: ${OUTCOME}" diff --git a/formulas/reproduce.toml b/formulas/reproduce.toml new file mode 100644 index 0000000..e68009d --- /dev/null +++ b/formulas/reproduce.toml @@ -0,0 +1,23 @@ +# formulas/reproduce.toml — Reproduce-agent formula +# +# Declares the reproduce-agent's runtime parameters. +# The dispatcher reads this to configure the sidecar container. +# +# stack_script: path (relative to PROJECT_REPO_ROOT) of the script used to +# restart/rebuild the project stack before reproduction. Omit (or leave +# blank) to connect to an existing staging environment instead. +# +# tools: MCP servers to pass to claude via --mcp-server flags. +# +# timeout_minutes: hard upper bound on the Claude session. + +name = "reproduce" +description = "Navigate the app via Playwright, reproduce a bug-report issue, and do a quick log-based root cause check" +version = 1 + +# Set stack_script to the restart command for local stacks. +# Leave empty ("") to target an existing staging environment. +stack_script = "" + +tools = ["playwright"] +timeout_minutes = 15 -- 2.49.1 From 5faa86956b9a4185572048673cbeac299d98cc79 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 08:00:55 +0000 Subject: [PATCH 02/17] fix: fix: disinto init can produce duplicate keys in projects/*.toml (#269) Export actual_ops_slug from setup_ops_repo via _ACTUAL_OPS_SLUG global, then update ops_repo in the TOML in-place using Python re.sub after TOML creation or detection. Falls back to inserting after the repo line if the key is missing. This prevents duplicate TOML keys on repeated init runs. Co-Authored-By: Claude Sonnet 4.6 --- bin/disinto | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/bin/disinto b/bin/disinto index 4d27e38..ece30cb 100755 --- a/bin/disinto +++ b/bin/disinto @@ -1216,6 +1216,9 @@ OPSEOF fi fi fi + + # Export resolved slug for the caller to write back to the project TOML + _ACTUAL_OPS_SLUG="${actual_ops_slug}" } # Push local clone to the Forgejo remote. @@ -2145,6 +2148,24 @@ p.write_text(text) echo "Created: ${toml_path}" fi + # Update ops_repo in TOML with the resolved actual ops slug. + # Uses in-place substitution to prevent duplicate keys on repeated init runs. + # If the key is missing (manually created TOML), it is inserted after the repo line. + if [ -n "${_ACTUAL_OPS_SLUG:-}" ] && [ -f "$toml_path" ]; then + python3 -c " +import sys, re, pathlib +p = pathlib.Path(sys.argv[1]) +text = p.read_text() +new_val = 'ops_repo = \"' + sys.argv[2] + '\"' +if re.search(r'^ops_repo\s*=', text, re.MULTILINE): + text = re.sub(r'^ops_repo\s*=\s*.*\$', new_val, text, flags=re.MULTILINE) +else: + text = re.sub(r'^(repo\s*=\s*\"[^\"]*\")', r'\1\n' + new_val, text, flags=re.MULTILINE) +p.write_text(text) +" "$toml_path" "${_ACTUAL_OPS_SLUG}" + echo "Updated: ops_repo in ${toml_path}" + fi + # Create OAuth2 app on Forgejo for Woodpecker (before compose up) _WP_REPO_ID="" create_woodpecker_oauth "$forge_url" "$forge_repo" -- 2.49.1 From cff90e927d1514939998971c6d9a3b49f064312c Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 6 Apr 2026 08:19:54 +0000 Subject: [PATCH 03/17] fix: fix: disinto init change-password triggers must_change_password despite --must-change-password=false (#267) --- bin/disinto | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/bin/disinto b/bin/disinto index ece30cb..bff0810 100755 --- a/bin/disinto +++ b/bin/disinto @@ -708,11 +708,15 @@ setup_forge() { fi else echo "Admin user: ${admin_user} (already exists)" - # Reset password to the persisted value so basic-auth works (#158) - _forgejo_exec forgejo admin user change-password \ - --username "${admin_user}" \ - --password "${admin_pass}" \ - --must-change-password=false + # Only reset password if basic auth fails (#158, #267) + # Forgejo 11.x may ignore --must-change-password=false, blocking token creation + if ! curl -sf --max-time 5 -u "${admin_user}:${admin_pass}" \ + "${forge_url}/api/v1/user" >/dev/null 2>&1; then + _forgejo_exec forgejo admin user change-password \ + --username "${admin_user}" \ + --password "${admin_pass}" \ + --must-change-password=false + fi fi # Preserve password for Woodpecker OAuth2 token generation (#779) _FORGE_ADMIN_PASS="$admin_pass" -- 2.49.1 From 44b645ee0002b687f2f2dbc87cc2296925565b8d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 08:19:58 +0000 Subject: [PATCH 04/17] =?UTF-8?q?fix:=20fix:=20disinto=20init=20fails=20on?= =?UTF-8?q?=20re-run=20=E2=80=94=20admin=20token=20name=20collision=20(#26?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete any existing token with the same name before creating a fresh one, so that sha1 is always returned by the create response. The list API does not return sha1 (Forgejo redacts it for security), making the old fallback unreliable. Co-Authored-By: Claude Sonnet 4.6 --- bin/disinto | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/bin/disinto b/bin/disinto index bff0810..0939d1c 100755 --- a/bin/disinto +++ b/bin/disinto @@ -756,7 +756,19 @@ setup_forge() { echo "Human user: ${human_user} (already exists)" fi - # Get or create admin token + # Delete existing admin token if present (token sha1 is only returned at creation time) + local existing_token_id + existing_token_id=$(curl -sf \ + -u "${admin_user}:${admin_pass}" \ + "${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \ + | jq -r '.[] | select(.name == "disinto-admin-token") | .id') || existing_token_id="" + if [ -n "$existing_token_id" ]; then + curl -sf -X DELETE \ + -u "${admin_user}:${admin_pass}" \ + "${forge_url}/api/v1/users/${admin_user}/tokens/${existing_token_id}" >/dev/null 2>&1 || true + fi + + # Create admin token (fresh, so sha1 is returned) local admin_token admin_token=$(curl -sf -X POST \ -u "${admin_user}:${admin_pass}" \ @@ -765,14 +777,6 @@ setup_forge() { -d '{"name":"disinto-admin-token","scopes":["all"]}' 2>/dev/null \ | jq -r '.sha1 // empty') || admin_token="" - if [ -z "$admin_token" ]; then - # Token might already exist — try listing - admin_token=$(curl -sf \ - -u "${admin_user}:${admin_pass}" \ - "${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \ - | jq -r '.[0].sha1 // empty') || admin_token="" - fi - if [ -z "$admin_token" ]; then echo "Error: failed to obtain admin API token" >&2 exit 1 -- 2.49.1 From 59b10e27de59a7c61bedafae0db351bf4b9eb306 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 6 Apr 2026 08:29:46 +0000 Subject: [PATCH 05/17] fix: feat: add triage workflow labels (needs-triage, reproduced, cannot-reproduce) to disinto init (#268) --- bin/disinto | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/disinto b/bin/disinto index 0939d1c..3d0dd9e 100755 --- a/bin/disinto +++ b/bin/disinto @@ -1468,6 +1468,9 @@ create_labels() { ["prediction/dismissed"]="#d73a4a" ["prediction/actioned"]="#28a745" ["bug-report"]="#e11d48" + ["needs-triage"]="#f9d0c4" + ["reproduced"]="#0e8a16" + ["cannot-reproduce"]="#cccccc" ) echo "Creating labels on ${repo}..." @@ -1481,7 +1484,7 @@ create_labels() { local name color local created=0 skipped=0 failed=0 - for name in backlog in-progress blocked tech-debt underspecified vision action bug-report prediction/unreviewed prediction/dismissed prediction/actioned; do + for name in backlog in-progress blocked tech-debt underspecified vision action bug-report prediction/unreviewed prediction/dismissed prediction/actioned needs-triage reproduced cannot-reproduce; do if echo "$existing" | grep -qx "$name"; then echo " . ${name} (already exists)" skipped=$((skipped + 1)) -- 2.49.1 From 6181e4349aba666400792af6c8c3390ec3e3172b Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 6 Apr 2026 09:26:18 +0000 Subject: [PATCH 06/17] fix: fix: cron agents (gardener, planner, architect, predictor) never set FORGE_REMOTE (#278) --- architect/architect-run.sh | 3 +++ gardener/gardener-run.sh | 11 +++++++---- lib/formula-session.sh | 25 ++++++++++++++++++++++--- planner/planner-run.sh | 3 +++ predictor/predictor-run.sh | 3 +++ supervisor/supervisor-run.sh | 3 +++ 6 files changed, 41 insertions(+), 7 deletions(-) diff --git a/architect/architect-run.sh b/architect/architect-run.sh index d2ecc3b..6052d0b 100755 --- a/architect/architect-run.sh +++ b/architect/architect-run.sh @@ -53,6 +53,9 @@ check_memory 2000 log "--- Architect run start ---" +# ── Resolve forge remote for git operations ───────────────────────────── +resolve_forge_remote + # ── Resolve agent identity for .profile repo ──────────────────────────── if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_ARCHITECT_TOKEN:-}" ]; then AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_ARCHITECT_TOKEN}" \ diff --git a/gardener/gardener-run.sh b/gardener/gardener-run.sh index dba1875..abaf0a0 100755 --- a/gardener/gardener-run.sh +++ b/gardener/gardener-run.sh @@ -64,6 +64,9 @@ check_memory 2000 log "--- Gardener run start ---" +# ── Resolve forge remote for git operations ───────────────────────────── +resolve_forge_remote + # ── Resolve agent identity for .profile repo ──────────────────────────── if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_GARDENER_TOKEN:-}" ]; then AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_GARDENER_TOKEN}" \ @@ -128,9 +131,9 @@ ${PROMPT_FOOTER}" # ── Create worktree ────────────────────────────────────────────────────── cd "$PROJECT_REPO_ROOT" -git fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true +git fetch "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true worktree_cleanup "$WORKTREE" -git worktree add "$WORKTREE" "origin/${PRIMARY_BRANCH}" --detach 2>/dev/null +git worktree add "$WORKTREE" "${FORGE_REMOTE}/${PRIMARY_BRANCH}" --detach 2>/dev/null cleanup() { worktree_cleanup "$WORKTREE" @@ -328,9 +331,9 @@ if [ -n "$PR_NUMBER" ]; then if [ "$_PR_WALK_EXIT_REASON" = "merged" ]; then # Post-merge: pull primary, mirror push, execute manifest - git -C "$PROJECT_REPO_ROOT" fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true + git -C "$PROJECT_REPO_ROOT" fetch "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true git -C "$PROJECT_REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true - git -C "$PROJECT_REPO_ROOT" pull --ff-only origin "$PRIMARY_BRANCH" 2>/dev/null || true + git -C "$PROJECT_REPO_ROOT" pull --ff-only "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true mirror_push _gardener_execute_manifest rm -f "$SCRATCH_FILE" diff --git a/lib/formula-session.sh b/lib/formula-session.sh index 8c228b0..264c8e1 100644 --- a/lib/formula-session.sh +++ b/lib/formula-session.sh @@ -91,6 +91,24 @@ resolve_agent_identity() { return 0 } +# ── Forge remote resolution ────────────────────────────────────────────── + +# resolve_forge_remote +# Resolves FORGE_REMOTE by matching FORGE_URL hostname against git remotes. +# Falls back to "origin" if no match found. +# Requires: FORGE_URL, git repo with remotes configured. +# Exports: FORGE_REMOTE (always set). +resolve_forge_remote() { + # Extract hostname from FORGE_URL (e.g., https://codeberg.org/user/repo -> codeberg.org) + _forge_host=$(printf '%s' "$FORGE_URL" | sed 's|https\?://||; s|/.*||; s|:.*||') + # Find git remote whose push URL matches the forge host + FORGE_REMOTE=$(git remote -v | awk -v host="$_forge_host" '$2 ~ host && /\(push\)/ {print $1; exit}') + # Fallback to origin if no match found + FORGE_REMOTE="${FORGE_REMOTE:-origin}" + export FORGE_REMOTE + log "forge remote: ${FORGE_REMOTE}" +} + # ── .profile repo management ────────────────────────────────────────────── # ensure_profile_repo [AGENT_IDENTITY] @@ -711,13 +729,14 @@ build_sdk_prompt_footer() { # Creates an isolated worktree for synchronous formula execution. # Fetches primary branch, cleans stale worktree, creates new one, and # sets an EXIT trap for cleanup. -# Requires globals: PROJECT_REPO_ROOT, PRIMARY_BRANCH. +# Requires globals: PROJECT_REPO_ROOT, PRIMARY_BRANCH, FORGE_REMOTE. +# Ensure resolve_forge_remote() is called before this function. formula_worktree_setup() { local worktree="$1" cd "$PROJECT_REPO_ROOT" || return - git fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true + git fetch "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true worktree_cleanup "$worktree" - git worktree add "$worktree" "origin/${PRIMARY_BRANCH}" --detach 2>/dev/null + git worktree add "$worktree" "${FORGE_REMOTE}/${PRIMARY_BRANCH}" --detach 2>/dev/null # shellcheck disable=SC2064 # expand worktree now, not at trap time trap "worktree_cleanup '$worktree'" EXIT } diff --git a/planner/planner-run.sh b/planner/planner-run.sh index 663703c..4cc3800 100755 --- a/planner/planner-run.sh +++ b/planner/planner-run.sh @@ -52,6 +52,9 @@ check_memory 2000 log "--- Planner run start ---" +# ── Resolve forge remote for git operations ───────────────────────────── +resolve_forge_remote + # ── Resolve agent identity for .profile repo ──────────────────────────── if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_PLANNER_TOKEN:-}" ]; then AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_PLANNER_TOKEN}" \ diff --git a/predictor/predictor-run.sh b/predictor/predictor-run.sh index 266829c..0fdc8fa 100755 --- a/predictor/predictor-run.sh +++ b/predictor/predictor-run.sh @@ -53,6 +53,9 @@ check_memory 2000 log "--- Predictor run start ---" +# ── Resolve forge remote for git operations ───────────────────────────── +resolve_forge_remote + # ── Resolve agent identity for .profile repo ──────────────────────────── if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_PREDICTOR_TOKEN:-}" ]; then AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_PREDICTOR_TOKEN}" \ diff --git a/supervisor/supervisor-run.sh b/supervisor/supervisor-run.sh index 4ba6ec3..57a3f95 100755 --- a/supervisor/supervisor-run.sh +++ b/supervisor/supervisor-run.sh @@ -55,6 +55,9 @@ check_memory 2000 log "--- Supervisor run start ---" +# ── Resolve forge remote for git operations ───────────────────────────── +resolve_forge_remote + # ── Housekeeping: clean up stale crashed worktrees (>24h) ──────────────── cleanup_stale_crashed_worktrees 24 -- 2.49.1 From ea8050d5cf3d05193f0c402252e91b3b9c49b759 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 09:24:47 +0000 Subject: [PATCH 07/17] fix: fix: agent_run swallows all Claude failures silently via || true (#277) Capture exit code from claude invocations instead of suppressing with || true. Log timeout (rc=124) and non-zero exits distinctly. Skip nudge when output is empty (claude crashed or failed). Log empty output as a clear diagnostic message. Co-Authored-By: Claude Sonnet 4.6 --- lib/agent-sdk.sh | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/agent-sdk.sh b/lib/agent-sdk.sh index 4816ab8..1180982 100644 --- a/lib/agent-sdk.sh +++ b/lib/agent-sdk.sh @@ -48,9 +48,17 @@ agent_run() { local run_dir="${worktree_dir:-$(pwd)}" local lock_file="${HOME}/.claude/session.lock" mkdir -p "$(dirname "$lock_file")" - local output + local output rc log "agent_run: starting (resume=${resume_id:-(new)}, dir=${run_dir})" - output=$(cd "$run_dir" && flock -w 600 "$lock_file" timeout "${CLAUDE_TIMEOUT:-7200}" claude "${args[@]}" 2>>"$LOGFILE") || true + output=$(cd "$run_dir" && flock -w 600 "$lock_file" timeout "${CLAUDE_TIMEOUT:-7200}" claude "${args[@]}" 2>>"$LOGFILE") && rc=0 || rc=$? + if [ "$rc" -eq 124 ]; then + log "agent_run: timeout after ${CLAUDE_TIMEOUT:-7200}s" + elif [ "$rc" -ne 0 ]; then + log "agent_run: claude exited with code $rc" + fi + if [ -z "$output" ]; then + log "agent_run: empty output (claude may have crashed or failed)" + fi # Extract and persist session_id local new_sid @@ -68,7 +76,7 @@ agent_run() { # Nudge: if the model stopped without pushing, resume with encouragement. # Some models emit end_turn prematurely when confused. A nudge often unsticks them. - if [ -n "$_AGENT_SESSION_ID" ]; then + if [ -n "$_AGENT_SESSION_ID" ] && [ -n "$output" ]; then local has_changes has_changes=$(cd "$run_dir" && git status --porcelain 2>/dev/null | head -1) || true local has_pushed @@ -78,7 +86,13 @@ agent_run() { # Nudge: there are uncommitted changes local nudge="You stopped but did not push any code. You have uncommitted changes. Commit them and push." log "agent_run: nudging (uncommitted changes)" - output=$(cd "$run_dir" && flock -w 600 "$lock_file" timeout "${CLAUDE_TIMEOUT:-7200}" claude -p "$nudge" --resume "$_AGENT_SESSION_ID" --output-format json --dangerously-skip-permissions --max-turns 50 ${CLAUDE_MODEL:+--model "$CLAUDE_MODEL"} 2>>"$LOGFILE") || true + local nudge_rc + output=$(cd "$run_dir" && flock -w 600 "$lock_file" timeout "${CLAUDE_TIMEOUT:-7200}" claude -p "$nudge" --resume "$_AGENT_SESSION_ID" --output-format json --dangerously-skip-permissions --max-turns 50 ${CLAUDE_MODEL:+--model "$CLAUDE_MODEL"} 2>>"$LOGFILE") && nudge_rc=0 || nudge_rc=$? + if [ "$nudge_rc" -eq 124 ]; then + log "agent_run: nudge timeout after ${CLAUDE_TIMEOUT:-7200}s" + elif [ "$nudge_rc" -ne 0 ]; then + log "agent_run: nudge claude exited with code $nudge_rc" + fi new_sid=$(printf '%s' "$output" | jq -r '.session_id // empty' 2>/dev/null) || true if [ -n "$new_sid" ]; then _AGENT_SESSION_ID="$new_sid" -- 2.49.1 From 67d11631cc7d016676dbdf1bc0994d113674a435 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 6 Apr 2026 09:36:14 +0000 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20fix:=20duplicated=20memory=20guard?= =?UTF-8?q?=20=E2=80=94=20memory=5Fguard()=20in=20env.sh=20vs=20check=5Fme?= =?UTF-8?q?mory()=20in=20formula-session.sh=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove check_memory() from lib/formula-session.sh and update all *-run.sh scripts to use memory_guard() from lib/env.sh. Changes: - lib/formula-session.sh: Removed check_memory() function and its documentation - gardener/gardener-run.sh: Replaced check_memory(2000) with memory_guard(2000) - planner/planner-run.sh: Replaced check_memory(2000) with memory_guard(2000) - architect/architect-run.sh: Replaced check_memory(2000) with memory_guard(2000) - predictor/predictor-run.sh: Replaced check_memory(2000) with memory_guard(2000) - supervisor/supervisor-run.sh: Replaced check_memory(2000) with memory_guard(2000) Benefits: - Only one memory check function exists now - All agents use the same function - No dependency on free command - uses /proc/meminfo which is more portable --- architect/architect-run.sh | 2 +- gardener/gardener-run.sh | 2 +- lib/formula-session.sh | 24 +++--------------------- planner/planner-run.sh | 2 +- predictor/predictor-run.sh | 2 +- supervisor/supervisor-run.sh | 2 +- 6 files changed, 8 insertions(+), 26 deletions(-) diff --git a/architect/architect-run.sh b/architect/architect-run.sh index 6052d0b..18de885 100755 --- a/architect/architect-run.sh +++ b/architect/architect-run.sh @@ -49,7 +49,7 @@ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; } # ── Guards ──────────────────────────────────────────────────────────────── check_active architect acquire_cron_lock "/tmp/architect-run.lock" -check_memory 2000 +memory_guard 2000 log "--- Architect run start ---" diff --git a/gardener/gardener-run.sh b/gardener/gardener-run.sh index abaf0a0..00ea611 100755 --- a/gardener/gardener-run.sh +++ b/gardener/gardener-run.sh @@ -60,7 +60,7 @@ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; } # ── Guards ──────────────────────────────────────────────────────────────── check_active gardener acquire_cron_lock "/tmp/gardener-run.lock" -check_memory 2000 +memory_guard 2000 log "--- Gardener run start ---" diff --git a/lib/formula-session.sh b/lib/formula-session.sh index 264c8e1..d1830be 100644 --- a/lib/formula-session.sh +++ b/lib/formula-session.sh @@ -6,7 +6,6 @@ # # Functions: # acquire_cron_lock LOCK_FILE — PID lock with stale cleanup -# check_memory [MIN_MB] — skip if available RAM too low # load_formula FORMULA_FILE — sets FORMULA_CONTENT # build_context_block FILE [FILE ...] — sets CONTEXT_BLOCK # build_prompt_footer [EXTRA_API_LINES] — sets PROMPT_FOOTER (API ref + env) @@ -51,23 +50,6 @@ acquire_cron_lock() { trap 'rm -f "$_CRON_LOCK_FILE"' EXIT } -# check_memory [MIN_MB] -# Exits 0 (skip) if available memory is below MIN_MB (default 2000). -check_memory() { - local min_mb="${1:-2000}" - # Graceful fallback if free command is not available (procps not installed) - if ! command -v free &>/dev/null; then - log "run: free not found, skipping memory check" - return 0 - fi - local avail_mb - avail_mb=$(free -m | awk '/Mem:/{print $7}') - if [ "${avail_mb:-0}" -lt "$min_mb" ]; then - log "run: skipping — only ${avail_mb}MB available (need ${min_mb})" - exit 0 - fi -} - # ── Agent identity resolution ──────────────────────────────────────────── # resolve_agent_identity @@ -168,7 +150,7 @@ ensure_profile_repo() { # Checks if the agent has a .profile repo by querying Forgejo API. # Returns 0 if repo exists, 1 otherwise. _profile_has_repo() { - local agent_identity="${1:-${AGENT_IDENTITY:-}}" + local agent_identity="${AGENT_IDENTITY:-}" if [ -z "$agent_identity" ]; then if ! resolve_agent_identity; then @@ -204,8 +186,8 @@ _count_undigested_journals() { # Runs a claude -p one-shot to digest undigested journals into lessons-learned.md # Returns 0 on success, 1 on failure. _profile_digest_journals() { - local agent_identity="${1:-${AGENT_IDENTITY:-}}" - local model="${2:-${CLAUDE_MODEL:-opus}}" + local agent_identity="${AGENT_IDENTITY:-}" + local model="${CLAUDE_MODEL:-opus}" if [ -z "$agent_identity" ]; then if ! resolve_agent_identity; then diff --git a/planner/planner-run.sh b/planner/planner-run.sh index 4cc3800..47e057f 100755 --- a/planner/planner-run.sh +++ b/planner/planner-run.sh @@ -48,7 +48,7 @@ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; } # ── Guards ──────────────────────────────────────────────────────────────── check_active planner acquire_cron_lock "/tmp/planner-run.lock" -check_memory 2000 +memory_guard 2000 log "--- Planner run start ---" diff --git a/predictor/predictor-run.sh b/predictor/predictor-run.sh index 0fdc8fa..b76ae64 100755 --- a/predictor/predictor-run.sh +++ b/predictor/predictor-run.sh @@ -49,7 +49,7 @@ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; } # ── Guards ──────────────────────────────────────────────────────────────── check_active predictor acquire_cron_lock "/tmp/predictor-run.lock" -check_memory 2000 +memory_guard 2000 log "--- Predictor run start ---" diff --git a/supervisor/supervisor-run.sh b/supervisor/supervisor-run.sh index 57a3f95..907c228 100755 --- a/supervisor/supervisor-run.sh +++ b/supervisor/supervisor-run.sh @@ -51,7 +51,7 @@ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; } # ── Guards ──────────────────────────────────────────────────────────────── check_active supervisor acquire_cron_lock "/tmp/supervisor-run.lock" -check_memory 2000 +memory_guard 2000 log "--- Supervisor run start ---" -- 2.49.1 From d1b9d54693b8fef48dfaaa1be32bb3ddd8095252 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 09:55:07 +0000 Subject: [PATCH 09/17] =?UTF-8?q?fix:=20fix:=20agent=20identity=20resoluti?= =?UTF-8?q?on=20copy-pasted=205=20times=20=E2=80=94=20use=20resolve=5Fagen?= =?UTF-8?q?t=5Fidentity()=20(#280)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- gardener/gardener-run.sh | 5 +---- planner/planner-run.sh | 5 +---- predictor/predictor-run.sh | 5 +---- review/review-pr.sh | 5 +---- supervisor/supervisor-run.sh | 5 +---- 5 files changed, 5 insertions(+), 20 deletions(-) diff --git a/gardener/gardener-run.sh b/gardener/gardener-run.sh index 00ea611..ac659e3 100755 --- a/gardener/gardener-run.sh +++ b/gardener/gardener-run.sh @@ -68,10 +68,7 @@ log "--- Gardener run start ---" resolve_forge_remote # ── Resolve agent identity for .profile repo ──────────────────────────── -if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_GARDENER_TOKEN:-}" ]; then - AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_GARDENER_TOKEN}" \ - "${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true) -fi +resolve_agent_identity || true # ── Load formula + context ─────────────────────────────────────────────── load_formula_or_profile "gardener" "$FACTORY_ROOT/formulas/run-gardener.toml" || exit 1 diff --git a/planner/planner-run.sh b/planner/planner-run.sh index 47e057f..2bbfab8 100755 --- a/planner/planner-run.sh +++ b/planner/planner-run.sh @@ -56,10 +56,7 @@ log "--- Planner run start ---" resolve_forge_remote # ── Resolve agent identity for .profile repo ──────────────────────────── -if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_PLANNER_TOKEN:-}" ]; then - AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_PLANNER_TOKEN}" \ - "${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true) -fi +resolve_agent_identity || true # ── Load formula + context ─────────────────────────────────────────────── load_formula_or_profile "planner" "$FACTORY_ROOT/formulas/run-planner.toml" || exit 1 diff --git a/predictor/predictor-run.sh b/predictor/predictor-run.sh index b76ae64..f87001b 100755 --- a/predictor/predictor-run.sh +++ b/predictor/predictor-run.sh @@ -57,10 +57,7 @@ log "--- Predictor run start ---" resolve_forge_remote # ── Resolve agent identity for .profile repo ──────────────────────────── -if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_PREDICTOR_TOKEN:-}" ]; then - AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_PREDICTOR_TOKEN}" \ - "${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true) -fi +resolve_agent_identity || true # ── Load formula + context ─────────────────────────────────────────────── load_formula_or_profile "predictor" "$FACTORY_ROOT/formulas/run-predictor.toml" || exit 1 diff --git a/review/review-pr.sh b/review/review-pr.sh index 8a9a29d..63784dd 100755 --- a/review/review-pr.sh +++ b/review/review-pr.sh @@ -61,10 +61,7 @@ fi # ============================================================================= # RESOLVE AGENT IDENTITY FOR .PROFILE REPO # ============================================================================= -if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_TOKEN:-}" ]; then - AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true) -fi +resolve_agent_identity || true # ============================================================================= # MEMORY GUARD diff --git a/supervisor/supervisor-run.sh b/supervisor/supervisor-run.sh index 907c228..d911385 100755 --- a/supervisor/supervisor-run.sh +++ b/supervisor/supervisor-run.sh @@ -62,10 +62,7 @@ resolve_forge_remote cleanup_stale_crashed_worktrees 24 # ── Resolve agent identity for .profile repo ──────────────────────────── -if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_SUPERVISOR_TOKEN:-}" ]; then - AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_SUPERVISOR_TOKEN}" \ - "${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true) -fi +resolve_agent_identity || true # ── Collect pre-flight metrics ──────────────────────────────────────────── log "Running preflight.sh" -- 2.49.1 From 175b0b9510b60867a0b3a0db184360eed78e9ad2 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 6 Apr 2026 09:54:53 +0000 Subject: [PATCH 10/17] fix: fix: gardener-run.sh uses manual worktree setup instead of formula_worktree_setup() (#281) --- gardener/gardener-run.sh | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/gardener/gardener-run.sh b/gardener/gardener-run.sh index ac659e3..3b29987 100755 --- a/gardener/gardener-run.sh +++ b/gardener/gardener-run.sh @@ -127,16 +127,7 @@ ${SCRATCH_INSTRUCTION} ${PROMPT_FOOTER}" # ── Create worktree ────────────────────────────────────────────────────── -cd "$PROJECT_REPO_ROOT" -git fetch "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true -worktree_cleanup "$WORKTREE" -git worktree add "$WORKTREE" "${FORGE_REMOTE}/${PRIMARY_BRANCH}" --detach 2>/dev/null - -cleanup() { - worktree_cleanup "$WORKTREE" - rm -f "$GARDENER_PR_FILE" -} -trap cleanup EXIT +formula_worktree_setup "$WORKTREE" # ── Post-merge manifest execution ──────────────────────────────────────── # Reads gardener/pending-actions.json and executes each action via API. -- 2.49.1 From 332f7c158c10f19f0c65392063adfbf58573a833 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 10:05:04 +0000 Subject: [PATCH 11/17] =?UTF-8?q?fix:=20fix:=20duplicated=20label=20ID=20l?= =?UTF-8?q?ookup=20=E2=80=94=20ensure=5Fblocked=5Flabel=5Fid=20vs=20=5Filc?= =?UTF-8?q?=5Fensure=5Flabel=5Fid=20(#282)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove ensure_blocked_label_id() from ci-helpers.sh; _ilc_ensure_label_id() in issue-lifecycle.sh is the canonical, general implementation. Update the stale comment that referenced the removed function. Co-Authored-By: Claude Sonnet 4.6 --- lib/ci-helpers.sh | 21 --------------------- lib/issue-lifecycle.sh | 1 - 2 files changed, 22 deletions(-) diff --git a/lib/ci-helpers.sh b/lib/ci-helpers.sh index 42f306e..11c668e 100644 --- a/lib/ci-helpers.sh +++ b/lib/ci-helpers.sh @@ -7,27 +7,6 @@ set -euo pipefail # ci_commit_status() / ci_pipeline_number() require: woodpecker_api(), forge_api() (from env.sh) # classify_pipeline_failure() requires: woodpecker_api() (defined in env.sh) -# ensure_blocked_label_id — look up (or create) the "blocked" label, print its ID. -# Caches the result in _BLOCKED_LABEL_ID to avoid repeated API calls. -# Requires: FORGE_TOKEN, FORGE_API (from env.sh), forge_api() -ensure_blocked_label_id() { - if [ -n "${_BLOCKED_LABEL_ID:-}" ]; then - printf '%s' "$_BLOCKED_LABEL_ID" - return 0 - fi - _BLOCKED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \ - | jq -r '.[] | select(.name == "blocked") | .id' 2>/dev/null || true) - if [ -z "$_BLOCKED_LABEL_ID" ]; then - _BLOCKED_LABEL_ID=$(curl -sf -X POST \ - -H "Authorization: token ${FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${FORGE_API}/labels" \ - -d '{"name":"blocked","color":"#e11d48"}' 2>/dev/null \ - | jq -r '.id // empty' 2>/dev/null || true) - fi - printf '%s' "$_BLOCKED_LABEL_ID" -} - # ensure_priority_label — look up (or create) the "priority" label, print its ID. # Caches the result in _PRIORITY_LABEL_ID to avoid repeated API calls. # Requires: FORGE_TOKEN, FORGE_API (from env.sh), forge_api() diff --git a/lib/issue-lifecycle.sh b/lib/issue-lifecycle.sh index 6b14090..3792d3f 100644 --- a/lib/issue-lifecycle.sh +++ b/lib/issue-lifecycle.sh @@ -43,7 +43,6 @@ _ilc_log() { # --------------------------------------------------------------------------- # Label ID caching — lookup once per name, cache in globals. -# Pattern follows ci-helpers.sh (ensure_blocked_label_id). # --------------------------------------------------------------------------- declare -A _ILC_LABEL_IDS _ILC_LABEL_IDS["backlog"]="" -- 2.49.1 From 7a9ebade64c162c11edaf6b0c2cd3bb9e6a28db6 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 6 Apr 2026 10:24:18 +0000 Subject: [PATCH 12/17] =?UTF-8?q?fix:=20chore:=20remove=20dead=20lib=20fil?= =?UTF-8?q?es=20=E2=80=94=20profile.sh,=20file-action-issue.sh,=20CODEBERG?= =?UTF-8?q?=5F*=20exports=20(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/env.sh | 25 ++--- lib/file-action-issue.sh | 59 ----------- lib/load-project.sh | 5 - lib/profile.sh | 210 --------------------------------------- 4 files changed, 7 insertions(+), 292 deletions(-) delete mode 100644 lib/file-action-issue.sh delete mode 100644 lib/profile.sh diff --git a/lib/env.sh b/lib/env.sh index 0eab2c9..95803f5 100755 --- a/lib/env.sh +++ b/lib/env.sh @@ -77,16 +77,11 @@ if [ -n "${PROJECT_TOML:-}" ] && [ -f "$PROJECT_TOML" ]; then source "${FACTORY_ROOT}/lib/load-project.sh" "$PROJECT_TOML" fi -# Forge token: new FORGE_TOKEN > legacy CODEBERG_TOKEN -if [ -z "${FORGE_TOKEN:-}" ]; then - FORGE_TOKEN="${CODEBERG_TOKEN:-}" -fi -export FORGE_TOKEN -export CODEBERG_TOKEN="${FORGE_TOKEN}" # backwards compat +# Forge token +export FORGE_TOKEN="${FORGE_TOKEN:-}" -# Review bot token: FORGE_REVIEW_TOKEN > legacy REVIEW_BOT_TOKEN +# Review bot token export FORGE_REVIEW_TOKEN="${FORGE_REVIEW_TOKEN:-${REVIEW_BOT_TOKEN:-}}" -export REVIEW_BOT_TOKEN="${FORGE_REVIEW_TOKEN}" # backwards compat # Per-agent tokens (#747): each agent gets its own Forgejo identity. # Falls back to FORGE_TOKEN for backwards compat with single-token setups. @@ -97,18 +92,14 @@ export FORGE_SUPERVISOR_TOKEN="${FORGE_SUPERVISOR_TOKEN:-${FORGE_TOKEN}}" export FORGE_PREDICTOR_TOKEN="${FORGE_PREDICTOR_TOKEN:-${FORGE_TOKEN}}" export FORGE_ARCHITECT_TOKEN="${FORGE_ARCHITECT_TOKEN:-${FORGE_TOKEN}}" -# Bot usernames filter: FORGE_BOT_USERNAMES > legacy CODEBERG_BOT_USERNAMES -export FORGE_BOT_USERNAMES="${FORGE_BOT_USERNAMES:-${CODEBERG_BOT_USERNAMES:-dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,supervisor-bot,predictor-bot,architect-bot}}" -export CODEBERG_BOT_USERNAMES="${FORGE_BOT_USERNAMES}" # backwards compat +# Bot usernames filter +export FORGE_BOT_USERNAMES="${FORGE_BOT_USERNAMES:-dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,supervisor-bot,predictor-bot,architect-bot}" -# Project config (FORGE_* preferred, CODEBERG_* fallback) -export FORGE_REPO="${FORGE_REPO:-${CODEBERG_REPO:-}}" -export CODEBERG_REPO="${FORGE_REPO}" # backwards compat +# Project config +export FORGE_REPO="${FORGE_REPO:-}" export FORGE_URL="${FORGE_URL:-http://localhost:3000}" export FORGE_API="${FORGE_API:-${FORGE_URL}/api/v1/repos/${FORGE_REPO}}" export FORGE_WEB="${FORGE_WEB:-${FORGE_URL}/${FORGE_REPO}}" -export CODEBERG_API="${FORGE_API}" # backwards compat -export CODEBERG_WEB="${FORGE_WEB}" # backwards compat # tea CLI login name: derived from FORGE_URL (codeberg vs local forgejo) if [ -z "${TEA_LOGIN:-}" ]; then case "${FORGE_URL}" in @@ -209,8 +200,6 @@ forge_api() { -H "Content-Type: application/json" \ "${FORGE_API}${path}" "$@" } -# Backwards-compat alias -codeberg_api() { forge_api "$@"; } # Paginate a Forge API GET endpoint and return all items as a merged JSON array. # Usage: forge_api_all /path (no existing query params) diff --git a/lib/file-action-issue.sh b/lib/file-action-issue.sh deleted file mode 100644 index abba4c8..0000000 --- a/lib/file-action-issue.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -# file-action-issue.sh — File an action issue for a formula run -# -# Usage: source this file, then call file_action_issue. -# Requires: forge_api() from lib/env.sh, jq, lib/secret-scan.sh -# -# file_action_issue <body> -# Sets FILED_ISSUE_NUM on success. -# Returns: 0=created, 1=duplicate exists, 2=label not found, 3=API error, 4=secrets detected - -# Load secret scanner -# shellcheck source=secret-scan.sh -source "$(dirname "${BASH_SOURCE[0]}")/secret-scan.sh" - -file_action_issue() { - local formula_name="$1" title="$2" body="$3" - FILED_ISSUE_NUM="" - - # Secret scan: reject issue bodies containing embedded secrets - if ! scan_for_secrets "$body"; then - echo "file-action-issue: BLOCKED — issue body for '${formula_name}' contains potential secrets. Use env var references instead." >&2 - return 4 - fi - - # Dedup: skip if an open action issue for this formula already exists - local open_actions - open_actions=$(forge_api_all "/issues?state=open&type=issues&labels=action" 2>/dev/null || true) - if [ -n "$open_actions" ] && [ "$open_actions" != "null" ]; then - local existing - existing=$(printf '%s' "$open_actions" | \ - jq --arg f "$formula_name" '[.[] | select(.title | test($f))] | length' 2>/dev/null || echo 0) - if [ "${existing:-0}" -gt 0 ]; then - return 1 - fi - fi - - # Fetch 'action' label ID - local action_label_id - action_label_id=$(forge_api GET "/labels" 2>/dev/null | \ - jq -r '.[] | select(.name == "action") | .id' 2>/dev/null || true) - if [ -z "$action_label_id" ]; then - return 2 - fi - - # Create the issue - local payload result - payload=$(jq -nc \ - --arg title "$title" \ - --arg body "$body" \ - --argjson labels "[$action_label_id]" \ - '{title: $title, body: $body, labels: $labels}') - - result=$(forge_api POST "/issues" -d "$payload" 2>/dev/null || true) - FILED_ISSUE_NUM=$(printf '%s' "$result" | jq -r '.number // empty' 2>/dev/null || true) - - if [ -z "$FILED_ISSUE_NUM" ]; then - return 3 - fi -} diff --git a/lib/load-project.sh b/lib/load-project.sh index 95d3480..9d7afaf 100755 --- a/lib/load-project.sh +++ b/lib/load-project.sh @@ -10,7 +10,6 @@ # PROJECT_CONTAINERS, CHECK_PRS, CHECK_DEV_AGENT, # CHECK_PIPELINE_STALL, CI_STALE_MINUTES, # MIRROR_NAMES, MIRROR_URLS, MIRROR_<NAME> (per configured mirror) -# (plus backwards-compat aliases: CODEBERG_REPO, CODEBERG_API, CODEBERG_WEB) # # If no argument given, does nothing (allows poll scripts to work with # plain .env fallback for backwards compatibility). @@ -103,10 +102,6 @@ if [ -n "$FORGE_REPO" ]; then # Extract repo owner (first path segment of owner/repo) export FORGE_REPO_OWNER="${FORGE_REPO%%/*}" fi -# Backwards-compat aliases -export CODEBERG_REPO="${FORGE_REPO}" -export CODEBERG_API="${FORGE_API:-}" -export CODEBERG_WEB="${FORGE_WEB:-}" # Derive PROJECT_REPO_ROOT if not explicitly set if [ -z "${PROJECT_REPO_ROOT:-}" ] && [ -n "${PROJECT_NAME:-}" ]; then diff --git a/lib/profile.sh b/lib/profile.sh deleted file mode 100644 index 79f8514..0000000 --- a/lib/profile.sh +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env bash -# profile.sh — Helpers for agent .profile repo management -# -# Source after lib/env.sh and lib/formula-session.sh: -# source "$(dirname "$0")/../lib/env.sh" -# source "$(dirname "$0")/lib/formula-session.sh" -# source "$(dirname "$0")/lib/profile.sh" -# -# Required globals: FORGE_TOKEN, FORGE_URL, AGENT_IDENTITY, PROFILE_REPO_PATH -# -# Functions: -# profile_propose_formula NEW_FORMULA CONTENT REASON — create PR to update formula.toml - -set -euo pipefail - -# Internal log helper -_profile_log() { - if declare -f log >/dev/null 2>&1; then - log "profile: $*" - else - printf '[%s] profile: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2 - fi -} - -# ----------------------------------------------------------------------------- -# profile_propose_formula — Propose a formula change via PR -# -# Creates a branch, writes updated formula.toml, opens a PR, and returns PR number. -# Branch is protected (requires admin approval per #87). -# -# Args: -# $1 - NEW_FORMULA_CONTENT: The complete new formula.toml content -# $2 - REASON: Human-readable explanation of what changed and why -# -# Returns: -# 0 on success, prints PR number to stdout -# 1 on failure -# -# Example: -# source "$(dirname "$0")/../lib/env.sh" -# source "$(dirname "$0")/lib/formula-session.sh" -# source "$(dirname "$0")/lib/profile.sh" -# AGENT_IDENTITY="dev-bot" -# ensure_profile_repo "$AGENT_IDENTITY" -# profile_propose_formula "$new_formula" "Added new prompt pattern for code review" -# ----------------------------------------------------------------------------- -profile_propose_formula() { - local new_formula="$1" - local reason="$2" - - if [ -z "${AGENT_IDENTITY:-}" ]; then - _profile_log "ERROR: AGENT_IDENTITY not set" - return 1 - fi - - if [ -z "${PROFILE_REPO_PATH:-}" ]; then - _profile_log "ERROR: PROFILE_REPO_PATH not set — ensure_profile_repo not called" - return 1 - fi - - if [ -z "${FORGE_TOKEN:-}" ]; then - _profile_log "ERROR: FORGE_TOKEN not set" - return 1 - fi - - if [ -z "${FORGE_URL:-}" ]; then - _profile_log "ERROR: FORGE_URL not set" - return 1 - fi - - # Generate short description from reason for branch name - local short_desc - short_desc=$(printf '%s' "$reason" | \ - tr '[:upper:]' '[:lower:]' | \ - sed 's/[^a-z0-9 ]//g' | \ - sed 's/ */ /g' | \ - sed 's/^ *//;s/ *$//' | \ - cut -c1-40 | \ - tr ' ' '-') - - if [ -z "$short_desc" ]; then - short_desc="formula-update" - fi - - local branch_name="formula/${short_desc}" - local formula_path="${PROFILE_REPO_PATH}/formula.toml" - - _profile_log "Proposing formula change: ${branch_name}" - _profile_log "Reason: ${reason}" - - # Ensure we're on main branch and up-to-date - _profile_log "Fetching .profile repo" - ( - cd "$PROFILE_REPO_PATH" || return 1 - - git fetch origin main --quiet 2>/dev/null || \ - git fetch origin master --quiet 2>/dev/null || true - - # Reset to main/master - if git checkout main --quiet 2>/dev/null; then - git pull --ff-only origin main --quiet 2>/dev/null || true - elif git checkout master --quiet 2>/dev/null; then - git pull --ff-only origin master --quiet 2>/dev/null || true - else - _profile_log "ERROR: Failed to checkout main/master branch" - return 1 - fi - - # Create and checkout new branch - git checkout -b "$branch_name" 2>/dev/null || { - _profile_log "Branch ${branch_name} may already exist" - git checkout "$branch_name" 2>/dev/null || return 1 - } - - # Write formula.toml - printf '%s' "$new_formula" > "$formula_path" - - # Commit the change - git config user.name "${AGENT_IDENTITY}" || true - git config user.email "${AGENT_IDENTITY}@users.noreply.codeberg.org" || true - - git add "$formula_path" - git commit -m "formula: ${reason}" --no-verify || { - _profile_log "No changes to commit (formula unchanged)" - # Check if branch has any commits - if git rev-parse HEAD >/dev/null 2>&1; then - : # branch has commits, continue - else - _profile_log "ERROR: Failed to create commit" - return 1 - fi - } - - # Push branch - local remote="${FORGE_REMOTE:-origin}" - git push --set-upstream "$remote" "$branch_name" --quiet 2>/dev/null || { - _profile_log "ERROR: Failed to push branch" - return 1 - } - - _profile_log "Branch pushed: ${branch_name}" - - # Create PR - local forge_url="${FORGE_URL%/}" - local api_url="${forge_url}/api/v1/repos/${AGENT_IDENTITY}/.profile" - local primary_branch="main" - - # Check if main or master is the primary branch - if ! curl -sf -o /dev/null -w "%{http_code}" \ - -H "Authorization: token ${FORGE_TOKEN}" \ - "${api_url}/git/branches/main" 2>/dev/null | grep -q "200"; then - primary_branch="master" - fi - - local pr_title="formula: ${reason}" - local pr_body="# Formula Update - -**Reason:** ${reason} - ---- -*This PR was auto-generated by ${AGENT_IDENTITY}.* -" - - local pr_response http_code - local pr_json - pr_json=$(jq -n \ - --arg t "$pr_title" \ - --arg b "$pr_body" \ - --arg h "$branch_name" \ - --arg base "$primary_branch" \ - '{title:$t, body:$b, head:$h, base:$base}') || { - _profile_log "ERROR: Failed to build PR JSON" - return 1 - } - - pr_response=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Authorization: token ${FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${api_url}/pulls" \ - -d "$pr_json" || true) - - http_code=$(printf '%s\n' "$pr_response" | tail -1) - pr_response=$(printf '%s\n' "$pr_response" | sed '$d') - - if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then - local pr_num - pr_num=$(printf '%s' "$pr_response" | jq -r '.number') - _profile_log "PR created: #${pr_num}" - printf '%s' "$pr_num" - return 0 - else - # Check if PR already exists (409 conflict) - if [ "$http_code" = "409" ]; then - local existing_pr - existing_pr=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${api_url}/pulls?state=open&head=${AGENT_IDENTITY}:formula/${short_desc}" 2>/dev/null | \ - jq -r '.[0].number // empty') || true - if [ -n "$existing_pr" ]; then - _profile_log "PR already exists: #${existing_pr}" - printf '%s' "$existing_pr" - return 0 - fi - fi - _profile_log "ERROR: Failed to create PR (HTTP ${http_code})" - return 1 - fi - ) - - return $? -} -- 2.49.1 From 8bc216a27e709872ca453354689f3fc946d86dbe Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 6 Apr 2026 10:35:01 +0000 Subject: [PATCH 13/17] fix: feat: gardener should enrich bug-report issues with context, reproduction plan, and verification checklist (#285) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- formulas/run-gardener.toml | 46 +++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/formulas/run-gardener.toml b/formulas/run-gardener.toml index 4a92d61..185e7fb 100644 --- a/formulas/run-gardener.toml +++ b/formulas/run-gardener.toml @@ -86,9 +86,49 @@ Pre-checks (bash, zero tokens — detect problems before invoking Claude): reproduce" heading, or clear sequence of actions that trigger the bug) c. Issue is not already labeled - If all criteria match, write an add_label action to the manifest: - echo '{"action":"add_label","issue":NNN,"label":"bug-report"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl" - echo "ACTION: labeled #NNN as bug-report — <reason>" >> "$RESULT_FILE" + If all criteria match, enrich the issue body and write the manifest actions: + + Body enrichment (CRITICAL — turns raw reports into actionable investigation briefs): + Before writing the add_label action, construct an enriched body by appending + these sections to the original issue body: + + a. ``## What was reported`` + One or two sentence summary of the user's claim. Distill the broken + behavior concisely — what the user expected vs. what actually happened. + + b. ``## Known context`` + What can be inferred from the codebase without running anything: + - Which contracts/components/files are involved (use AGENTS.md layout + and file paths mentioned in the issue or body) + - What the expected behavior should be (from VISION.md, docs, code) + - Any recent changes to involved components: + git log --oneline -5 -- <paths> + - Related issues or prior fixes (cross-reference by number if known) + + c. ``## Reproduction plan`` + Concrete steps for a reproduce-agent or human. Be specific: + - Which environment to use (e.g. "start fresh stack with + \`./scripts/dev.sh restart --full\`") + - Which transactions or actions to execute (with \`cast\` commands, + API calls, or UI navigation steps where applicable) + - What state to check after each step (contract reads, API queries, + UI observations, log output) + + d. ``## What needs verification`` + Checkboxes distinguishing known facts from unknowns: + - ``- [ ]`` Does the reported behavior actually occur? (reproduce) + - ``- [ ]`` Is <component X> behaving as expected? (check state) + - ``- [ ]`` Is the data flow correct from <A> to <B>? (trace) + Tailor these to the specific bug — three to five items covering the + key unknowns a reproduce-agent must resolve. + + e. Construct full new body = original body text + appended sections. + Write an edit_body action BEFORE the add_label action: + echo '{"action":"edit_body","issue":NNN,"body":"<full new body>"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl" + + f. Write the add_label action: + echo '{"action":"add_label","issue":NNN,"label":"bug-report"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl" + echo "ACTION: labeled #NNN as bug-report — <reason>" >> "$RESULT_FILE" Do NOT also add the backlog label — bug-report is a separate triage track that feeds into reproduction automation. -- 2.49.1 From 62ad6a9eeec9ab077c21fad5e4f4b74f05e30e4a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 6 Apr 2026 12:05:35 +0000 Subject: [PATCH 14/17] chore: gardener housekeeping 2026-04-06 --- AGENTS.md | 4 ++-- architect/AGENTS.md | 2 +- dev/AGENTS.md | 2 +- gardener/AGENTS.md | 2 +- gardener/pending-actions.json | 19 ++++++++++++------- lib/AGENTS.md | 11 ++++++----- planner/AGENTS.md | 2 +- predictor/AGENTS.md | 2 +- review/AGENTS.md | 2 +- supervisor/AGENTS.md | 2 +- 10 files changed, 27 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 71d1e34..5009bb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> +<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 --> # Disinto — Agent Instructions ## What this repo is @@ -31,7 +31,7 @@ disinto/ (code repo) │ supervisor-poll.sh — legacy bash orchestrator (superseded) ├── architect/ architect-run.sh — strategic decomposition of vision into sprints ├── vault/ vault-env.sh — shared env setup (vault redesign in progress, see #73-#77) -├── lib/ env.sh, agent-sdk.sh, ci-helpers.sh, ci-debug.sh, load-project.sh, parse-deps.sh, guard.sh, mirrors.sh, pr-lifecycle.sh, issue-lifecycle.sh, worktree.sh, formula-session.sh, profile.sh, build-graph.py +├── lib/ env.sh, agent-sdk.sh, ci-helpers.sh, ci-debug.sh, load-project.sh, parse-deps.sh, guard.sh, mirrors.sh, pr-lifecycle.sh, issue-lifecycle.sh, worktree.sh, formula-session.sh, stack-lock.sh, build-graph.py ├── projects/ *.toml.example — templates; *.toml — local per-box config (gitignored) ├── formulas/ Issue templates (TOML specs for multi-step agent tasks) └── docs/ Protocol docs (PHASE-PROTOCOL.md, EVIDENCE-ARCHITECTURE.md) diff --git a/architect/AGENTS.md b/architect/AGENTS.md index c2e99ba..19ed969 100644 --- a/architect/AGENTS.md +++ b/architect/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: auto-generated --> +<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 --> # Architect — Agent Instructions ## What this agent is diff --git a/dev/AGENTS.md b/dev/AGENTS.md index 9facdb2..be7ac40 100644 --- a/dev/AGENTS.md +++ b/dev/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> +<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 --> # Dev Agent **Role**: Implement issues autonomously — write code, push branches, address diff --git a/gardener/AGENTS.md b/gardener/AGENTS.md index 3f2e91b..cb66708 100644 --- a/gardener/AGENTS.md +++ b/gardener/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> +<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 --> # Gardener Agent **Role**: Backlog grooming — detect duplicate issues, missing acceptance diff --git a/gardener/pending-actions.json b/gardener/pending-actions.json index 0b60a5a..174a014 100644 --- a/gardener/pending-actions.json +++ b/gardener/pending-actions.json @@ -1,17 +1,22 @@ [ { - "action": "remove_label", - "issue": 240, - "label": "blocked" + "action": "edit_body", + "issue": 288, + "body": "Flagged by AI reviewer in PR #287.\n\n## Problem\n\n`review/review-pr.sh` fetches the PR head branch using hardcoded `origin` at two locations (lines 134 and 165):\n\n```bash\ngit fetch origin \"$PR_HEAD\"\n```\n\nThis is the same class of bug fixed for cron agents in #278. If the project repo is checked out with a different remote name (e.g. `codeberg`, `forge`), the review agent will silently fail to fetch the PR branch, potentially reviewing a stale or wrong commit.\n\n## Fix\n\nCall `resolve_forge_remote` early in `review-pr.sh` (same pattern as cron agents) and replace hardcoded `origin` with `${FORGE_REMOTE}`.\n\n---\n*Auto-created from AI review*\n\n## Affected files\n- `review/review-pr.sh` (lines ~134, ~165)\n- `lib/mirrors.sh` (for `resolve_forge_remote` reference if needed)\n\n## Acceptance criteria\n- [ ] `resolve_forge_remote` is called early in `review/review-pr.sh` to set `FORGE_REMOTE`\n- [ ] Hardcoded `origin` at both fetch locations replaced with `${FORGE_REMOTE}`\n- [ ] ShellCheck passes on the modified file\n- [ ] Mirrors the same fix pattern used for cron agents in #278\n" }, { "action": "add_label", - "issue": 240, + "issue": 288, "label": "backlog" }, { - "action": "comment", - "issue": 240, - "body": "Gardener: PR #242 was closed without merging (implementation was empty). Re-queuing this issue for dev-agent pickup. The fix is well-scoped and blocks #239." + "action": "edit_body", + "issue": 275, + "body": "Flagged by AI reviewer in PR #274.\n\n## Problem\n\nIn `bin/disinto` `setup_forge()`, the admin token was fixed (PR #274) to delete-then-recreate so the sha1 is captured. However the human token fallback at lines 791–797 still uses the old broken pattern:\n\n```sh\nhuman_token=$(curl -sf \\n -u \"${human_user}:${human_pass}\" \\n \"${forge_url}/api/v1/users/${human_user}/tokens\" 2>/dev/null \\n | jq -r '.[0].sha1 // empty') || human_token=\"\"\n```\n\nForge/Forgejo does **not** return `sha1` in token list responses — only at creation time. So on a re-run when `disinto-human-token` already exists, the create call returns 409 (token name collision), the fallback listing returns an empty sha1, and `HUMAN_TOKEN` is silently not saved/updated.\n\n## Fix\n\nApply the same delete-then-recreate pattern used for the admin token in PR #274: look up the token by name, delete it if it exists, then create fresh.\n\n---\n*Auto-created from AI review*\n\n## Affected files\n- `bin/disinto` (lines ~791–797, inside `setup_forge()`)\n\n## Acceptance criteria\n- [ ] Human token creation uses delete-then-recreate pattern (same as admin token in PR #274)\n- [ ] Re-running `disinto init` on an existing box correctly saves `HUMAN_TOKEN` (no silent empty)\n- [ ] No 409 collision on token name re-use\n- [ ] ShellCheck passes on the modified file\n" + }, + { + "action": "add_label", + "issue": 275, + "label": "backlog" } ] diff --git a/lib/AGENTS.md b/lib/AGENTS.md index cc883d5..ab774d4 100644 --- a/lib/AGENTS.md +++ b/lib/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> +<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 --> # Shared Helpers (`lib/`) All agents source `lib/env.sh` as their first action. Additional helpers are @@ -7,16 +7,17 @@ sourced as needed. | File | What it provides | Sourced by | |---|---|---| | `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (paginates all pages; accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`; handles invalid/empty JSON responses gracefully — returns empty on parse error instead of crashing), `woodpecker_api()`, `wpdb()`, `memory_guard()` (skips agent if RAM < threshold). Auto-loads project TOML if `PROJECT_TOML` is set. Exports per-agent tokens (`FORGE_PLANNER_TOKEN`, `FORGE_GARDENER_TOKEN`, `FORGE_VAULT_TOKEN`, `FORGE_SUPERVISOR_TOKEN`, `FORGE_PREDICTOR_TOKEN`) — each falls back to `$FORGE_TOKEN` if not set. **Vault-only token guard (AD-006)**: `unset GITHUB_TOKEN CLAWHUB_TOKEN` so agents never hold external-action tokens — only the runner container receives them. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. | Every agent | -| `lib/ci-helpers.sh` | `ci_passed()` — returns 0 if CI state is "success" (or no CI configured). `ci_required_for_pr()` — returns 0 if PR has code files (CI required), 1 if non-code only (CI not required). `is_infra_step()` — returns 0 if a single CI step failure matches infra heuristics (clone/git exit 128, any exit 137, log timeout patterns). `classify_pipeline_failure()` — returns "infra \<reason>" if any failed Woodpecker step matches infra heuristics via `is_infra_step()`, else "code". `ensure_priority_label()` — looks up (or creates) the `priority` label and returns its ID; caches in `_PRIORITY_LABEL_ID`. `ci_commit_status <sha>` — queries Woodpecker directly for CI state, falls back to forge commit status API. `ci_pipeline_number <sha>` — returns the Woodpecker pipeline number for a commit, falls back to parsing forge status `target_url`. `ci_promote <repo_id> <pipeline_num> <environment>` — promotes a pipeline to a named Woodpecker environment (vault-gated deployment: vault approves, vault-fire calls this — vault redesign in progress, see #73-#77). `ci_get_logs <pipeline_number> [--step <name>]` — reads CI logs from Woodpecker SQLite database; outputs last 200 lines to stdout. Requires mounted woodpecker-data volume at /woodpecker-data. | dev-poll, review-poll, review-pr, supervisor-poll | +| `lib/ci-helpers.sh` | `ci_passed()` — returns 0 if CI state is "success" (or no CI configured). `ci_required_for_pr()` — returns 0 if PR has code files (CI required), 1 if non-code only (CI not required). `is_infra_step()` — returns 0 if a single CI step failure matches infra heuristics (clone/git exit 128, any exit 137, log timeout patterns). `classify_pipeline_failure()` — returns "infra \<reason>" if any failed Woodpecker step matches infra heuristics via `is_infra_step()`, else "code". `ensure_priority_label()` — looks up (or creates) the `priority` label and returns its ID; caches in `_PRIORITY_LABEL_ID`. `ci_commit_status <sha>` — queries Woodpecker directly for CI state, falls back to forge commit status API. `ci_pipeline_number <sha>` — returns the Woodpecker pipeline number for a commit, falls back to parsing forge status `target_url`. `ci_promote <repo_id> <pipeline_num> <environment>` — promotes a pipeline to a named Woodpecker environment (vault-gated deployment: vault approves, vault-fire calls this — vault redesign in progress, see #73-#77). `ci_get_logs <pipeline_number> [--step <name>]` — reads CI logs from Woodpecker SQLite database via `lib/ci-log-reader.py`; outputs last 200 lines to stdout. Requires mounted woodpecker-data volume at /woodpecker-data. | dev-poll, review-poll, review-pr, supervisor-poll | | `lib/ci-debug.sh` | CLI tool for Woodpecker CI: `list`, `status`, `logs`, `failures` subcommands. Not sourced — run directly. | Humans / dev-agent (tool access) | +| `lib/ci-log-reader.py` | Python tool: reads CI logs from Woodpecker SQLite database. `<pipeline_number> [--step <name>]` — returns last 200 lines from failed steps (or specified step). Used by `ci_get_logs()` in ci-helpers.sh. Requires `WOODPECKER_DATA_DIR` (default: /woodpecker-data). | ci-helpers.sh | | `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `FORGE_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, mirror config, etc.). Also exports `FORGE_REPO_OWNER` (the owner component of `FORGE_REPO`, e.g. `disinto-admin` from `disinto-admin/disinto`). | 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` / `blocked by #N` patterns. Inline scan skips fenced code blocks to prevent false positives from code examples in issue bodies. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll, supervisor-poll | -| `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `load_formula_or_profile()`, `build_context_block()`, `ensure_ops_repo()`, `ops_commit_and_push()`, `build_prompt_footer()`, `build_sdk_prompt_footer()`, `formula_worktree_setup()`, `formula_prepare_profile_context()`, `formula_lessons_block()`, `profile_write_journal()`, `profile_load_lessons()`, `ensure_profile_repo()`, `_profile_has_repo()`, `_count_undigested_journals()`, `_profile_digest_journals()`, `_profile_commit_and_push()`, `resolve_agent_identity()`, `build_graph_section()`, `build_scratch_instruction()`, `read_scratch_context()`, `cleanup_stale_crashed_worktrees()` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, .profile repo management, prompt assembly, worktree setup). `build_graph_section()` generates the structural-analysis section (runs `lib/build-graph.py`, formats JSON output) — previously duplicated in planner-run.sh and predictor-run.sh, now shared here. `cleanup_stale_crashed_worktrees()` — thin wrapper around `worktree_cleanup_stale()` from `lib/worktree.sh` (kept for backwards compatibility). | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh | +| `lib/formula-session.sh` | `acquire_cron_lock()`, `load_formula()`, `load_formula_or_profile()`, `build_context_block()`, `ensure_ops_repo()`, `ops_commit_and_push()`, `build_prompt_footer()`, `build_sdk_prompt_footer()`, `formula_worktree_setup()`, `formula_prepare_profile_context()`, `formula_lessons_block()`, `profile_write_journal()`, `profile_load_lessons()`, `ensure_profile_repo()`, `_profile_has_repo()`, `_count_undigested_journals()`, `_profile_digest_journals()`, `_profile_commit_and_push()`, `resolve_agent_identity()`, `build_graph_section()`, `build_scratch_instruction()`, `read_scratch_context()`, `cleanup_stale_crashed_worktrees()` — shared helpers for formula-driven cron agents (lock, .profile repo management, prompt assembly, worktree setup). Memory guard is provided by `memory_guard()` in `lib/env.sh` (not duplicated here). `resolve_agent_identity()` — sets `FORGE_TOKEN`, `AGENT_IDENTITY`, `FORGE_REMOTE` from per-agent token env vars and FORGE_URL remote detection. `build_graph_section()` generates the structural-analysis section (runs `lib/build-graph.py`, formats JSON output) — previously duplicated in planner-run.sh and predictor-run.sh, now shared here. `cleanup_stale_crashed_worktrees()` — thin wrapper around `worktree_cleanup_stale()` from `lib/worktree.sh` (kept for backwards compatibility). | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh | | `lib/guard.sh` | `check_active(agent_name)` — reads `$FACTORY_ROOT/state/.{agent_name}-active`; exits 0 (skip) if the file is absent. Factory is off by default — state files must be created to enable each agent. **Logs a message to stderr** when skipping (`[check_active] SKIP: state file not found`), so agent dropout is visible in cron logs. Sourced by dev-poll.sh, review-poll.sh, predictor-run.sh, supervisor-run.sh. | cron entry points | | `lib/mirrors.sh` | `mirror_push()` — pushes `$PRIMARY_BRANCH` + tags to all configured mirror remotes (fire-and-forget background pushes). Reads `MIRROR_NAMES` and `MIRROR_*` vars exported by `load-project.sh` from the `[mirrors]` TOML section. Failures are logged but never block the pipeline. Sourced by dev-poll.sh — called after every successful merge. | dev-poll.sh | | `lib/build-graph.py` | Python tool: parses VISION.md, prerequisites.md (from ops repo), AGENTS.md, formulas/*.toml, evidence/ (from ops repo), and forge issues/labels into a NetworkX DiGraph. Runs structural analyses (orphaned objectives, stale prerequisites, thin evidence, circular deps) and outputs a JSON report. Used by `review-pr.sh` (per-PR changed-file analysis) and `predictor-run.sh` (full-project analysis) to provide structural context to Claude. | review-pr.sh, predictor-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 | -| `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/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]`. | issue-lifecycle.sh | +| `lib/stack-lock.sh` | File-based lock protocol for singleton project stack access. `stack_lock_acquire(holder, project)` — polls until free, breaks stale heartbeats (>10 min old), claims lock. `stack_lock_release(project)` — deletes lock file. `stack_lock_check(project)` — inspect current lock state. `stack_lock_heartbeat(project)` — update heartbeat timestamp (callers must call every 2 min while holding). Lock files at `~/data/locks/<project>-stack.lock`. | docker/edge/dispatcher.sh, reproduce formula | | `lib/tea-helpers.sh` | `tea_file_issue(title, body, labels...)` — create issue via tea CLI with secret scanning; sets `FILED_ISSUE_NUM`. `tea_relabel(issue_num, labels...)` — replace labels using tea's `edit` subcommand (not `label`). `tea_comment(issue_num, body)` — add comment with secret scanning. `tea_close(issue_num)` — close issue. All use `TEA_LOGIN` and `FORGE_REPO` from env.sh. Labels by name (no ID lookup). Tea binary download verified via sha256 checksum. Sourced by env.sh when `tea` binary is available. | env.sh (conditional) | | `lib/worktree.sh` | Reusable git worktree management: `worktree_create(path, branch, [base_ref])` — create worktree, checkout base, fetch submodules. `worktree_recover(path, branch, [remote])` — detect existing worktree, reuse if on correct branch (sets `_WORKTREE_REUSED`), otherwise clean and recreate. `worktree_cleanup(path)` — `git worktree remove --force`, clear Claude Code project cache (`~/.claude/projects/` matching path). `worktree_cleanup_stale([max_age_hours])` — scan `/tmp` for orphaned worktrees older than threshold, skip preserved and active tmux worktrees, prune. `worktree_preserve(path, reason)` — mark worktree as preserved for debugging (writes `.worktree-preserved` marker, skipped by stale cleanup). | dev-agent.sh, supervisor-run.sh, planner-run.sh, predictor-run.sh, gardener-run.sh | | `lib/pr-lifecycle.sh` | Reusable PR lifecycle library: `pr_create()`, `pr_find_by_branch()`, `pr_poll_ci()`, `pr_poll_review()`, `pr_merge()`, `pr_is_merged()`, `pr_walk_to_merge()`, `build_phase_protocol_prompt()`. Requires `lib/ci-helpers.sh`. | dev-agent.sh (future) | diff --git a/planner/AGENTS.md b/planner/AGENTS.md index 769f84d..e0d1f4c 100644 --- a/planner/AGENTS.md +++ b/planner/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> +<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 --> # Planner Agent **Role**: Strategic planning using a Prerequisite Tree (Theory of Constraints), diff --git a/predictor/AGENTS.md b/predictor/AGENTS.md index a8457d1..ae556b5 100644 --- a/predictor/AGENTS.md +++ b/predictor/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> +<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 --> # Predictor Agent **Role**: Abstract adversary (the "goblin"). Runs a 2-step formula diff --git a/review/AGENTS.md b/review/AGENTS.md index 6853945..f6afb17 100644 --- a/review/AGENTS.md +++ b/review/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> +<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 --> # Review Agent **Role**: AI-powered PR review — post structured findings and formal diff --git a/supervisor/AGENTS.md b/supervisor/AGENTS.md index 1f16c4b..72af4cd 100644 --- a/supervisor/AGENTS.md +++ b/supervisor/AGENTS.md @@ -1,4 +1,4 @@ -<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 --> +<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 --> # Supervisor Agent **Role**: Health monitoring and auto-remediation, executed as a formula-driven -- 2.49.1 From cddf0dea8810315f579310c902f658bb7835a80b Mon Sep 17 00:00:00 2001 From: Agent <agent@example.com> Date: Mon, 6 Apr 2026 12:15:38 +0000 Subject: [PATCH 15/17] fix: fix: review/review-pr.sh uses hardcoded 'origin' for project repo fetch (#288) --- review/review-pr.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/review/review-pr.sh b/review/review-pr.sh index 63784dd..08ce653 100755 --- a/review/review-pr.sh +++ b/review/review-pr.sh @@ -58,6 +58,11 @@ if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 10 mv "$LOGFILE" "$LOGFILE.old" fi +# ============================================================================= +# RESOLVE FORGE REMOTE FOR GIT OPERATIONS +# ============================================================================= +resolve_forge_remote + # ============================================================================= # RESOLVE AGENT IDENTITY FOR .PROFILE REPO # ============================================================================= @@ -128,7 +133,7 @@ PREV_REV=$(printf '%s' "$ALL_COMMENTS" | jq -r --arg s "$PR_SHA" \ if [ -n "$PREV_REV" ] && [ "$PREV_REV" != "null" ]; then PREV_BODY=$(printf '%s' "$PREV_REV" | jq -r '.body') PREV_SHA=$(printf '%s' "$PREV_BODY" | grep -oP '<!-- reviewed: \K[a-f0-9]+' | head -1) - cd "${PROJECT_REPO_ROOT}"; git fetch origin "$PR_HEAD" 2>/dev/null || true + cd "${PROJECT_REPO_ROOT}"; git fetch "${FORGE_REMOTE}" "$PR_HEAD" 2>/dev/null || true INCR=$(git diff "${PREV_SHA}..${PR_SHA}" 2>/dev/null | head -c "$MAX_DIFF") || true if [ -n "$INCR" ]; then IS_RE_REVIEW=true; log "re-review: previous at ${PREV_SHA:0:7}" @@ -159,7 +164,7 @@ DNOTE=""; [ "$FSIZE" -gt "$MAX_DIFF" ] && DNOTE=" (truncated from ${FSIZE} bytes # WORKTREE SETUP # ============================================================================= cd "${PROJECT_REPO_ROOT}" -git fetch origin "$PR_HEAD" 2>/dev/null || true +git fetch "${FORGE_REMOTE}" "$PR_HEAD" 2>/dev/null || true if [ -d "$WORKTREE" ]; then cd "$WORKTREE"; git checkout --detach "$PR_SHA" 2>/dev/null || { -- 2.49.1 From bbc1c21930b474ef9e0f3bb29dfa5c656bdca728 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 6 Apr 2026 12:14:49 +0000 Subject: [PATCH 16/17] fix: fix: disinto init re-run silently drops HUMAN_TOKEN when token already exists (#275) Apply delete-then-recreate pattern for human token (matching admin token in PR #274). Forge/Forgejo only returns sha1 at creation time; listing returns no sha1, causing HUMAN_TOKEN to be silently empty on re-runs when token name already exists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- bin/disinto | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bin/disinto b/bin/disinto index 3d0dd9e..67bdf17 100755 --- a/bin/disinto +++ b/bin/disinto @@ -785,6 +785,19 @@ setup_forge() { # Get or create human user token local human_token if curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then + # Delete existing human token if present (token sha1 is only returned at creation time) + local existing_human_token_id + existing_human_token_id=$(curl -sf \ + -u "${human_user}:${human_pass}" \ + "${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \ + | jq -r '.[] | select(.name == "disinto-human-token") | .id') || existing_human_token_id="" + if [ -n "$existing_human_token_id" ]; then + curl -sf -X DELETE \ + -u "${human_user}:${human_pass}" \ + "${forge_url}/api/v1/users/${human_user}/tokens/${existing_human_token_id}" >/dev/null 2>&1 || true + fi + + # Create human token (fresh, so sha1 is returned) human_token=$(curl -sf -X POST \ -u "${human_user}:${human_pass}" \ -H "Content-Type: application/json" \ @@ -792,14 +805,6 @@ setup_forge() { -d '{"name":"disinto-human-token","scopes":["all"]}' 2>/dev/null \ | jq -r '.sha1 // empty') || human_token="" - if [ -z "$human_token" ]; then - # Token might already exist — try listing - human_token=$(curl -sf \ - -u "${human_user}:${human_pass}" \ - "${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \ - | jq -r '.[0].sha1 // empty') || human_token="" - fi - if [ -n "$human_token" ]; then # Store human token in .env if grep -q '^HUMAN_TOKEN=' "$env_file" 2>/dev/null; then -- 2.49.1 From 74088c5aba65ab067517b775f1fd86c2e97f8bc2 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 6 Apr 2026 18:02:26 +0000 Subject: [PATCH 17/17] fix: refactor: extract setup_forge() from bin/disinto into lib/forge-setup.sh (#298) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- bin/disinto | 466 +------------------------------------------ lib/forge-setup.sh | 480 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 482 insertions(+), 464 deletions(-) create mode 100644 lib/forge-setup.sh diff --git a/bin/disinto b/bin/disinto index 67bdf17..3920030 100755 --- a/bin/disinto +++ b/bin/disinto @@ -25,19 +25,10 @@ set -euo pipefail FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" source "${FACTORY_ROOT}/lib/env.sh" +source "${FACTORY_ROOT}/lib/forge-setup.sh" # ── Helpers ────────────────────────────────────────────────────────────────── -# Execute a command in the Forgejo container (for admin operations) -_forgejo_exec() { - local use_bare="${DISINTO_BARE:-false}" - if [ "$use_bare" = true ]; then - docker exec -u git disinto-forgejo "$@" - else - docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T -u git forgejo "$@" - fi -} - usage() { cat <<EOF disinto — autonomous code factory CLI @@ -574,460 +565,7 @@ is_compose_mode() { [ -f "${FACTORY_ROOT}/docker-compose.yml" ] } -# Provision or connect to a local Forgejo instance. -# Creates admin + bot users, generates API tokens, stores in .env. -# When $DISINTO_BARE is set, uses standalone docker run; otherwise uses compose. -setup_forge() { - local forge_url="$1" - local repo_slug="$2" - local use_bare="${DISINTO_BARE:-false}" - - echo "" - echo "── Forge setup ────────────────────────────────────────" - - # Check if Forgejo is already running - if curl -sf --max-time 5 "${forge_url}/api/v1/version" >/dev/null 2>&1; then - echo "Forgejo: ${forge_url} (already running)" - else - echo "Forgejo not reachable at ${forge_url}" - echo "Starting Forgejo via Docker..." - - if ! command -v docker &>/dev/null; then - echo "Error: docker not found — needed to provision Forgejo" >&2 - echo " Install Docker or start Forgejo manually at ${forge_url}" >&2 - exit 1 - fi - - # Extract port from forge_url - local forge_port - forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|') - forge_port="${forge_port:-3000}" - - if [ "$use_bare" = true ]; then - # Bare-metal mode: standalone docker run - mkdir -p "${FORGEJO_DATA_DIR}" - - if docker ps -a --format '{{.Names}}' | grep -q '^disinto-forgejo$'; then - docker start disinto-forgejo >/dev/null 2>&1 || true - else - docker run -d \ - --name disinto-forgejo \ - --restart unless-stopped \ - -p "${forge_port}:3000" \ - -p 2222:22 \ - -v "${FORGEJO_DATA_DIR}:/data" \ - -e "FORGEJO__database__DB_TYPE=sqlite3" \ - -e "FORGEJO__server__ROOT_URL=${forge_url}/" \ - -e "FORGEJO__server__HTTP_PORT=3000" \ - -e "FORGEJO__service__DISABLE_REGISTRATION=true" \ - codeberg.org/forgejo/forgejo:11.0 - fi - else - # Compose mode: start Forgejo via docker compose - docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d forgejo - fi - - # Wait for Forgejo to become healthy - echo -n "Waiting for Forgejo to start" - local retries=0 - while ! curl -sf --max-time 3 "${forge_url}/api/v1/version" >/dev/null 2>&1; do - retries=$((retries + 1)) - if [ "$retries" -gt 60 ]; then - echo "" - echo "Error: Forgejo did not become ready within 60s" >&2 - exit 1 - fi - echo -n "." - sleep 1 - done - echo " ready" - fi - - # Wait for Forgejo database to accept writes (API may be ready before DB is) - echo -n "Waiting for Forgejo database" - local db_ready=false - for _i in $(seq 1 30); do - if _forgejo_exec forgejo admin user list >/dev/null 2>&1; then - db_ready=true - break - fi - echo -n "." - sleep 1 - done - echo "" - if [ "$db_ready" != true ]; then - echo "Error: Forgejo database not ready after 30s" >&2 - exit 1 - fi - - # Create admin user if it doesn't exist - local admin_user="disinto-admin" - local admin_pass - local env_file="${FACTORY_ROOT}/.env" - - # Re-read persisted admin password if available (#158) - if grep -q '^FORGE_ADMIN_PASS=' "$env_file" 2>/dev/null; then - admin_pass=$(grep '^FORGE_ADMIN_PASS=' "$env_file" | head -1 | cut -d= -f2-) - fi - # Generate a fresh password only when none was persisted - if [ -z "${admin_pass:-}" ]; then - admin_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)" - fi - - if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${admin_user}" >/dev/null 2>&1; then - echo "Creating admin user: ${admin_user}" - local create_output - if ! create_output=$(_forgejo_exec forgejo admin user create \ - --admin \ - --username "${admin_user}" \ - --password "${admin_pass}" \ - --email "admin@disinto.local" \ - --must-change-password=false 2>&1); then - echo "Error: failed to create admin user '${admin_user}':" >&2 - echo " ${create_output}" >&2 - exit 1 - fi - # Forgejo 11.x ignores --must-change-password=false on create; - # explicitly clear the flag so basic-auth token creation works. - _forgejo_exec forgejo admin user change-password \ - --username "${admin_user}" \ - --password "${admin_pass}" \ - --must-change-password=false - - # Verify admin user was actually created - if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${admin_user}" >/dev/null 2>&1; then - echo "Error: admin user '${admin_user}' not found after creation" >&2 - exit 1 - fi - - # Persist admin password to .env for idempotent re-runs (#158) - if grep -q '^FORGE_ADMIN_PASS=' "$env_file" 2>/dev/null; then - sed -i "s|^FORGE_ADMIN_PASS=.*|FORGE_ADMIN_PASS=${admin_pass}|" "$env_file" - else - printf 'FORGE_ADMIN_PASS=%s\n' "$admin_pass" >> "$env_file" - fi - else - echo "Admin user: ${admin_user} (already exists)" - # Only reset password if basic auth fails (#158, #267) - # Forgejo 11.x may ignore --must-change-password=false, blocking token creation - if ! curl -sf --max-time 5 -u "${admin_user}:${admin_pass}" \ - "${forge_url}/api/v1/user" >/dev/null 2>&1; then - _forgejo_exec forgejo admin user change-password \ - --username "${admin_user}" \ - --password "${admin_pass}" \ - --must-change-password=false - fi - fi - # Preserve password for Woodpecker OAuth2 token generation (#779) - _FORGE_ADMIN_PASS="$admin_pass" - - # Create human user (disinto-admin) as site admin if it doesn't exist - local human_user="disinto-admin" - local human_pass - human_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)" - - if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then - echo "Creating human user: ${human_user}" - local create_output - if ! create_output=$(_forgejo_exec forgejo admin user create \ - --admin \ - --username "${human_user}" \ - --password "${human_pass}" \ - --email "admin@disinto.local" \ - --must-change-password=false 2>&1); then - echo "Error: failed to create human user '${human_user}':" >&2 - echo " ${create_output}" >&2 - exit 1 - fi - # Forgejo 11.x ignores --must-change-password=false on create; - # explicitly clear the flag so basic-auth token creation works. - _forgejo_exec forgejo admin user change-password \ - --username "${human_user}" \ - --password "${human_pass}" \ - --must-change-password=false - - # Verify human user was actually created - if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then - echo "Error: human user '${human_user}' not found after creation" >&2 - exit 1 - fi - echo " Human user '${human_user}' created as site admin" - else - echo "Human user: ${human_user} (already exists)" - fi - - # Delete existing admin token if present (token sha1 is only returned at creation time) - local existing_token_id - existing_token_id=$(curl -sf \ - -u "${admin_user}:${admin_pass}" \ - "${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \ - | jq -r '.[] | select(.name == "disinto-admin-token") | .id') || existing_token_id="" - if [ -n "$existing_token_id" ]; then - curl -sf -X DELETE \ - -u "${admin_user}:${admin_pass}" \ - "${forge_url}/api/v1/users/${admin_user}/tokens/${existing_token_id}" >/dev/null 2>&1 || true - fi - - # Create admin token (fresh, so sha1 is returned) - local admin_token - admin_token=$(curl -sf -X POST \ - -u "${admin_user}:${admin_pass}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/users/${admin_user}/tokens" \ - -d '{"name":"disinto-admin-token","scopes":["all"]}' 2>/dev/null \ - | jq -r '.sha1 // empty') || admin_token="" - - if [ -z "$admin_token" ]; then - echo "Error: failed to obtain admin API token" >&2 - exit 1 - fi - - # Get or create human user token - local human_token - if curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then - # Delete existing human token if present (token sha1 is only returned at creation time) - local existing_human_token_id - existing_human_token_id=$(curl -sf \ - -u "${human_user}:${human_pass}" \ - "${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \ - | jq -r '.[] | select(.name == "disinto-human-token") | .id') || existing_human_token_id="" - if [ -n "$existing_human_token_id" ]; then - curl -sf -X DELETE \ - -u "${human_user}:${human_pass}" \ - "${forge_url}/api/v1/users/${human_user}/tokens/${existing_human_token_id}" >/dev/null 2>&1 || true - fi - - # Create human token (fresh, so sha1 is returned) - human_token=$(curl -sf -X POST \ - -u "${human_user}:${human_pass}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/users/${human_user}/tokens" \ - -d '{"name":"disinto-human-token","scopes":["all"]}' 2>/dev/null \ - | jq -r '.sha1 // empty') || human_token="" - - if [ -n "$human_token" ]; then - # Store human token in .env - if grep -q '^HUMAN_TOKEN=' "$env_file" 2>/dev/null; then - sed -i "s|^HUMAN_TOKEN=.*|HUMAN_TOKEN=${human_token}|" "$env_file" - else - printf 'HUMAN_TOKEN=%s\n' "$human_token" >> "$env_file" - fi - export HUMAN_TOKEN="$human_token" - echo " Human token saved (HUMAN_TOKEN)" - fi - fi - - # Create bot users and tokens - # Each agent gets its own Forgejo account for identity and audit trail (#747). - # Map: bot-username -> env-var-name for the token - local -A bot_token_vars=( - [dev-bot]="FORGE_TOKEN" - [review-bot]="FORGE_REVIEW_TOKEN" - [planner-bot]="FORGE_PLANNER_TOKEN" - [gardener-bot]="FORGE_GARDENER_TOKEN" - [vault-bot]="FORGE_VAULT_TOKEN" - [supervisor-bot]="FORGE_SUPERVISOR_TOKEN" - [predictor-bot]="FORGE_PREDICTOR_TOKEN" - [architect-bot]="FORGE_ARCHITECT_TOKEN" - ) - - local bot_user bot_pass token token_var - - for bot_user in dev-bot review-bot planner-bot gardener-bot vault-bot supervisor-bot predictor-bot architect-bot; do - bot_pass="bot-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)" - token_var="${bot_token_vars[$bot_user]}" - - # Check if bot user exists - local user_exists=false - if curl -sf --max-time 5 \ - -H "Authorization: token ${admin_token}" \ - "${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then - user_exists=true - fi - - if [ "$user_exists" = false ]; then - echo "Creating bot user: ${bot_user}" - local create_output - if ! create_output=$(_forgejo_exec forgejo admin user create \ - --username "${bot_user}" \ - --password "${bot_pass}" \ - --email "${bot_user}@disinto.local" \ - --must-change-password=false 2>&1); then - echo "Error: failed to create bot user '${bot_user}':" >&2 - echo " ${create_output}" >&2 - exit 1 - fi - # Forgejo 11.x ignores --must-change-password=false on create; - # explicitly clear the flag so basic-auth token creation works. - _forgejo_exec forgejo admin user change-password \ - --username "${bot_user}" \ - --password "${bot_pass}" \ - --must-change-password=false - - # Verify bot user was actually created - if ! curl -sf --max-time 5 \ - -H "Authorization: token ${admin_token}" \ - "${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then - echo "Error: bot user '${bot_user}' not found after creation" >&2 - exit 1 - fi - echo " ${bot_user} user created" - else - echo " ${bot_user} user exists (resetting password for token generation)" - # User exists but may not have a known password. - # Use admin API to reset the password so we can generate a new token. - _forgejo_exec forgejo admin user change-password \ - --username "${bot_user}" \ - --password "${bot_pass}" \ - --must-change-password=false || { - echo "Error: failed to reset password for existing bot user '${bot_user}'" >&2 - exit 1 - } - fi - - # Generate token via API (basic auth as the bot user — Forgejo requires - # basic auth on POST /users/{username}/tokens, token auth is rejected) - # First, try to delete existing tokens to avoid name collision - # Use bot user's own Basic Auth (we just set the password above) - local existing_token_ids - existing_token_ids=$(curl -sf \ - -u "${bot_user}:${bot_pass}" \ - "${forge_url}/api/v1/users/${bot_user}/tokens" 2>/dev/null \ - | jq -r '.[].id // empty' 2>/dev/null) || existing_token_ids="" - - # Delete any existing tokens for this user - if [ -n "$existing_token_ids" ]; then - while IFS= read -r tid; do - [ -n "$tid" ] && curl -sf -X DELETE \ - -u "${bot_user}:${bot_pass}" \ - "${forge_url}/api/v1/users/${bot_user}/tokens/${tid}" >/dev/null 2>&1 || true - done <<< "$existing_token_ids" - fi - - token=$(curl -sf -X POST \ - -u "${bot_user}:${bot_pass}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/users/${bot_user}/tokens" \ - -d "{\"name\":\"disinto-${bot_user}-token\",\"scopes\":[\"all\"]}" 2>/dev/null \ - | jq -r '.sha1 // empty') || token="" - - if [ -z "$token" ]; then - echo "Error: failed to create API token for '${bot_user}'" >&2 - exit 1 - fi - - # Store token in .env under the per-agent variable name - if grep -q "^${token_var}=" "$env_file" 2>/dev/null; then - sed -i "s|^${token_var}=.*|${token_var}=${token}|" "$env_file" - else - printf '%s=%s\n' "$token_var" "$token" >> "$env_file" - fi - export "${token_var}=${token}" - echo " ${bot_user} token generated and saved (${token_var})" - - # Backwards-compat aliases for dev-bot and review-bot - if [ "$bot_user" = "dev-bot" ]; then - export CODEBERG_TOKEN="$token" - elif [ "$bot_user" = "review-bot" ]; then - export REVIEW_BOT_TOKEN="$token" - fi - done - - # Store FORGE_URL in .env if not already present - if ! grep -q '^FORGE_URL=' "$env_file" 2>/dev/null; then - printf 'FORGE_URL=%s\n' "$forge_url" >> "$env_file" - fi - - # Create the repo on Forgejo if it doesn't exist - local org_name="${repo_slug%%/*}" - local repo_name="${repo_slug##*/}" - - # Check if repo already exists - if ! curl -sf --max-time 5 \ - -H "Authorization: token ${FORGE_TOKEN}" \ - "${forge_url}/api/v1/repos/${repo_slug}" >/dev/null 2>&1; then - - # Try creating org first (ignore if exists) - curl -sf -X POST \ - -H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/orgs" \ - -d "{\"username\":\"${org_name}\",\"visibility\":\"public\"}" >/dev/null 2>&1 || true - - # Create repo under org - if ! curl -sf -X POST \ - -H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/orgs/${org_name}/repos" \ - -d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then - # Fallback: create under the human user namespace using admin endpoint - if [ -n "${admin_token:-}" ]; then - if ! curl -sf -X POST \ - -H "Authorization: token ${admin_token}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/admin/users/${org_name}/repos" \ - -d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then - echo "Error: failed to create repo '${repo_slug}' on Forgejo (admin endpoint)" >&2 - exit 1 - fi - elif [ -n "${HUMAN_TOKEN:-}" ]; then - if ! curl -sf -X POST \ - -H "Authorization: token ${HUMAN_TOKEN}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/user/repos" \ - -d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then - echo "Error: failed to create repo '${repo_slug}' on Forgejo (user endpoint)" >&2 - exit 1 - fi - else - echo "Error: failed to create repo '${repo_slug}' — no admin or human token available" >&2 - exit 1 - fi - fi - - # Add all bot users as collaborators with appropriate permissions - # dev-bot: write (PR creation via lib/vault.sh) - # review-bot: read (PR review) - # planner-bot: write (prerequisites.md, memory) - # gardener-bot: write (backlog grooming) - # vault-bot: write (vault items) - # supervisor-bot: read (health monitoring) - # predictor-bot: read (pattern detection) - # architect-bot: write (sprint PRs) - local bot_user bot_perm - declare -A bot_permissions=( - [dev-bot]="write" - [review-bot]="read" - [planner-bot]="write" - [gardener-bot]="write" - [vault-bot]="write" - [supervisor-bot]="read" - [predictor-bot]="read" - [architect-bot]="write" - ) - for bot_user in "${!bot_permissions[@]}"; do - bot_perm="${bot_permissions[$bot_user]}" - curl -sf -X PUT \ - -H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/repos/${repo_slug}/collaborators/${bot_user}" \ - -d "{\"permission\":\"${bot_perm}\"}" >/dev/null 2>&1 || true - done - - # Add disinto-admin as admin collaborator - curl -sf -X PUT \ - -H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/repos/${repo_slug}/collaborators/disinto-admin" \ - -d '{"permission":"admin"}' >/dev/null 2>&1 || true - - echo "Repo: ${repo_slug} created on Forgejo" - else - echo "Repo: ${repo_slug} (already exists on Forgejo)" - fi - - echo "Forge: ${forge_url} (ready)" -} +# setup_forge() is defined in lib/forge-setup.sh # Create and seed the {project}-ops repo on Forgejo with initial directory structure. # The ops repo holds operational data: vault items, journals, evidence, prerequisites. diff --git a/lib/forge-setup.sh b/lib/forge-setup.sh new file mode 100644 index 0000000..78799e8 --- /dev/null +++ b/lib/forge-setup.sh @@ -0,0 +1,480 @@ +#!/usr/bin/env bash +set -euo pipefail +# forge-setup.sh — Forgejo provisioning helpers +# +# Source from bin/disinto: source "${FACTORY_ROOT}/lib/forge-setup.sh" +# Requires globals: FACTORY_ROOT (set by env.sh or the caller) +# Call _load_init_context before using this library standalone to assert +# that FORGE_URL, FACTORY_ROOT, and PRIMARY_BRANCH are all set. + +# Assert required globals are set (call before using this library standalone). +_load_init_context() { + : "${FORGE_URL:?FORGE_URL must be set}" + : "${FACTORY_ROOT:?FACTORY_ROOT must be set}" + : "${PRIMARY_BRANCH:?PRIMARY_BRANCH must be set}" +} + +# Execute a command in the Forgejo container (for admin operations) +_forgejo_exec() { + local use_bare="${DISINTO_BARE:-false}" + if [ "$use_bare" = true ]; then + docker exec -u git disinto-forgejo "$@" + else + docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T -u git forgejo "$@" + fi +} + +# Provision or connect to a local Forgejo instance. +# Creates admin + bot users, generates API tokens, stores in .env. +# When $DISINTO_BARE is set, uses standalone docker run; otherwise uses compose. +setup_forge() { + local forge_url="$1" + local repo_slug="$2" + local use_bare="${DISINTO_BARE:-false}" + + echo "" + echo "── Forge setup ────────────────────────────────────────" + + # Check if Forgejo is already running + if curl -sf --max-time 5 "${forge_url}/api/v1/version" >/dev/null 2>&1; then + echo "Forgejo: ${forge_url} (already running)" + else + echo "Forgejo not reachable at ${forge_url}" + echo "Starting Forgejo via Docker..." + + if ! command -v docker &>/dev/null; then + echo "Error: docker not found — needed to provision Forgejo" >&2 + echo " Install Docker or start Forgejo manually at ${forge_url}" >&2 + exit 1 + fi + + # Extract port from forge_url + local forge_port + forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|') + forge_port="${forge_port:-3000}" + + if [ "$use_bare" = true ]; then + # Bare-metal mode: standalone docker run + mkdir -p "${FORGEJO_DATA_DIR}" + + if docker ps -a --format '{{.Names}}' | grep -q '^disinto-forgejo$'; then + docker start disinto-forgejo >/dev/null 2>&1 || true + else + docker run -d \ + --name disinto-forgejo \ + --restart unless-stopped \ + -p "${forge_port}:3000" \ + -p 2222:22 \ + -v "${FORGEJO_DATA_DIR}:/data" \ + -e "FORGEJO__database__DB_TYPE=sqlite3" \ + -e "FORGEJO__server__ROOT_URL=${forge_url}/" \ + -e "FORGEJO__server__HTTP_PORT=3000" \ + -e "FORGEJO__service__DISABLE_REGISTRATION=true" \ + codeberg.org/forgejo/forgejo:11.0 + fi + else + # Compose mode: start Forgejo via docker compose + docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d forgejo + fi + + # Wait for Forgejo to become healthy + echo -n "Waiting for Forgejo to start" + local retries=0 + while ! curl -sf --max-time 3 "${forge_url}/api/v1/version" >/dev/null 2>&1; do + retries=$((retries + 1)) + if [ "$retries" -gt 60 ]; then + echo "" + echo "Error: Forgejo did not become ready within 60s" >&2 + exit 1 + fi + echo -n "." + sleep 1 + done + echo " ready" + fi + + # Wait for Forgejo database to accept writes (API may be ready before DB is) + echo -n "Waiting for Forgejo database" + local db_ready=false + for _i in $(seq 1 30); do + if _forgejo_exec forgejo admin user list >/dev/null 2>&1; then + db_ready=true + break + fi + echo -n "." + sleep 1 + done + echo "" + if [ "$db_ready" != true ]; then + echo "Error: Forgejo database not ready after 30s" >&2 + exit 1 + fi + + # Create admin user if it doesn't exist + local admin_user="disinto-admin" + local admin_pass + local env_file="${FACTORY_ROOT}/.env" + + # Re-read persisted admin password if available (#158) + if grep -q '^FORGE_ADMIN_PASS=' "$env_file" 2>/dev/null; then + admin_pass=$(grep '^FORGE_ADMIN_PASS=' "$env_file" | head -1 | cut -d= -f2-) + fi + # Generate a fresh password only when none was persisted + if [ -z "${admin_pass:-}" ]; then + admin_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)" + fi + + if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${admin_user}" >/dev/null 2>&1; then + echo "Creating admin user: ${admin_user}" + local create_output + if ! create_output=$(_forgejo_exec forgejo admin user create \ + --admin \ + --username "${admin_user}" \ + --password "${admin_pass}" \ + --email "admin@disinto.local" \ + --must-change-password=false 2>&1); then + echo "Error: failed to create admin user '${admin_user}':" >&2 + echo " ${create_output}" >&2 + exit 1 + fi + # Forgejo 11.x ignores --must-change-password=false on create; + # explicitly clear the flag so basic-auth token creation works. + _forgejo_exec forgejo admin user change-password \ + --username "${admin_user}" \ + --password "${admin_pass}" \ + --must-change-password=false + + # Verify admin user was actually created + if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${admin_user}" >/dev/null 2>&1; then + echo "Error: admin user '${admin_user}' not found after creation" >&2 + exit 1 + fi + + # Persist admin password to .env for idempotent re-runs (#158) + if grep -q '^FORGE_ADMIN_PASS=' "$env_file" 2>/dev/null; then + sed -i "s|^FORGE_ADMIN_PASS=.*|FORGE_ADMIN_PASS=${admin_pass}|" "$env_file" + else + printf 'FORGE_ADMIN_PASS=%s\n' "$admin_pass" >> "$env_file" + fi + else + echo "Admin user: ${admin_user} (already exists)" + # Only reset password if basic auth fails (#158, #267) + # Forgejo 11.x may ignore --must-change-password=false, blocking token creation + if ! curl -sf --max-time 5 -u "${admin_user}:${admin_pass}" \ + "${forge_url}/api/v1/user" >/dev/null 2>&1; then + _forgejo_exec forgejo admin user change-password \ + --username "${admin_user}" \ + --password "${admin_pass}" \ + --must-change-password=false + fi + fi + # Preserve password for Woodpecker OAuth2 token generation (#779) + _FORGE_ADMIN_PASS="$admin_pass" + + # Create human user (disinto-admin) as site admin if it doesn't exist + local human_user="disinto-admin" + local human_pass + human_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)" + + if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then + echo "Creating human user: ${human_user}" + local create_output + if ! create_output=$(_forgejo_exec forgejo admin user create \ + --admin \ + --username "${human_user}" \ + --password "${human_pass}" \ + --email "admin@disinto.local" \ + --must-change-password=false 2>&1); then + echo "Error: failed to create human user '${human_user}':" >&2 + echo " ${create_output}" >&2 + exit 1 + fi + # Forgejo 11.x ignores --must-change-password=false on create; + # explicitly clear the flag so basic-auth token creation works. + _forgejo_exec forgejo admin user change-password \ + --username "${human_user}" \ + --password "${human_pass}" \ + --must-change-password=false + + # Verify human user was actually created + if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then + echo "Error: human user '${human_user}' not found after creation" >&2 + exit 1 + fi + echo " Human user '${human_user}' created as site admin" + else + echo "Human user: ${human_user} (already exists)" + fi + + # Delete existing admin token if present (token sha1 is only returned at creation time) + local existing_token_id + existing_token_id=$(curl -sf \ + -u "${admin_user}:${admin_pass}" \ + "${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \ + | jq -r '.[] | select(.name == "disinto-admin-token") | .id') || existing_token_id="" + if [ -n "$existing_token_id" ]; then + curl -sf -X DELETE \ + -u "${admin_user}:${admin_pass}" \ + "${forge_url}/api/v1/users/${admin_user}/tokens/${existing_token_id}" >/dev/null 2>&1 || true + fi + + # Create admin token (fresh, so sha1 is returned) + local admin_token + admin_token=$(curl -sf -X POST \ + -u "${admin_user}:${admin_pass}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/users/${admin_user}/tokens" \ + -d '{"name":"disinto-admin-token","scopes":["all"]}' 2>/dev/null \ + | jq -r '.sha1 // empty') || admin_token="" + + if [ -z "$admin_token" ]; then + echo "Error: failed to obtain admin API token" >&2 + exit 1 + fi + + # Get or create human user token + local human_token + if curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then + # Delete existing human token if present (token sha1 is only returned at creation time) + local existing_human_token_id + existing_human_token_id=$(curl -sf \ + -u "${human_user}:${human_pass}" \ + "${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \ + | jq -r '.[] | select(.name == "disinto-human-token") | .id') || existing_human_token_id="" + if [ -n "$existing_human_token_id" ]; then + curl -sf -X DELETE \ + -u "${human_user}:${human_pass}" \ + "${forge_url}/api/v1/users/${human_user}/tokens/${existing_human_token_id}" >/dev/null 2>&1 || true + fi + + # Create human token (fresh, so sha1 is returned) + human_token=$(curl -sf -X POST \ + -u "${human_user}:${human_pass}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/users/${human_user}/tokens" \ + -d '{"name":"disinto-human-token","scopes":["all"]}' 2>/dev/null \ + | jq -r '.sha1 // empty') || human_token="" + + if [ -n "$human_token" ]; then + # Store human token in .env + if grep -q '^HUMAN_TOKEN=' "$env_file" 2>/dev/null; then + sed -i "s|^HUMAN_TOKEN=.*|HUMAN_TOKEN=${human_token}|" "$env_file" + else + printf 'HUMAN_TOKEN=%s\n' "$human_token" >> "$env_file" + fi + export HUMAN_TOKEN="$human_token" + echo " Human token saved (HUMAN_TOKEN)" + fi + fi + + # Create bot users and tokens + # Each agent gets its own Forgejo account for identity and audit trail (#747). + # Map: bot-username -> env-var-name for the token + local -A bot_token_vars=( + [dev-bot]="FORGE_TOKEN" + [review-bot]="FORGE_REVIEW_TOKEN" + [planner-bot]="FORGE_PLANNER_TOKEN" + [gardener-bot]="FORGE_GARDENER_TOKEN" + [vault-bot]="FORGE_VAULT_TOKEN" + [supervisor-bot]="FORGE_SUPERVISOR_TOKEN" + [predictor-bot]="FORGE_PREDICTOR_TOKEN" + [architect-bot]="FORGE_ARCHITECT_TOKEN" + ) + + local bot_user bot_pass token token_var + + for bot_user in dev-bot review-bot planner-bot gardener-bot vault-bot supervisor-bot predictor-bot architect-bot; do + bot_pass="bot-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)" + token_var="${bot_token_vars[$bot_user]}" + + # Check if bot user exists + local user_exists=false + if curl -sf --max-time 5 \ + -H "Authorization: token ${admin_token}" \ + "${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then + user_exists=true + fi + + if [ "$user_exists" = false ]; then + echo "Creating bot user: ${bot_user}" + local create_output + if ! create_output=$(_forgejo_exec forgejo admin user create \ + --username "${bot_user}" \ + --password "${bot_pass}" \ + --email "${bot_user}@disinto.local" \ + --must-change-password=false 2>&1); then + echo "Error: failed to create bot user '${bot_user}':" >&2 + echo " ${create_output}" >&2 + exit 1 + fi + # Forgejo 11.x ignores --must-change-password=false on create; + # explicitly clear the flag so basic-auth token creation works. + _forgejo_exec forgejo admin user change-password \ + --username "${bot_user}" \ + --password "${bot_pass}" \ + --must-change-password=false + + # Verify bot user was actually created + if ! curl -sf --max-time 5 \ + -H "Authorization: token ${admin_token}" \ + "${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then + echo "Error: bot user '${bot_user}' not found after creation" >&2 + exit 1 + fi + echo " ${bot_user} user created" + else + echo " ${bot_user} user exists (resetting password for token generation)" + # User exists but may not have a known password. + # Use admin API to reset the password so we can generate a new token. + _forgejo_exec forgejo admin user change-password \ + --username "${bot_user}" \ + --password "${bot_pass}" \ + --must-change-password=false || { + echo "Error: failed to reset password for existing bot user '${bot_user}'" >&2 + exit 1 + } + fi + + # Generate token via API (basic auth as the bot user — Forgejo requires + # basic auth on POST /users/{username}/tokens, token auth is rejected) + # First, try to delete existing tokens to avoid name collision + # Use bot user's own Basic Auth (we just set the password above) + local existing_token_ids + existing_token_ids=$(curl -sf \ + -u "${bot_user}:${bot_pass}" \ + "${forge_url}/api/v1/users/${bot_user}/tokens" 2>/dev/null \ + | jq -r '.[].id // empty' 2>/dev/null) || existing_token_ids="" + + # Delete any existing tokens for this user + if [ -n "$existing_token_ids" ]; then + while IFS= read -r tid; do + [ -n "$tid" ] && curl -sf -X DELETE \ + -u "${bot_user}:${bot_pass}" \ + "${forge_url}/api/v1/users/${bot_user}/tokens/${tid}" >/dev/null 2>&1 || true + done <<< "$existing_token_ids" + fi + + token=$(curl -sf -X POST \ + -u "${bot_user}:${bot_pass}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/users/${bot_user}/tokens" \ + -d "{\"name\":\"disinto-${bot_user}-token\",\"scopes\":[\"all\"]}" 2>/dev/null \ + | jq -r '.sha1 // empty') || token="" + + if [ -z "$token" ]; then + echo "Error: failed to create API token for '${bot_user}'" >&2 + exit 1 + fi + + # Store token in .env under the per-agent variable name + if grep -q "^${token_var}=" "$env_file" 2>/dev/null; then + sed -i "s|^${token_var}=.*|${token_var}=${token}|" "$env_file" + else + printf '%s=%s\n' "$token_var" "$token" >> "$env_file" + fi + export "${token_var}=${token}" + echo " ${bot_user} token generated and saved (${token_var})" + + # Backwards-compat aliases for dev-bot and review-bot + if [ "$bot_user" = "dev-bot" ]; then + export CODEBERG_TOKEN="$token" + elif [ "$bot_user" = "review-bot" ]; then + export REVIEW_BOT_TOKEN="$token" + fi + done + + # Store FORGE_URL in .env if not already present + if ! grep -q '^FORGE_URL=' "$env_file" 2>/dev/null; then + printf 'FORGE_URL=%s\n' "$forge_url" >> "$env_file" + fi + + # Create the repo on Forgejo if it doesn't exist + local org_name="${repo_slug%%/*}" + local repo_name="${repo_slug##*/}" + + # Check if repo already exists + if ! curl -sf --max-time 5 \ + -H "Authorization: token ${FORGE_TOKEN}" \ + "${forge_url}/api/v1/repos/${repo_slug}" >/dev/null 2>&1; then + + # Try creating org first (ignore if exists) + curl -sf -X POST \ + -H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/orgs" \ + -d "{\"username\":\"${org_name}\",\"visibility\":\"public\"}" >/dev/null 2>&1 || true + + # Create repo under org + if ! curl -sf -X POST \ + -H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/orgs/${org_name}/repos" \ + -d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then + # Fallback: create under the human user namespace using admin endpoint + if [ -n "${admin_token:-}" ]; then + if ! curl -sf -X POST \ + -H "Authorization: token ${admin_token}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/admin/users/${org_name}/repos" \ + -d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then + echo "Error: failed to create repo '${repo_slug}' on Forgejo (admin endpoint)" >&2 + exit 1 + fi + elif [ -n "${HUMAN_TOKEN:-}" ]; then + if ! curl -sf -X POST \ + -H "Authorization: token ${HUMAN_TOKEN}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/user/repos" \ + -d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then + echo "Error: failed to create repo '${repo_slug}' on Forgejo (user endpoint)" >&2 + exit 1 + fi + else + echo "Error: failed to create repo '${repo_slug}' — no admin or human token available" >&2 + exit 1 + fi + fi + + # Add all bot users as collaborators with appropriate permissions + # dev-bot: write (PR creation via lib/vault.sh) + # review-bot: read (PR review) + # planner-bot: write (prerequisites.md, memory) + # gardener-bot: write (backlog grooming) + # vault-bot: write (vault items) + # supervisor-bot: read (health monitoring) + # predictor-bot: read (pattern detection) + # architect-bot: write (sprint PRs) + local bot_user bot_perm + declare -A bot_permissions=( + [dev-bot]="write" + [review-bot]="read" + [planner-bot]="write" + [gardener-bot]="write" + [vault-bot]="write" + [supervisor-bot]="read" + [predictor-bot]="read" + [architect-bot]="write" + ) + for bot_user in "${!bot_permissions[@]}"; do + bot_perm="${bot_permissions[$bot_user]}" + curl -sf -X PUT \ + -H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/repos/${repo_slug}/collaborators/${bot_user}" \ + -d "{\"permission\":\"${bot_perm}\"}" >/dev/null 2>&1 || true + done + + # Add disinto-admin as admin collaborator + curl -sf -X PUT \ + -H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/repos/${repo_slug}/collaborators/disinto-admin" \ + -d '{"permission":"admin"}' >/dev/null 2>&1 || true + + echo "Repo: ${repo_slug} created on Forgejo" + else + echo "Repo: ${repo_slug} (already exists on Forgejo)" + fi + + echo "Forge: ${forge_url} (ready)" +} -- 2.49.1