From cbe5df52b2a60785a398e85b3eb188f771fad27e Mon Sep 17 00:00:00 2001 From: johba Date: Sat, 28 Mar 2026 11:13:24 +0000 Subject: [PATCH] feat: add disinto-factory skill for guided setup and operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distributable skill file (SKILL.md) that walks an AI agent through: - First-time factory setup with interactive [ASK] prompts - Post-init verification checklist - Mirror configuration to GitHub/Codeberg - Backlog seeding and issue creation - Ongoing monitoring: agent status, CI, PRs - Unsticking blocked issues Includes: - scripts/factory-status.sh — one-command factory health check - references/troubleshooting.md — common issues from real deployments - Slimmed CLAUDE.md pointing to the skill Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 248 +----------------- disinto-factory/SKILL.md | 210 +++++++++++++++ disinto-factory/references/troubleshooting.md | 53 ++++ disinto-factory/scripts/factory-status.sh | 44 ++++ 4 files changed, 318 insertions(+), 237 deletions(-) create mode 100644 disinto-factory/SKILL.md create mode 100644 disinto-factory/references/troubleshooting.md create mode 100755 disinto-factory/scripts/factory-status.sh diff --git a/CLAUDE.md b/CLAUDE.md index cdfa205..63927a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,246 +1,20 @@ -# CLAUDE.md — Skill file for disinto +# CLAUDE.md -## What is disinto? +This repo is **disinto** — an autonomous code factory. -Disinto is an autonomous code factory — bash scripts + Claude CLI that automate the full -software development lifecycle: picking up issues, implementing via Claude, creating PRs, -running CI, reviewing, merging, and mirroring to external forges. +For setup and operations, load the `disinto-factory` skill from `disinto-factory/SKILL.md`. -Read `VISION.md` for the project philosophy, `AGENTS.md` for architecture, and -`BOOTSTRAP.md` for setup instructions. - -## Setting up a new factory instance - -### Prerequisites - -- An LXD container (Debian 12) with Docker, git, jq, curl, tmux, python3 (>=3.11) -- `claude` CLI installed and authenticated -- SSH key for mirror pushes (added to GitHub/Codeberg) - -### First-time setup - -1. **Clone the repo** and cd into it: - ```bash - git clone https://codeberg.org/johba/disinto.git && cd disinto - ``` - -2. **Run init** against the repo you want the factory to develop: - ```bash - bin/disinto init https://codeberg.org/org/repo --yes - ``` - For self-development (factory develops itself): - ```bash - bin/disinto init https://codeberg.org/johba/disinto --yes --repo-root $(pwd) - ``` - -3. **Verify the stack** came up: - ```bash - docker ps --format "table {{.Names}}\t{{.Status}}" - ``` - Expected: forgejo (Up), woodpecker (healthy), woodpecker-agent (healthy), agents (Up), - edge (Up), staging (Up). - -4. **Check WOODPECKER_TOKEN** was generated: - ```bash - grep WOODPECKER_TOKEN .env - ``` - If empty, see "Known issues" below. - -5. **Verify agent cron** is running: - ```bash - docker exec -u agent disinto-agents-1 crontab -l -u agent - ``` - -6. **Set up mirrors** (optional): - Edit `projects/.toml`: - ```toml - [mirrors] - github = "git@github.com:Org/repo.git" - codeberg = "git@codeberg.org:user/repo.git" - ``` - Ensure `~/.ssh` is mounted into the agents container and SSH keys are added - to the remote forges. The compose template includes the mount; just add your - public key to GitHub/Codeberg. - -### Post-init checklist - -- [ ] Stack containers all running and healthy -- [ ] `WOODPECKER_TOKEN` in `.env` is non-empty -- [ ] `projects/.toml` exists with correct `repo_root` and `primary_branch` -- [ ] Labels exist on Forgejo repo: backlog, in-progress, blocked, tech-debt, etc. -- [ ] Agent container can reach Forgejo API: `docker exec disinto-agents-1 bash -c "source /home/agent/disinto/.env && curl -sf http://forgejo:3000/api/v1/version"` -- [ ] Agent repo is cloned: `docker exec -u agent disinto-agents-1 ls /home/agent/repos/` - - If not: `docker exec disinto-agents-1 chown -R agent:agent /home/agent/repos && docker exec -u agent disinto-agents-1 bash -c "source /home/agent/disinto/.env && git clone http://dev-bot:\${FORGE_TOKEN}@forgejo:3000/org/repo.git /home/agent/repos/"` -- [ ] Create backlog issues on Forgejo for the factory to work on - -## Checking on the factory - -### Agent status - -```bash -# Are agents running? -docker exec disinto-agents-1 bash -c " - for f in /proc/[0-9]*/cmdline; do - cmd=\$(tr '\0' ' ' < \$f 2>/dev/null) - echo \$cmd | grep -qi claude && echo PID \$(echo \$f | cut -d/ -f3): running - done -" - -# Latest dev-agent activity -docker exec disinto-agents-1 tail -20 /home/agent/data/logs/dev/dev-agent.log - -# Latest poll activity -docker exec disinto-agents-1 tail -20 /home/agent/data/logs/dev/dev-agent-.log -``` - -### Issue and PR status - -```bash -source .env -# Open issues -curl -sf "http://localhost:3000/api/v1/repos///issues?state=open" \ - -H "Authorization: token $FORGE_TOKEN" | jq -r '.[] | "#\(.number) [\(.labels | map(.name) | join(","))] \(.title)"' - -# Open PRs -curl -sf "http://localhost:3000/api/v1/repos///pulls?state=open" \ - -H "Authorization: token $FORGE_TOKEN" | jq -r '.[] | "PR #\(.number) [\(.head.ref)] \(.title)"' -``` - -### CI status - -```bash -source .env -# Check pipelines (requires session cookie + CSRF for WP v3 API) -WP_CSRF=$(curl -sf -b "user_sess=$WOODPECKER_TOKEN" http://localhost:8000/web-config.js \ - | sed -n 's/.*WOODPECKER_CSRF = "\([^"]*\)".*/\1/p') -curl -sf -b "user_sess=$WOODPECKER_TOKEN" -H "X-CSRF-Token: $WP_CSRF" \ - "http://localhost:8000/api/repos/1/pipelines?page=1&per_page=5" \ - | jq '.[] | {number, status, event}' -``` - -### Unsticking a blocked issue - -When a dev-agent run fails (CI timeout, implementation error), the issue gets labeled -`blocked`. To retry: - -```bash -source .env -# 1. Close stale PR if any -curl -sf -X PATCH "http://localhost:3000/api/v1/repos///pulls/" \ - -H "Authorization: token $FORGE_TOKEN" -H "Content-Type: application/json" \ - -d '{"state":"closed"}' - -# 2. Delete stale branch -curl -sf -X DELETE "http://localhost:3000/api/v1/repos///branches/fix/issue-" \ - -H "Authorization: token $FORGE_TOKEN" - -# 3. Remove locks -docker exec disinto-agents-1 rm -f /tmp/dev-agent-*.json /tmp/dev-agent-*.lock - -# 4. Relabel issue to backlog -BACKLOG_ID=$(curl -sf "http://localhost:3000/api/v1/repos///labels" \ - -H "Authorization: token $FORGE_TOKEN" | jq -r '.[] | select(.name=="backlog") | .id') -curl -sf -X PUT "http://localhost:3000/api/v1/repos///issues//labels" \ - -H "Authorization: token $FORGE_TOKEN" -H "Content-Type: application/json" \ - -d "{\"labels\":[$BACKLOG_ID]}" - -# 5. Update agent repo to latest main -docker exec -u agent disinto-agents-1 bash -c \ - "cd /home/agent/repos/ && git fetch origin && git reset --hard origin/main" -``` - -The next cron cycle (every 5 minutes) will pick it up. - -### Triggering a poll manually - -```bash -docker exec -u agent disinto-agents-1 bash -c \ - "cd /home/agent/disinto && bash dev/dev-poll.sh projects/.toml" -``` - -## Filing issues - -The factory picks up issues labeled `backlog`. The dev-agent: -1. Claims the issue (labels it `in-progress`) -2. Creates a worktree on branch `fix/issue-` -3. Runs Claude to implement the fix -4. Pushes, creates a PR, waits for CI -5. Requests review from review-bot -6. Merges on approval, pushes to mirrors - -Issue body should contain enough context for Claude to implement it. Include: -- What's wrong or what needs to change -- Which files are affected -- Any design constraints -- Dependency references: `Depends-on: #N` (dev-agent checks these before starting) - -Use labels: -- `backlog` — ready for the dev-agent to pick up -- `blocked` — not ready (missing dependency, needs investigation) -- `in-progress` — claimed by dev-agent (set automatically) -- No label — parked, not for the factory to touch - -## Reverse tunnel access (for browser UI) - -If running in an LXD container with a reverse SSH tunnel to a jump host: - -```bash -# On the LXD container, add to /etc/systemd/system/reverse-tunnel.service: -# -R 127.0.0.1:13000:localhost:3000 (Forgejo) -# -R 127.0.0.1:18000:localhost:8000 (Woodpecker) - -# From your machine: -ssh -L 3000:localhost:13000 user@jump-host -# Then open http://localhost:3000 in your browser -``` - -Forgejo admin login: `disinto-admin` / set during init (or reset with -`docker exec disinto-forgejo-1 su -c "forgejo admin user change-password --username disinto-admin --password --must-change-password=false" git`). - -## Known issues & workarounds - -### WP CI agent needs host networking in LXD - -Docker bridge networking inside LXD breaks gRPC/HTTP2. The compose template uses -`network_mode: host` + `privileged: true` for the WP agent, connecting via -`localhost:9000`. This is baked into the template and works on regular VMs too. - -### CI step containers need Docker network - -The WP agent spawns CI containers that need to reach Forgejo for git clone. -`WOODPECKER_BACKEND_DOCKER_NETWORK: disinto_disinto-net` is set in the compose -template to put CI containers on the compose network. - -### Forgejo webhook allowlist - -Forgejo blocks outgoing webhooks by default. The compose template sets -`FORGEJO__webhook__ALLOWED_HOST_LIST: "private"` to allow delivery to -Docker-internal hosts. - -### OAuth2 token generation during init - -The init script drives a Forgejo OAuth2 flow to generate a Woodpecker token. -This requires rewriting URL-encoded Docker-internal hostnames and submitting -all Forgejo grant form fields. If token generation fails, check Forgejo logs -for "Unregistered Redirect URI" errors. - -### Woodpecker UI not accessible via tunnel - -The WP OAuth login redirects use Docker-internal hostnames that browsers can't -resolve. Use the Forgejo UI instead — CI results appear as commit statuses on PRs. - -### PROJECT_REPO_ROOT inside agents container - -The agents container needs `PROJECT_REPO_ROOT` set in its environment to -`/home/agent/repos/` (not the host path from the TOML). The compose -template includes this. If the agent fails with "cd: no such file or directory", -check this env var. +Quick references: +- `AGENTS.md` — per-agent architecture and file-level docs +- `VISION.md` — project philosophy +- `BOOTSTRAP.md` — detailed init walkthrough +- `disinto-factory/references/troubleshooting.md` — common issues and fixes +- `disinto-factory/scripts/factory-status.sh` — quick status check ## Code conventions -See `AGENTS.md` for per-file architecture docs and coding conventions. -Key principles: - Bash for checks, AI for judgment -- Zero LLM tokens when idle (cron checks are pure bash) +- Zero LLM tokens when idle (cron polls are pure bash) - Fire-and-forget mirror pushes (never block the pipeline) - Issues are the unit of work; PRs are the delivery mechanism +- See `AGENTS.md` for per-file watermarks and coding conventions diff --git a/disinto-factory/SKILL.md b/disinto-factory/SKILL.md new file mode 100644 index 0000000..45186fc --- /dev/null +++ b/disinto-factory/SKILL.md @@ -0,0 +1,210 @@ +--- +name: disinto-factory +description: Set up and operate a disinto autonomous code factory. Use when bootstrapping a new factory instance, checking on agents and CI, managing the backlog, or troubleshooting the stack. +--- + +# Disinto Factory + +You are helping the user set up and operate a **disinto autonomous code factory** — a system +of bash scripts and Claude CLI that automates the full development lifecycle: picking up +issues, implementing via Claude, creating PRs, running CI, reviewing, merging, and mirroring. + +## First-time setup + +Walk the user through these steps interactively. Ask questions where marked with [ASK]. + +### 1. Environment + +[ASK] Where will the factory run? Options: +- **LXD container** (recommended for isolation) — need Debian 12, Docker, nesting enabled +- **Bare VM or server** — need Debian/Ubuntu with Docker +- **Existing container** — check prerequisites + +Verify prerequisites: +```bash +docker --version && git --version && jq --version && curl --version && tmux -V && python3 --version && claude --version +``` + +Any missing tool — help the user install it before continuing. + +### 2. Clone and init + +```bash +git clone https://codeberg.org/johba/disinto.git && cd disinto +``` + +[ASK] What repo should the factory develop? Options: +- **Itself** (self-development): `bin/disinto init https://codeberg.org/johba/disinto --yes --repo-root $(pwd)` +- **Another project**: `bin/disinto init --yes` + +Run the init and watch for: +- All bot users created (dev-bot, review-bot, etc.) +- `WOODPECKER_TOKEN` generated and saved +- Stack containers all started + +### 3. Post-init verification + +Run this checklist — fix any failures before proceeding: + +```bash +# Stack healthy? +docker ps --format "table {{.Names}}\t{{.Status}}" +# Expected: forgejo, woodpecker (healthy), woodpecker-agent (healthy), agents, edge, staging + +# Token generated? +grep WOODPECKER_TOKEN .env | grep -v "^$" && echo "OK" || echo "MISSING — see references/troubleshooting.md" + +# Agent cron active? +docker exec -u agent disinto-agents-1 crontab -l -u agent + +# Agent can reach Forgejo? +docker exec disinto-agents-1 bash -c "source /home/agent/disinto/.env && curl -sf http://forgejo:3000/api/v1/version | jq .version" + +# Agent repo cloned? +docker exec -u agent disinto-agents-1 ls /home/agent/repos/ +``` + +If the agent repo is missing, clone it: +```bash +docker exec disinto-agents-1 chown -R agent:agent /home/agent/repos +docker exec -u agent disinto-agents-1 bash -c "source /home/agent/disinto/.env && git clone http://dev-bot:\${FORGE_TOKEN}@forgejo:3000//.git /home/agent/repos/" +``` + +### 4. Mirrors (optional) + +[ASK] Should the factory mirror to external forges? If yes, which? +- GitHub: need repo URL and SSH key added to GitHub account +- Codeberg: need repo URL and SSH key added to Codeberg account + +Show the user their public key: +```bash +cat ~/.ssh/id_ed25519.pub +``` + +Test SSH access: +```bash +ssh -T git@github.com 2>&1; ssh -T git@codeberg.org 2>&1 +``` + +If SSH host keys are missing: `ssh-keyscan github.com codeberg.org >> ~/.ssh/known_hosts 2>/dev/null` + +Edit `projects/.toml` to add mirrors: +```toml +[mirrors] +github = "git@github.com:Org/repo.git" +codeberg = "git@codeberg.org:user/repo.git" +``` + +Test with a manual push: +```bash +source .env && source lib/env.sh && export PROJECT_TOML=projects/.toml && source lib/load-project.sh && source lib/mirrors.sh && mirror_push +``` + +### 5. Seed the backlog + +[ASK] What should the factory work on first? Brainstorm with the user. + +Help them create issues on the local Forgejo. Each issue needs: +- A clear title prefixed with `fix:`, `feat:`, or `chore:` +- A body describing what to change, which files, and any constraints +- The `backlog` label (so the dev-agent picks it up) + +```bash +source .env +BACKLOG_ID=$(curl -sf "http://localhost:3000/api/v1/repos///labels" \ + -H "Authorization: token $FORGE_TOKEN" | jq -r '.[] | select(.name=="backlog") | .id') + +curl -sf -X POST "http://localhost:3000/api/v1/repos///issues" \ + -H "Authorization: token $FORGE_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"title\": \"\", \"body\": \"<body>\", \"labels\": [$BACKLOG_ID]}" +``` + +For issues with dependencies, add `Depends-on: #N` in the body — the dev-agent checks +these before starting. + +Use labels: +- `backlog` — ready for the dev-agent +- `blocked` — parked, not for the factory +- No label — tracked but not for autonomous work + +### 6. Watch it work + +The dev-agent polls every 5 minutes. Trigger manually to see it immediately: +```bash +docker exec -u agent disinto-agents-1 bash -c "cd /home/agent/disinto && bash dev/dev-poll.sh projects/<name>.toml" +``` + +Then monitor: +```bash +# Watch the agent work +docker exec disinto-agents-1 tail -f /home/agent/data/logs/dev/dev-agent.log + +# Check for Claude running +docker exec disinto-agents-1 bash -c "for f in /proc/[0-9]*/cmdline; do cmd=\$(tr '\0' ' ' < \$f 2>/dev/null); echo \$cmd | grep -q 'claude.*-p' && echo 'Claude is running'; done" +``` + +## Ongoing operations + +### Check factory status + +```bash +source .env + +# Issues +curl -sf "http://localhost:3000/api/v1/repos/<org>/<repo>/issues?state=open" \ + -H "Authorization: token $FORGE_TOKEN" \ + | jq -r '.[] | "#\(.number) [\(.labels | map(.name) | join(","))] \(.title)"' + +# PRs +curl -sf "http://localhost:3000/api/v1/repos/<org>/<repo>/pulls?state=open" \ + -H "Authorization: token $FORGE_TOKEN" \ + | jq -r '.[] | "PR #\(.number) [\(.head.ref)] \(.title)"' + +# Agent logs +docker exec disinto-agents-1 tail -20 /home/agent/data/logs/dev/dev-agent.log +``` + +### Check CI + +```bash +source .env +WP_CSRF=$(curl -sf -b "user_sess=$WOODPECKER_TOKEN" http://localhost:8000/web-config.js \ + | sed -n 's/.*WOODPECKER_CSRF = "\([^"]*\)".*/\1/p') +curl -sf -b "user_sess=$WOODPECKER_TOKEN" -H "X-CSRF-Token: $WP_CSRF" \ + "http://localhost:8000/api/repos/1/pipelines?page=1&per_page=5" \ + | jq '.[] | {number, status, event}' +``` + +### Unstick a blocked issue + +When a dev-agent run fails (CI timeout, implementation error), the issue gets labeled `blocked`: + +1. Close stale PR and delete the branch +2. `docker exec disinto-agents-1 rm -f /tmp/dev-agent-*.json /tmp/dev-agent-*.lock` +3. Relabel the issue to `backlog` +4. Update agent repo: `docker exec -u agent disinto-agents-1 bash -c "cd /home/agent/repos/<name> && git fetch origin && git reset --hard origin/main"` + +### Access Forgejo UI + +If running in an LXD container with reverse tunnel: +```bash +# From your machine: +ssh -L 3000:localhost:13000 user@jump-host +# Open http://localhost:3000 +``` + +Reset admin password if needed: +```bash +docker exec disinto-forgejo-1 su -c "forgejo admin user change-password --username disinto-admin --password <new-pw> --must-change-password=false" git +``` + +## Important context + +- Read `AGENTS.md` for per-agent architecture and file-level docs +- Read `VISION.md` for project philosophy +- Read `BOOTSTRAP.md` for detailed init walkthrough +- The factory uses a single internal Forgejo as its forge, regardless of where mirrors go +- Dev-agent uses `claude -p --resume` for session continuity across CI/review cycles +- Mirror pushes happen automatically after every merge (fire-and-forget) +- Cron schedule: dev-poll every 5min, review-poll every 5min, gardener 4x/day diff --git a/disinto-factory/references/troubleshooting.md b/disinto-factory/references/troubleshooting.md new file mode 100644 index 0000000..0d1b282 --- /dev/null +++ b/disinto-factory/references/troubleshooting.md @@ -0,0 +1,53 @@ +# Troubleshooting + +## WOODPECKER_TOKEN empty after init + +The OAuth2 flow failed. Common causes: + +1. **URL-encoded redirect_uri mismatch**: Forgejo logs show "Unregistered Redirect URI". + The init script must rewrite both plain and URL-encoded Docker hostnames. + +2. **Forgejo must_change_password**: Admin user was created with forced password change. + The init script calls `--must-change-password=false` but Forgejo 11.x sometimes ignores it. + +3. **WOODPECKER_OPEN not set**: WP refuses first-user OAuth registration without it. + +Manual fix: reset admin password and re-run the token generation manually, or +use the Woodpecker UI to create a token. + +## WP CI agent won't connect (DeadlineExceeded) + +gRPC over Docker bridge fails in LXD (and possibly other nested container environments). +The compose template uses `network_mode: host` + `privileged: true` for the agent. +If you see this error, check: +- Server exposes port 9000: `grep "9000:9000" docker-compose.yml` +- Agent uses `localhost:9000`: `grep "WOODPECKER_SERVER" docker-compose.yml` +- Agent has `network_mode: host` + +## CI clone fails (could not resolve host) + +CI containers need to resolve Docker service names (e.g., `forgejo`). +Check `WOODPECKER_BACKEND_DOCKER_NETWORK` is set on the agent. + +## Webhooks not delivered + +Forgejo blocks outgoing webhooks by default. Check: +```bash +docker logs disinto-forgejo-1 2>&1 | grep "webhook.*ALLOWED_HOST_LIST" +``` +Fix: add `FORGEJO__webhook__ALLOWED_HOST_LIST: "private"` to Forgejo environment. + +Also verify the webhook exists: +```bash +curl -sf -u "disinto-admin:<password>" "http://localhost:3000/api/v1/repos/<org>/<repo>/hooks" | jq '.[].config.url' +``` +If missing, deactivate and reactivate the repo in Woodpecker to auto-create it. + +## Dev-agent fails with "cd: no such file or directory" + +`PROJECT_REPO_ROOT` inside the agents container points to a host path that doesn't +exist in the container. Check the compose env: +```bash +docker inspect disinto-agents-1 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep PROJECT_REPO_ROOT +``` +Should be `/home/agent/repos/<name>`, not `/home/<user>/<name>`. diff --git a/disinto-factory/scripts/factory-status.sh b/disinto-factory/scripts/factory-status.sh new file mode 100755 index 0000000..457ac9a --- /dev/null +++ b/disinto-factory/scripts/factory-status.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# factory-status.sh — Quick status check for a running disinto factory +set -euo pipefail + +FACTORY_ROOT="${1:-$(cd "$(dirname "$0")/../.." && pwd)}" +source "${FACTORY_ROOT}/.env" 2>/dev/null || { echo "No .env found at ${FACTORY_ROOT}"; exit 1; } + +FORGE_URL="${FORGE_URL:-http://localhost:3000}" +REPO=$(grep '^repo ' "${FACTORY_ROOT}/projects/"*.toml 2>/dev/null | head -1 | sed 's/.*= *"//;s/"//') +[ -z "$REPO" ] && { echo "No project TOML found"; exit 1; } + +echo "=== Stack ===" +docker ps --format "table {{.Names}}\t{{.Status}}" 2>/dev/null | grep disinto + +echo "" +echo "=== Open Issues ===" +curl -sf "${FORGE_URL}/api/v1/repos/${REPO}/issues?state=open&limit=20" \ + -H "Authorization: token ${FORGE_TOKEN}" \ + | jq -r '.[] | "#\(.number) [\(.labels | map(.name) | join(","))] \(.title)"' 2>/dev/null || echo "(API error)" + +echo "" +echo "=== Open PRs ===" +curl -sf "${FORGE_URL}/api/v1/repos/${REPO}/pulls?state=open&limit=10" \ + -H "Authorization: token ${FORGE_TOKEN}" \ + | jq -r '.[] | "PR #\(.number) [\(.head.ref)] \(.title)"' 2>/dev/null || echo "none" + +echo "" +echo "=== Agent Activity ===" +docker exec disinto-agents-1 bash -c "tail -5 /home/agent/data/logs/dev/dev-agent.log 2>/dev/null" || echo "(no logs)" + +echo "" +echo "=== Claude Running? ===" +docker exec disinto-agents-1 bash -c " + found=false + for f in /proc/[0-9]*/cmdline; do + cmd=\$(tr '\0' ' ' < \"\$f\" 2>/dev/null) + if echo \"\$cmd\" | grep -q 'claude.*-p'; then found=true; echo 'Yes — Claude is actively working'; break; fi + done + \$found || echo 'No — idle' +" 2>/dev/null + +echo "" +echo "=== Mirrors ===" +cd "${FACTORY_ROOT}" 2>/dev/null && git remote -v | grep -E 'github|codeberg' | grep push || echo "none configured"