fix: Replace Codeberg dependency with local Forgejo instance (#611)

- Add setup_forge() to bin/disinto: provisions Forgejo via Docker,
  creates admin + bot users (dev-bot, review-bot), generates API
  tokens, creates repo, and pushes code — all automated
- Rename env vars: CODEBERG_TOKEN→FORGE_TOKEN, REVIEW_BOT_TOKEN→
  FORGE_REVIEW_TOKEN, CODEBERG_REPO→FORGE_REPO, CODEBERG_API→
  FORGE_API, CODEBERG_WEB→FORGE_WEB, CODEBERG_BOT_USERNAMES→
  FORGE_BOT_USERNAMES (with backwards-compat fallbacks)
- Rename API helpers: codeberg_api()→forge_api(), codeberg_api_all()
  →forge_api_all() (with compat aliases)
- Add forge_url field to project TOML; load-project.sh derives
  FORGE_API/FORGE_WEB from forge_url + repo
- Update parse_repo_slug() to accept any host URL, not just codeberg
- Forgejo data stored under ~/.disinto/forgejo/ (not in factory repo)
- Update all 58 files: agent scripts, formulas, docs, site HTML

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-23 16:57:12 +00:00
parent 39d30faf45
commit a66bd91721
58 changed files with 863 additions and 628 deletions

View file

@ -3,24 +3,33 @@
# NEVER commit .env to the repo. # NEVER commit .env to the repo.
# ── Per-project config ──────────────────────────────────────────────────── # ── Per-project config ────────────────────────────────────────────────────
# Project-specific settings (CODEBERG_REPO, PROJECT_REPO_ROOT, PRIMARY_BRANCH, # Project-specific settings (FORGE_REPO, PROJECT_REPO_ROOT, PRIMARY_BRANCH,
# WOODPECKER_REPO_ID) now live in projects/*.toml — see projects/harb.toml # WOODPECKER_REPO_ID) now live in projects/*.toml — see projects/harb.toml
# for an example. Do NOT set them here; they leak into every session. # for an example. Do NOT set them here; they leak into every session.
# ── Forge (Forgejo) ─────────────────────────────────────────────────────
# Base URL for the local Forgejo instance. disinto init provisions this.
FORGE_URL=http://localhost:3000
# ── Auth tokens ─────────────────────────────────────────────────────────── # ── Auth tokens ───────────────────────────────────────────────────────────
# Dev-agent token: push branches, create PRs, merge PRs. # Dev-agent token: push branches, create PRs, merge PRs.
# Use the dedicated bot account (e.g. factory_bot / disinto_dev). # Use the dedicated bot account (e.g. dev-bot).
# Branch protection: this account must be in the merge whitelist. # Branch protection: this account must be in the merge whitelist.
CODEBERG_TOKEN= FORGE_TOKEN=
# Review-agent token: post review comments and submit formal approvals. # Review-agent token: post review comments and submit formal approvals.
# Use the human/admin account (e.g. johba). # Use the review bot account (e.g. review-bot).
# Branch protection: this account must be in the approvals whitelist. # Branch protection: this account must be in the approvals whitelist.
REVIEW_BOT_TOKEN= FORGE_REVIEW_TOKEN=
# Comma-separated Codeberg usernames to filter from issue comments. # Comma-separated forge usernames to filter from issue comments.
# The token owner is auto-detected; add extra bot accounts here if needed. # The token owner is auto-detected; add extra bot accounts here if needed.
CODEBERG_BOT_USERNAMES= FORGE_BOT_USERNAMES=
# ── Backwards compatibility ───────────────────────────────────────────────
# If CODEBERG_TOKEN is set but FORGE_TOKEN is not, env.sh falls back to
# CODEBERG_TOKEN automatically (same for REVIEW_BOT_TOKEN, CODEBERG_REPO,
# CODEBERG_BOT_USERNAMES). No action needed for existing deployments.
# ── Woodpecker CI ───────────────────────────────────────────────────────── # ── Woodpecker CI ─────────────────────────────────────────────────────────
WOODPECKER_TOKEN= WOODPECKER_TOKEN=

View file

@ -92,7 +92,7 @@ echo "=== 2/2 Function resolution ==="
# Functions provided by shared lib files (available to all agent scripts via source). # Functions provided by shared lib files (available to all agent scripts via source).
# #
# Included — these are inline-sourced by agent scripts: # Included — these are inline-sourced by agent scripts:
# lib/env.sh — sourced by every agent (log, codeberg_api, etc.) # lib/env.sh — sourced by every agent (log, forge_api, etc.)
# lib/agent-session.sh — sourced by orchestrators (create_agent_session, monitor_phase_loop, etc.) # lib/agent-session.sh — sourced by orchestrators (create_agent_session, monitor_phase_loop, etc.)
# lib/ci-helpers.sh — sourced by pollers and review (ci_passed, classify_pipeline_failure, etc.) # lib/ci-helpers.sh — sourced by pollers and review (ci_passed, classify_pipeline_failure, etc.)
# lib/load-project.sh — sourced by env.sh when PROJECT_TOML is set # lib/load-project.sh — sourced by env.sh when PROJECT_TOML is set

View file

@ -4,7 +4,7 @@
## What this repo is ## What this repo is
Disinto is an autonomous code factory. It manages eight agents (dev, review, Disinto is an autonomous code factory. It manages eight agents (dev, review,
gardener, supervisor, planner, predictor, action, vault) that pick up issues from Codeberg, gardener, supervisor, planner, predictor, action, vault) that pick up issues from forge,
implement them, review PRs, plan from the vision, gate dangerous actions, and implement them, review PRs, plan from the vision, gate dangerous actions, and
keep the system healthy — all via cron and `claude -p`. keep the system healthy — all via cron and `claude -p`.
@ -42,7 +42,7 @@ disinto/
- **Shell**: bash (all agents are bash scripts) - **Shell**: bash (all agents are bash scripts)
- **AI**: `claude -p` (one-shot) or `claude` (interactive/tmux sessions) - **AI**: `claude -p` (one-shot) or `claude` (interactive/tmux sessions)
- **CI**: Woodpecker CI (queried via REST API + Postgres) - **CI**: Woodpecker CI (queried via REST API + Postgres)
- **VCS**: Codeberg (git + Gitea REST API) - **VCS**: Forgejo (git + Gitea-compatible REST API)
- **Notifications**: Matrix (optional) - **Notifications**: Matrix (optional)
## Coding conventions ## Coding conventions

View file

@ -1,18 +1,36 @@
# Bootstrapping a New Project # Bootstrapping a New Project
How to point disinto at a new target project and get all four agents running. How to point disinto at a new target project and get all agents running.
## Prerequisites ## Prerequisites
Before starting, ensure you have: Before starting, ensure you have:
- [ ] A **Codeberg repo** with at least one issue labeled `backlog` - [ ] A **git repo** (GitHub, Codeberg, or any URL) with at least one issue labeled `backlog`
- [ ] A **Woodpecker CI** pipeline (`.woodpecker/` dir with at least one `.yml`) - [ ] A **Woodpecker CI** pipeline (`.woodpecker/` dir with at least one `.yml`)
- [ ] A **second Codeberg account** for the review bot (branch protection requires reviews from a different user) - [ ] **Docker** installed (for local Forgejo provisioning) — or a running Forgejo instance
- [ ] A **local clone** of the target repo on the same machine as disinto - [ ] A **local clone** of the target repo on the same machine as disinto
- [ ] `claude` CLI installed and authenticated (`claude --version`) - [ ] `claude` CLI installed and authenticated (`claude --version`)
- [ ] `tmux` installed (`tmux -V`) — required for persistent dev sessions (issue #80+) - [ ] `tmux` installed (`tmux -V`) — required for persistent dev sessions (issue #80+)
## Quick Start
The fastest path is `disinto init`, which provisions a local Forgejo instance, creates bot users and tokens, clones the repo, and sets up cron — all in one command:
```bash
disinto init https://github.com/org/repo
```
This will:
1. Start a local Forgejo instance via Docker (at `http://localhost:3000`)
2. Create admin + bot users (dev-bot, review-bot) with API tokens
3. Create the repo on Forgejo and push your code
4. Generate a `projects/<name>.toml` config
5. Create standard labels (backlog, in-progress, blocked, etc.)
6. Install cron entries for the agents
No external accounts or tokens needed.
## 1. Configure `.env` ## 1. Configure `.env`
```bash ```bash
@ -22,19 +40,15 @@ cp .env.example .env
Fill in: Fill in:
```bash ```bash
# ── Target project ────────────────────────────────────────── # ── Forge (auto-populated by disinto init) ─────────────────
CODEBERG_REPO=org/project # Codeberg slug FORGE_URL=http://localhost:3000 # local Forgejo instance
PROJECT_REPO_ROOT=/home/you/project # absolute path to local clone FORGE_TOKEN= # dev-bot token (auto-generated)
PRIMARY_BRANCH=main # main or master FORGE_REVIEW_TOKEN= # review-bot token (auto-generated)
# ── Auth ────────────────────────────────────────────────────
# CODEBERG_TOKEN= # or use ~/.netrc (machine codeberg.org)
REVIEW_BOT_TOKEN=tok_xxxxxxxx # the second account's API token
# ── Woodpecker CI ─────────────────────────────────────────── # ── Woodpecker CI ───────────────────────────────────────────
WOODPECKER_TOKEN=tok_xxxxxxxx WOODPECKER_TOKEN=tok_xxxxxxxx
WOODPECKER_SERVER=http://localhost:8000 WOODPECKER_SERVER=http://localhost:8000
WOODPECKER_REPO_ID=2 # numeric — find via Woodpecker UI or API # WOODPECKER_REPO_ID — now per-project, set in projects/*.toml [ci] section
# Woodpecker Postgres (for direct pipeline queries) # Woodpecker Postgres (for direct pipeline queries)
WOODPECKER_DB_PASSWORD=secret WOODPECKER_DB_PASSWORD=secret
@ -52,23 +66,35 @@ WOODPECKER_DB_NAME=woodpecker
CLAUDE_TIMEOUT=7200 # seconds per Claude invocation CLAUDE_TIMEOUT=7200 # seconds per Claude invocation
``` ```
### Backwards compatibility
If you have an existing deployment using `CODEBERG_TOKEN` / `REVIEW_BOT_TOKEN` in `.env`, those still work — `env.sh` falls back to the old names automatically. No migration needed.
## 2. Configure Project TOML ## 2. Configure Project TOML
Each project needs a `projects/<name>.toml` file with box-specific settings Each project needs a `projects/<name>.toml` file with box-specific settings
(absolute paths, Woodpecker CI IDs, Matrix credentials). These files are (absolute paths, Woodpecker CI IDs, Matrix credentials, forge URL). These files are
**gitignored** — they are local installation config, not shared code. **gitignored** — they are local installation config, not shared code.
To create one: To create one:
```bash ```bash
# Automatic — generates TOML, clones repo, sets up cron: # Automatic — generates TOML, clones repo, sets up cron:
disinto init https://codeberg.org/org/repo disinto init https://github.com/org/repo
# Manual — copy a template and fill in your values: # Manual — copy a template and fill in your values:
cp projects/myproject.toml.example projects/myproject.toml cp projects/myproject.toml.example projects/myproject.toml
vim projects/myproject.toml vim projects/myproject.toml
``` ```
The `forge_url` field in the TOML tells all agents where to find the forge API:
```toml
name = "myproject"
repo = "org/myproject"
forge_url = "http://localhost:3000"
```
The repo ships `projects/*.toml.example` templates showing the expected The repo ships `projects/*.toml.example` templates showing the expected
structure. See any `.toml.example` file for the full field reference. structure. See any `.toml.example` file for the full field reference.
@ -133,7 +159,7 @@ The dev-agent reads this file via `claude -p` before implementing any issue. The
### Required: Issue labels ### Required: Issue labels
Create two labels on the Codeberg repo: `disinto init` creates these automatically. If setting up manually, create these labels on the forge repo:
| Label | Purpose | | Label | Purpose |
|-------|---------| |-------|---------|
@ -150,7 +176,7 @@ Optional but recommended:
### Required: Branch protection ### Required: Branch protection
On Codeberg, set up branch protection for your primary branch: On Forgejo, set up branch protection for your primary branch:
- **Require pull request reviews**: enabled - **Require pull request reviews**: enabled
- **Required approvals**: 1 (from the review bot account) - **Required approvals**: 1 (from the review bot account)
@ -159,8 +185,8 @@ On Codeberg, set up branch protection for your primary branch:
This ensures dev-agent can't merge its own PRs — it must wait for review-agent (running as the bot account) to approve. This ensures dev-agent can't merge its own PRs — it must wait for review-agent (running as the bot account) to approve.
> **Common pitfall:** Approvals alone are not enough. You must also: > **Common pitfall:** Approvals alone are not enough. You must also:
> 1. Add `review_bot` as a **write** collaborator on the repo (Settings → Collaborators) > 1. Add `review-bot` as a **write** collaborator on the repo (Settings → Collaborators)
> 2. Set both `approvals_whitelist_username` **and** `merge_whitelist_usernames` to include `review_bot` in the branch protection rule > 2. Set both `approvals_whitelist_username` **and** `merge_whitelist_usernames` to include `review-bot` in the branch protection rule
> >
> Without write access, the bot's approval is counted but the merge API returns HTTP 405. > Without write access, the bot's approval is counted but the merge API returns HTTP 405.
@ -361,7 +387,7 @@ Meanwhile:
|---------|-------|-----| |---------|-------|-----|
| Dev-agent for project B never starts | Shared lock file path | Each TOML `name` field must be unique — lock is `/tmp/dev-agent-{name}.lock` | | Dev-agent for project B never starts | Shared lock file path | Each TOML `name` field must be unique — lock is `/tmp/dev-agent-{name}.lock` |
| Review-poll skips all PRs | CI gate with no CI configured | Set `woodpecker_repo_id = 0` in the TOML `[ci]` section to bypass the CI check | | Review-poll skips all PRs | CI gate with no CI configured | Set `woodpecker_repo_id = 0` in the TOML `[ci]` section to bypass the CI check |
| Approved PRs never merge (HTTP 405) | `review_bot` not in merge/approvals whitelist | Add as write collaborator; set both `approvals_whitelist_username` and `merge_whitelist_usernames` in branch protection | | Approved PRs never merge (HTTP 405) | `review-bot` not in merge/approvals whitelist | Add as write collaborator; set both `approvals_whitelist_username` and `merge_whitelist_usernames` in branch protection |
| Dev-agent churns through issues without waiting for open PRs to land | No single-threaded enforcement | `WAITING_PRS` check in dev-poll holds new work — verify TOML `name` is consistent across invocations | | Dev-agent churns through issues without waiting for open PRs to land | No single-threaded enforcement | `WAITING_PRS` check in dev-poll holds new work — verify TOML `name` is consistent across invocations |
| Label ping-pong (issue reopened then immediately re-closed) | `already_done` handler doesn't close issue | Review dev-agent log; `already_done` status should auto-close the issue | | Label ping-pong (issue reopened then immediately re-closed) | `already_done` handler doesn't close issue | Review dev-agent log; `already_done` status should auto-close the issue |

View file

@ -14,7 +14,7 @@
<br> <br>
Point it at a Codeberg repo with a Woodpecker CI pipeline and it will pick up issues, implement them, review PRs, and keep the system healthy — all on its own. Point it at a git repo with a Woodpecker CI pipeline and it will pick up issues, implement them, review PRs, and keep the system healthy — all on its own.
## Architecture ## Architecture
@ -49,9 +49,8 @@ all agents ──→ matrix_send() ← status updates, escalations, merge no
**Required:** **Required:**
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) — `claude` in PATH, authenticated - [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) — `claude` in PATH, authenticated
- [Codeberg](https://codeberg.org/) account with an API token — disinto reads issues, opens PRs, posts comments, and merges via the Codeberg API - [Docker](https://docker.com/) — for provisioning a local Forgejo instance (or a running Forgejo/Gitea instance)
- A second Codeberg account for the review bot — reviews posted under a separate identity so the dev-agent doesn't review its own PRs (`REVIEW_BOT_TOKEN`) - [Woodpecker CI](https://woodpecker-ci.org/) — local instance connected to your forge; disinto monitors pipelines, retries failures, and queries the Woodpecker Postgres DB directly
- [Woodpecker CI](https://woodpecker-ci.org/) — local instance connected to your Codeberg repo; disinto monitors pipelines, retries failures, and queries the Woodpecker Postgres DB directly
- PostgreSQL client (`psql`) — for Woodpecker DB queries (pipeline status, build counts) - PostgreSQL client (`psql`) — for Woodpecker DB queries (pipeline status, build counts)
- `jq`, `curl`, `git` - `jq`, `curl`, `git`
@ -65,24 +64,20 @@ all agents ──→ matrix_send() ← status updates, escalations, merge no
```bash ```bash
# 1. Clone # 1. Clone
git clone ssh://git@codeberg.org/johba/disinto.git git clone https://github.com/johba/disinto.git
cd disinto cd disinto
# 2. Configure # 2. Bootstrap a project (provisions local Forgejo, creates tokens, clones repo)
cp .env.example .env disinto init https://github.com/yourorg/yourproject
``` ```
Edit `.env` with your values: Or configure manually — edit `.env` with your values:
```bash ```bash
# Target repo # Forge (auto-populated by disinto init)
CODEBERG_REPO=yourorg/yourproject # Codeberg org/repo slug FORGE_URL=http://localhost:3000 # local Forgejo instance
CODEBERG_API=https://codeberg.org/api/v1/repos/yourorg/yourproject FORGE_TOKEN=... # dev-bot token
PROJECT_REPO_ROOT=/path/to/your/project # local clone of the target repo FORGE_REVIEW_TOKEN=... # review-bot token
# Auth tokens
CODEBERG_TOKEN=... # main account — or put it in ~/.netrc
REVIEW_BOT_TOKEN=... # separate Codeberg account for code reviews
# Woodpecker CI # Woodpecker CI
WOODPECKER_SERVER=http://localhost:8000 WOODPECKER_SERVER=http://localhost:8000
@ -148,7 +143,7 @@ disinto/
├── memory.md ├── memory.md
├── disk.md ├── disk.md
├── ci.md ├── ci.md
├── codeberg.md ├── forge.md
├── dev-agent.md ├── dev-agent.md
├── review-agent.md ├── review-agent.md
└── git.md └── git.md

View file

@ -31,7 +31,7 @@
| Service | Purpose | Limits | | Service | Purpose | Limits |
|---------|---------|--------| |---------|---------|--------|
| Codeberg | source hosting + CI triggers | 10 GB storage, 1000 min/mo CI | | Forge (Forgejo) | source hosting + CI triggers | 10 GB storage, 1000 min/mo CI |
| Anthropic | Claude API | $X/mo budget, rate limit: 100k TPM | | Anthropic | Claude API | $X/mo budget, rate limit: 100k TPM |
| Cloudflare | DNS + CDN | free tier | | Cloudflare | DNS + CDN | free tier |

View file

@ -34,7 +34,7 @@ A solo founder sets the vision and defines quality gates. Disinto derives the ba
## Growth goals ## Growth goals
- **Attract developers** — the project should be easy to understand, easy to fork, easy to contribute to. - **Attract developers** — the project should be easy to understand, easy to fork, easy to contribute to.
- **Stars and forks** — measure traction through Codeberg/GitHub engagement. - **Stars and forks** — measure traction through forge/GitHub engagement.
- **Contributors** — lower the barrier to entry. Good docs, clear architecture, working examples. - **Contributors** — lower the barrier to entry. Good docs, clear architecture, working examples.
- **Reference deployments** — showcase real projects built and operated by Disinto. - **Reference deployments** — showcase real projects built and operated by Disinto.
- **Vault as differentiator** — the quality gate model (vision + vault) is what sets Disinto apart from generic CI/CD. Make it visible and easy to understand. - **Vault as differentiator** — the quality gate model (vision + vault) is what sets Disinto apart from generic CI/CD. Make it visible and easy to understand.

View file

@ -25,7 +25,7 @@ issues labeled `action` that have no active tmux session, then spawns
8. For human input: Claude sends a Matrix message and waits; the reply is injected into the session by `matrix_listener.sh`. 8. For human input: Claude sends a Matrix message and waits; the reply is injected into the session by `matrix_listener.sh`.
**Environment variables consumed**: **Environment variables consumed**:
- `CODEBERG_TOKEN`, `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `CODEBERG_WEB` - `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `FORGE_WEB`
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` — Matrix notifications + human input - `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` — Matrix notifications + human input
- `ACTION_IDLE_TIMEOUT` — Max seconds before killing idle session (default 14400 = 4h) - `ACTION_IDLE_TIMEOUT` — Max seconds before killing idle session (default 14400 = 4h)
- `ACTION_MAX_LIFETIME` — Max total session wall-clock seconds (default 28800 = 8h); caps session independently of idle timeout - `ACTION_MAX_LIFETIME` — Max total session wall-clock seconds (default 28800 = 8h); caps session independently of idle timeout

View file

@ -42,7 +42,7 @@ SESSION_START_EPOCH=$(date +%s)
# --- Phase handler globals (agent-specific; defaults in phase-handler.sh) --- # --- Phase handler globals (agent-specific; defaults in phase-handler.sh) ---
# shellcheck disable=SC2034 # used by phase-handler.sh # shellcheck disable=SC2034 # used by phase-handler.sh
API="${CODEBERG_API}" API="${FORGE_API}"
BRANCH="action/issue-${ISSUE}" BRANCH="action/issue-${ISSUE}"
# shellcheck disable=SC2034 # used by phase-handler.sh # shellcheck disable=SC2034 # used by phase-handler.sh
WORKTREE="/tmp/action-${ISSUE}-$(date +%s)" WORKTREE="/tmp/action-${ISSUE}-$(date +%s)"
@ -133,8 +133,8 @@ fi
# --- Fetch issue --- # --- Fetch issue ---
log "fetching issue #${ISSUE}" log "fetching issue #${ISSUE}"
ISSUE_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ ISSUE_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/issues/${ISSUE}") || true "${FORGE_API}/issues/${ISSUE}") || true
if [ -z "$ISSUE_JSON" ] || ! printf '%s' "$ISSUE_JSON" | jq -e '.id' >/dev/null 2>&1; then if [ -z "$ISSUE_JSON" ] || ! printf '%s' "$ISSUE_JSON" | jq -e '.id' >/dev/null 2>&1; then
log "ERROR: failed to fetch issue #${ISSUE}" log "ERROR: failed to fetch issue #${ISSUE}"
@ -161,18 +161,18 @@ if [ -n "$YAML_MODEL" ]; then
fi fi
# --- Resolve bot username(s) for comment filtering --- # --- Resolve bot username(s) for comment filtering ---
_bot_login=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ _bot_login=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API%%/repos*}/user" | jq -r '.login // empty' 2>/dev/null || true) "${FORGE_API%%/repos*}/user" | jq -r '.login // empty' 2>/dev/null || true)
# Build list: token owner + any extra names from CODEBERG_BOT_USERNAMES (comma-separated) # Build list: token owner + any extra names from FORGE_BOT_USERNAMES (comma-separated)
_bot_logins="${_bot_login}" _bot_logins="${_bot_login}"
if [ -n "${CODEBERG_BOT_USERNAMES:-}" ]; then if [ -n "${FORGE_BOT_USERNAMES:-}" ]; then
_bot_logins="${_bot_logins:+${_bot_logins},}${CODEBERG_BOT_USERNAMES}" _bot_logins="${_bot_logins:+${_bot_logins},}${FORGE_BOT_USERNAMES}"
fi fi
# --- Fetch existing comments (resume context, excluding bot comments) --- # --- Fetch existing comments (resume context, excluding bot comments) ---
COMMENTS_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ COMMENTS_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/issues/${ISSUE}/comments?limit=50") || true "${FORGE_API}/issues/${ISSUE}/comments?limit=50") || true
PRIOR_COMMENTS="" PRIOR_COMMENTS=""
if [ -n "$COMMENTS_JSON" ] && [ "$COMMENTS_JSON" != "null" ] && [ "$COMMENTS_JSON" != "[]" ]; then if [ -n "$COMMENTS_JSON" ] && [ "$COMMENTS_JSON" != "null" ] && [ "$COMMENTS_JSON" != "[]" ]; then
@ -184,7 +184,7 @@ if [ -n "$COMMENTS_JSON" ] && [ "$COMMENTS_JSON" != "null" ] && [ "$COMMENTS_JSO
fi fi
# --- Create Matrix thread for this issue --- # --- Create Matrix thread for this issue ---
ISSUE_URL="${CODEBERG_WEB}/issues/${ISSUE}" ISSUE_URL="${FORGE_WEB}/issues/${ISSUE}"
_thread_id=$(matrix_send_ctx "action" \ _thread_id=$(matrix_send_ctx "action" \
"⚡ Action #${ISSUE}: ${ISSUE_TITLE}${ISSUE_URL}" \ "⚡ Action #${ISSUE}: ${ISSUE_TITLE}${ISSUE_URL}" \
"⚡ <a href='${ISSUE_URL}'>Action #${ISSUE}</a>: ${ISSUE_TITLE}") || true "⚡ <a href='${ISSUE_URL}'>Action #${ISSUE}</a>: ${ISSUE_TITLE}") || true
@ -254,9 +254,9 @@ ${PRIOR_SECTION}## Instructions
3. Post progress as comments on issue #${ISSUE} after significant steps: 3. Post progress as comments on issue #${ISSUE} after significant steps:
curl -sf -X POST \\ curl -sf -X POST \\
-H \"Authorization: token \${CODEBERG_TOKEN}\" \\ -H \"Authorization: token \${FORGE_TOKEN}\" \\
-H 'Content-Type: application/json' \\ -H 'Content-Type: application/json' \\
\"${CODEBERG_API}/issues/${ISSUE}/comments\" \\ \"${FORGE_API}/issues/${ISSUE}/comments\" \\
-d \"{\\\"body\\\": \\\"your comment here\\\"}\" -d \"{\\\"body\\\": \\\"your comment here\\\"}\"
4. If a step requires human input or approval, send a Matrix message explaining 4. If a step requires human input or approval, send a Matrix message explaining
@ -278,19 +278,19 @@ ${PRIOR_SECTION}## Instructions
files you need to persistent paths before signaling done. files you need to persistent paths before signaling done.
- Close the issue: - Close the issue:
curl -sf -X PATCH \\ curl -sf -X PATCH \\
-H \"Authorization: token \${CODEBERG_TOKEN}\" \\ -H \"Authorization: token \${FORGE_TOKEN}\" \\
-H 'Content-Type: application/json' \\ -H 'Content-Type: application/json' \\
\"${CODEBERG_API}/issues/${ISSUE}\" \\ \"${FORGE_API}/issues/${ISSUE}\" \\
-d '{\"state\": \"closed\"}' -d '{\"state\": \"closed\"}'
- Signal completion: echo \"PHASE:done\" > \"${PHASE_FILE}\" - Signal completion: echo \"PHASE:done\" > \"${PHASE_FILE}\"
5. Environment variables available in your bash sessions: 5. Environment variables available in your bash sessions:
CODEBERG_TOKEN, CODEBERG_API, CODEBERG_REPO, CODEBERG_WEB, PROJECT_NAME FORGE_TOKEN, FORGE_API, FORGE_REPO, FORGE_WEB, PROJECT_NAME
(all sourced from ${FACTORY_ROOT}/.env) (all sourced from ${FACTORY_ROOT}/.env)
### CRITICAL: Never embed secrets in issue bodies, comments, or PR descriptions ### CRITICAL: Never embed secrets in issue bodies, comments, or PR descriptions
- NEVER put API keys, tokens, passwords, or private keys in issue text or comments. - NEVER put API keys, tokens, passwords, or private keys in issue text or comments.
- Always reference secrets via env var names (e.g. \\\$BASE_RPC_URL, \\\$CODEBERG_TOKEN). - Always reference secrets via env var names (e.g. \\\$BASE_RPC_URL, \\\${FORGE_TOKEN}).
- If a formula step needs a secret, read it from .env or the environment at runtime. - If a formula step needs a secret, read it from .env or the environment at runtime.
- Before posting any comment, verify it contains no credentials, hex keys > 32 chars, - Before posting any comment, verify it contains no credentials, hex keys > 32 chars,
or URLs with embedded API keys. or URLs with embedded API keys.
@ -330,9 +330,9 @@ _lifetime_watchdog() {
# Post summary comment on issue # Post summary comment on issue
local body="Action session killed: wall-clock lifetime cap (${hours}h) reached." local body="Action session killed: wall-clock lifetime cap (${hours}h) reached."
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${CODEBERG_API}/issues/${ISSUE}/comments" \ "${FORGE_API}/issues/${ISSUE}/comments" \
-d "{\"body\": \"${body}\"}" >/dev/null 2>&1 || true -d "{\"body\": \"${body}\"}" >/dev/null 2>&1 || true
printf 'PHASE:failed\nReason: max_lifetime (%sh) reached\n' "$hours" > "$PHASE_FILE" printf 'PHASE:failed\nReason: max_lifetime (%sh) reached\n' "$hours" > "$PHASE_FILE"
# Touch phase-changed marker so monitor_phase_loop picks up immediately # Touch phase-changed marker so monitor_phase_loop picks up immediately

View file

@ -31,8 +31,8 @@ fi
# --- Find open 'action' issues --- # --- Find open 'action' issues ---
log "scanning for open action issues" log "scanning for open action issues"
ACTION_ISSUES=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ ACTION_ISSUES=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/issues?state=open&labels=action&limit=50&type=issues") || true "${FORGE_API}/issues?state=open&labels=action&limit=50&type=issues") || true
if [ -z "$ACTION_ISSUES" ] || [ "$ACTION_ISSUES" = "null" ]; then if [ -z "$ACTION_ISSUES" ] || [ "$ACTION_ISSUES" = "null" ]; then
log "no action issues found" log "no action issues found"

View file

@ -7,8 +7,8 @@
# disinto status Show factory status # disinto status Show factory status
# #
# Usage: # Usage:
# disinto init https://codeberg.org/user/repo # disinto init https://github.com/user/repo
# disinto init https://codeberg.org/user/repo --branch main --ci-id 3 # disinto init user/repo --branch main --ci-id 3
# disinto status # disinto status
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
@ -30,45 +30,49 @@ Init options:
--branch <name> Primary branch (default: auto-detect) --branch <name> Primary branch (default: auto-detect)
--repo-root <path> Local clone path (default: ~/name) --repo-root <path> Local clone path (default: ~/name)
--ci-id <n> Woodpecker CI repo ID (default: 0 = no CI) --ci-id <n> Woodpecker CI repo ID (default: 0 = no CI)
--token <token> Codeberg API token (saved to ~/.netrc) --forge-url <url> Forge base URL (default: http://localhost:3000)
--yes Skip confirmation prompts --yes Skip confirmation prompts
EOF EOF
exit 1 exit 1
} }
# Extract org/repo slug from various URL formats. # Extract org/repo slug from various URL formats.
# Accepts: https://codeberg.org/user/repo, codeberg.org/user/repo, # Accepts: https://github.com/user/repo, https://codeberg.org/user/repo,
# user/repo, https://codeberg.org/user/repo.git # http://localhost:3000/user/repo, user/repo, *.git
parse_repo_slug() { parse_repo_slug() {
local url="$1" local url="$1"
url="${url#https://}" url="${url#https://}"
url="${url#http://}" url="${url#http://}"
url="${url#codeberg.org/}" # Strip any hostname (anything before the first / that contains a dot or colon)
if [[ "$url" =~ ^[a-zA-Z0-9._:-]+/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+ ]]; then
url="${url#*/}" # strip host part
fi
url="${url%.git}" url="${url%.git}"
url="${url%/}" url="${url%/}"
if [[ ! "$url" =~ ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ ]]; then if [[ ! "$url" =~ ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ ]]; then
echo "Error: invalid repo URL — expected https://codeberg.org/org/repo or org/repo" >&2 echo "Error: invalid repo URL — expected https://host/org/repo or org/repo" >&2
exit 1 exit 1
fi fi
printf '%s' "$url" printf '%s' "$url"
} }
# Build a clone-able URL from a slug. # Build a clone-able URL from a slug and forge URL.
clone_url_from_slug() { clone_url_from_slug() {
printf 'https://codeberg.org/%s.git' "$1" local slug="$1" forge_url="${2:-${FORGE_URL:-http://localhost:3000}}"
printf '%s/%s.git' "$forge_url" "$slug"
} }
# Write (or update) Codeberg credentials in ~/.netrc. # Write (or update) credentials in ~/.netrc for a given host.
write_netrc() { write_netrc() {
local login="$1" token="$2" local host="$1" login="$2" token="$3"
local netrc="${HOME}/.netrc" local netrc="${HOME}/.netrc"
# Remove existing codeberg.org entry if present # Remove existing entry for this host if present
if [ -f "$netrc" ]; then if [ -f "$netrc" ]; then
local tmp local tmp
tmp=$(mktemp) tmp=$(mktemp)
awk ' awk -v h="$host" '
/^machine codeberg\.org/ { skip=1; next } $0 ~ "^machine " h { skip=1; next }
/^machine / { skip=0 } /^machine / { skip=0 }
!skip !skip
' "$netrc" > "$tmp" ' "$netrc" > "$tmp"
@ -76,93 +80,252 @@ write_netrc() {
fi fi
# Append new entry # Append new entry
printf 'machine codeberg.org\nlogin %s\npassword %s\n' "$login" "$token" >> "$netrc" printf 'machine %s\nlogin %s\npassword %s\n' "$host" "$login" "$token" >> "$netrc"
chmod 600 "$netrc" chmod 600 "$netrc"
} }
# Interactively set up Codeberg auth if missing. FORGEJO_DATA_DIR="${HOME}/.disinto/forgejo"
# Args: [token_from_flag]
setup_codeberg_auth() {
local token_flag="${1:-}"
local repo_slug="${2:-}"
# --token flag takes priority: verify and save # Provision or connect to a local Forgejo instance.
if [ -n "$token_flag" ]; then # Creates admin + bot users, generates API tokens, stores in .env.
local verify_url="https://codeberg.org/api/v1/repos/${repo_slug}" setup_forge() {
if ! curl -sf --max-time 10 \ local forge_url="$1"
-H "Authorization: token ${token_flag}" \ local repo_slug="$2"
"$verify_url" >/dev/null 2>&1; then
echo "Error: provided token failed verification" >&2 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 exit 1
fi fi
write_netrc "token" "$token_flag"
echo "Saving to ~/.netrc... done."
echo "Verified: token accepted ✓"
export CODEBERG_TOKEN="$token_flag"
return
fi
# Existing auth — skip # Create data directory
if [ -n "${CODEBERG_TOKEN:-}" ]; then mkdir -p "${FORGEJO_DATA_DIR}"
return
fi
if grep -q 'codeberg\.org' ~/.netrc 2>/dev/null; then
CODEBERG_TOKEN="$(awk '/codeberg.org/{getline;getline;print $2}' ~/.netrc 2>/dev/null || true)"
export CODEBERG_TOKEN
return
fi
# Non-interactive — fail with guidance # Extract port from forge_url
if [ ! -t 0 ]; then local forge_port
echo "Error: no Codeberg auth found" >&2 forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|')
echo " Set CODEBERG_TOKEN, configure ~/.netrc, or use --token <token>" >&2 forge_port="${forge_port:-3000}"
exit 1
fi
# Interactive guided flow # Start Forgejo container
echo "" if docker ps -a --format '{{.Names}}' | grep -q '^disinto-forgejo$'; then
echo "No Codeberg authentication found." docker start disinto-forgejo >/dev/null 2>&1 || true
echo "" else
echo "1. Open https://codeberg.org/user/settings/applications" docker run -d \
echo "2. Create a token with these scopes:" --name disinto-forgejo \
echo " - write:issue (create issues, add labels, post comments, close issues)" --restart unless-stopped \
echo " - write:repository (push branches, create PRs, merge PRs)" -p "${forge_port}:3000" \
echo "3. Paste the token below." -p 2222:22 \
echo "" -v "${FORGEJO_DATA_DIR}:/data" \
-e "FORGEJO__database__DB_TYPE=sqlite3" \
while true; do -e "FORGEJO__server__ROOT_URL=${forge_url}/" \
read -rsp "Codeberg token: " token_input -e "FORGEJO__server__HTTP_PORT=3000" \
echo "" -e "FORGEJO__service__DISABLE_REGISTRATION=true" \
forgejo/forgejo:latest
if [ -z "$token_input" ]; then
echo "Token cannot be empty. Try again." >&2
continue
fi fi
local verify_url="https://codeberg.org/api/v1/repos/${repo_slug}" # Wait for Forgejo to become healthy
if ! curl -sf --max-time 10 \ echo -n "Waiting for Forgejo to start"
-H "Authorization: token ${token_input}" \ local retries=0
"$verify_url" >/dev/null 2>&1; then while ! curl -sf --max-time 3 "${forge_url}/api/v1/version" >/dev/null 2>&1; do
echo "Token verification failed. Check your token and try again." >&2 retries=$((retries + 1))
read -rp "Retry? [Y/n] " retry if [ "$retries" -gt 60 ]; then
if [[ "$retry" =~ ^[Nn] ]]; then echo ""
echo "Aborted." >&2 echo "Error: Forgejo did not become ready within 60s" >&2
exit 1 exit 1
fi fi
continue echo -n "."
sleep 1
done
echo " ready"
fi
# Create admin user if it doesn't exist
local admin_user="disinto-admin"
local admin_pass
admin_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/${admin_user}" >/dev/null 2>&1; then
echo "Creating admin user: ${admin_user}"
docker exec disinto-forgejo forgejo admin user create \
--admin \
--username "${admin_user}" \
--password "${admin_pass}" \
--email "admin@disinto.local" \
--must-change-password=false 2>/dev/null || true
fi
# Get or create admin token
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
# 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
# Create bot users and tokens
local dev_token="" review_token=""
for bot_user in dev-bot review-bot; do
local bot_pass
bot_pass="bot-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
if ! curl -sf --max-time 5 \
-H "Authorization: token ${admin_token}" \
"${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then
echo "Creating bot user: ${bot_user}"
docker exec disinto-forgejo forgejo admin user create \
--username "${bot_user}" \
--password "${bot_pass}" \
--email "${bot_user}@disinto.local" \
--must-change-password=false 2>/dev/null || true
fi fi
write_netrc "token" "$token_input" # Generate token via API (using admin credentials for the bot)
echo "Saving to ~/.netrc... done." local token
echo "Verified: token accepted ✓" token=$(curl -sf -X POST \
export CODEBERG_TOKEN="$token_input" -H "Authorization: token ${admin_token}" \
return -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
# Token name collision — create with timestamp suffix
token=$(curl -sf -X POST \
-H "Authorization: token ${admin_token}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/users/${bot_user}/tokens" \
-d "{\"name\":\"disinto-${bot_user}-$(date +%s)\",\"scopes\":[\"all\"]}" 2>/dev/null \
| jq -r '.sha1 // empty') || token=""
fi
if [ "$bot_user" = "dev-bot" ]; then
dev_token="$token"
else
review_token="$token"
fi
done done
# Store tokens in .env
local env_file="${FACTORY_ROOT}/.env"
if [ -n "$dev_token" ]; then
if grep -q '^FORGE_TOKEN=' "$env_file" 2>/dev/null; then
sed -i "s|^FORGE_TOKEN=.*|FORGE_TOKEN=${dev_token}|" "$env_file"
elif grep -q '^CODEBERG_TOKEN=' "$env_file" 2>/dev/null; then
sed -i "s|^CODEBERG_TOKEN=.*|FORGE_TOKEN=${dev_token}|" "$env_file"
else
printf '\nFORGE_TOKEN=%s\n' "$dev_token" >> "$env_file"
fi
export FORGE_TOKEN="$dev_token"
export CODEBERG_TOKEN="$dev_token"
echo " dev-bot token saved"
fi
if [ -n "$review_token" ]; then
if grep -q '^FORGE_REVIEW_TOKEN=' "$env_file" 2>/dev/null; then
sed -i "s|^FORGE_REVIEW_TOKEN=.*|FORGE_REVIEW_TOKEN=${review_token}|" "$env_file"
elif grep -q '^REVIEW_BOT_TOKEN=' "$env_file" 2>/dev/null; then
sed -i "s|^REVIEW_BOT_TOKEN=.*|FORGE_REVIEW_TOKEN=${review_token}|" "$env_file"
else
printf 'FORGE_REVIEW_TOKEN=%s\n' "$review_token" >> "$env_file"
fi
export FORGE_REVIEW_TOKEN="$review_token"
export REVIEW_BOT_TOKEN="$review_token"
echo " review-bot token saved"
fi
# 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 dev-bot user
curl -sf -X POST \
-H "Authorization: token ${FORGE_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 || true
fi
# Add bot users as collaborators
for bot_user in dev-bot review-bot; do
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":"write"}' >/dev/null 2>&1 || true
done
echo "Repo: ${repo_slug} created on Forgejo"
else
echo "Repo: ${repo_slug} (already exists on Forgejo)"
fi
echo "Forge: ${forge_url} (ready)"
}
# Push local clone to the Forgejo remote.
push_to_forge() {
local repo_root="$1" forge_url="$2" repo_slug="$3"
local remote_url="${forge_url}/${repo_slug}.git"
if git -C "$repo_root" remote get-url forgejo >/dev/null 2>&1; then
echo "Remote: forgejo (already configured)"
else
git -C "$repo_root" remote add forgejo "$remote_url" 2>/dev/null || \
git -C "$repo_root" remote set-url forgejo "$remote_url"
echo "Remote: forgejo -> ${remote_url}"
fi
# Push all branches
git -C "$repo_root" push forgejo --all 2>/dev/null || true
git -C "$repo_root" push forgejo --tags 2>/dev/null || true
} }
# Preflight check — verify all factory requirements before proceeding. # Preflight check — verify all factory requirements before proceeding.
preflight_check() { preflight_check() {
local repo_slug="${1:-}" local repo_slug="${1:-}"
local forge_url="${2:-${FORGE_URL:-http://localhost:3000}}"
local errors=0 local errors=0
# ── Required commands ── # ── Required commands ──
@ -207,37 +370,20 @@ preflight_check() {
fi fi
fi fi
# ── Codeberg auth (setup_codeberg_auth handles interactive setup; # ── Forge API check (verify the forge is reachable and token works) ──
# this verifies the API actually works) ── if [ -n "${FORGE_TOKEN:-}" ] && command -v curl &>/dev/null; then
if [ -n "${CODEBERG_TOKEN:-}" ] && command -v curl &>/dev/null; then if ! curl -sf --max-time 10 \
local curl_args=(-sf --max-time 10) -H "Authorization: token ${FORGE_TOKEN}" \
if [ -n "${CODEBERG_TOKEN:-}" ]; then "${forge_url}/api/v1/repos/${repo_slug}" >/dev/null 2>&1; then
curl_args+=(-H "Authorization: token ${CODEBERG_TOKEN}") echo "Error: Forge API auth failed at ${forge_url}" >&2
else echo " Verify your FORGE_TOKEN and that Forgejo is running" >&2
curl_args+=(--netrc)
fi
if ! curl "${curl_args[@]}" "https://codeberg.org/api/v1/repos/${repo_slug}" >/dev/null 2>&1; then
echo "Error: Codeberg API auth failed" >&2
echo " Verify your CODEBERG_TOKEN or ~/.netrc credentials" >&2
errors=$((errors + 1))
fi
fi
# ── Codeberg SSH access ──
if command -v ssh &>/dev/null; then
local ssh_output
ssh_output=$(ssh -T -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
git@codeberg.org 2>&1) || true
if ! printf '%s' "$ssh_output" | grep -qi "successfully authenticated"; then
echo "Error: Codeberg SSH access failed (agents push via SSH)" >&2
echo " Add your SSH key: https://codeberg.org/user/settings/keys" >&2
errors=$((errors + 1)) errors=$((errors + 1))
fi fi
fi fi
# ── Optional tools (warn only) ── # ── Optional tools (warn only) ──
if ! command -v docker &>/dev/null; then if ! command -v docker &>/dev/null; then
echo "Warning: docker not found (some projects may need it)" >&2 echo "Warning: docker not found (needed for Forgejo provisioning)" >&2
fi fi
if [ "$errors" -gt 0 ]; then if [ "$errors" -gt 0 ]; then
@ -249,13 +395,13 @@ preflight_check() {
# Clone the repo if the target directory doesn't exist; validate if it does. # Clone the repo if the target directory doesn't exist; validate if it does.
clone_or_validate() { clone_or_validate() {
local slug="$1" target="$2" local slug="$1" target="$2" forge_url="${3:-${FORGE_URL:-http://localhost:3000}}"
if [ -d "${target}/.git" ]; then if [ -d "${target}/.git" ]; then
echo "Repo: ${target} (existing clone)" echo "Repo: ${target} (existing clone)"
return return
fi fi
local url local url
url=$(clone_url_from_slug "$slug") url=$(clone_url_from_slug "$slug" "$forge_url")
echo "Cloning: ${url} -> ${target}" echo "Cloning: ${url} -> ${target}"
git clone "$url" "$target" git clone "$url" "$target"
} }
@ -278,7 +424,7 @@ detect_branch() {
# Generate projects/<name>.toml config file. # Generate projects/<name>.toml config file.
generate_toml() { generate_toml() {
local path="$1" name="$2" repo="$3" root="$4" branch="$5" ci_id="$6" local path="$1" name="$2" repo="$3" root="$4" branch="$5" ci_id="$6" forge_url="$7"
cat > "$path" <<EOF cat > "$path" <<EOF
# projects/${name}.toml — Project config for ${repo} # projects/${name}.toml — Project config for ${repo}
# #
@ -286,6 +432,7 @@ generate_toml() {
name = "${name}" name = "${name}"
repo = "${repo}" repo = "${repo}"
forge_url = "${forge_url}"
repo_root = "${root}" repo_root = "${root}"
primary_branch = "${branch}" primary_branch = "${branch}"
@ -303,10 +450,11 @@ check_pipeline_stall = false
EOF EOF
} }
# Create standard labels on the Codeberg repo. # Create standard labels on the forge repo.
create_labels() { create_labels() {
local repo="$1" local repo="$1"
local api="https://codeberg.org/api/v1/repos/${repo}" local forge_url="${2:-${FORGE_URL:-http://localhost:3000}}"
local api="${forge_url}/api/v1/repos/${repo}"
local -A labels=( local -A labels=(
["backlog"]="#0075ca" ["backlog"]="#0075ca"
@ -323,7 +471,7 @@ create_labels() {
for name in backlog in-progress blocked tech-debt underspecified vision action; do for name in backlog in-progress blocked tech-debt underspecified vision action; do
color="${labels[$name]}" color="${labels[$name]}"
if curl -sf -X POST \ if curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${api}/labels" \ "${api}/labels" \
-d "{\"name\":\"${name}\",\"color\":\"${color}\"}" >/dev/null 2>&1; then -d "{\"name\":\"${name}\",\"color\":\"${color}\"}" >/dev/null 2>&1; then
@ -415,27 +563,31 @@ disinto_init() {
shift shift
# Parse flags # Parse flags
local branch="" repo_root="" ci_id="0" auto_yes=false token_flag="" local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag=""
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--branch) branch="$2"; shift 2 ;; --branch) branch="$2"; shift 2 ;;
--repo-root) repo_root="$2"; shift 2 ;; --repo-root) repo_root="$2"; shift 2 ;;
--ci-id) ci_id="$2"; shift 2 ;; --ci-id) ci_id="$2"; shift 2 ;;
--token) token_flag="$2"; shift 2 ;; --forge-url) forge_url_flag="$2"; shift 2 ;;
--yes) auto_yes=true; shift ;; --yes) auto_yes=true; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;; *) echo "Unknown option: $1" >&2; exit 1 ;;
esac esac
done done
# Extract org/repo slug # Extract org/repo slug
local codeberg_repo local forge_repo
codeberg_repo=$(parse_repo_slug "$repo_url") forge_repo=$(parse_repo_slug "$repo_url")
local project_name="${codeberg_repo##*/}" local project_name="${forge_repo##*/}"
local toml_path="${FACTORY_ROOT}/projects/${project_name}.toml" local toml_path="${FACTORY_ROOT}/projects/${project_name}.toml"
# Determine forge URL (flag > env > default)
local forge_url="${forge_url_flag:-${FORGE_URL:-http://localhost:3000}}"
echo "=== disinto init ===" echo "=== disinto init ==="
echo "Project: ${codeberg_repo}" echo "Project: ${forge_repo}"
echo "Name: ${project_name}" echo "Name: ${project_name}"
echo "Forge: ${forge_url}"
# Check for existing config # Check for existing config
local toml_exists=false local toml_exists=false
@ -492,17 +644,27 @@ p.write_text(text)
fi fi
fi fi
# Set up Codeberg auth (interactive if needed, before preflight) # Set up local Forgejo instance (provision if needed, create users/tokens/repo)
setup_codeberg_auth "$token_flag" "$codeberg_repo" setup_forge "$forge_url" "$forge_repo"
# Preflight: verify factory requirements # Preflight: verify factory requirements
preflight_check "$codeberg_repo" preflight_check "$forge_repo" "$forge_url"
# Determine repo root (for new projects) # Determine repo root (for new projects)
repo_root="${repo_root:-/home/${USER}/${project_name}}" repo_root="${repo_root:-/home/${USER}/${project_name}}"
# Clone or validate # Clone or validate (try origin first for initial clone from upstream)
clone_or_validate "$codeberg_repo" "$repo_root" if [ ! -d "${repo_root}/.git" ]; then
# For initial setup, clone from the provided URL directly
echo "Cloning: ${repo_url} -> ${repo_root}"
git clone "$repo_url" "$repo_root" 2>/dev/null || \
clone_or_validate "$forge_repo" "$repo_root" "$forge_url"
else
echo "Repo: ${repo_root} (existing clone)"
fi
# Push to local Forgejo
push_to_forge "$repo_root" "$forge_url" "$forge_repo"
# Detect primary branch # Detect primary branch
if [ -z "$branch" ]; then if [ -z "$branch" ]; then
@ -518,12 +680,12 @@ p.write_text(text)
ci_id="${user_ci_id:-0}" ci_id="${user_ci_id:-0}"
fi fi
generate_toml "$toml_path" "$project_name" "$codeberg_repo" "$repo_root" "$branch" "$ci_id" generate_toml "$toml_path" "$project_name" "$forge_repo" "$repo_root" "$branch" "$ci_id" "$forge_url"
echo "Created: ${toml_path}" echo "Created: ${toml_path}"
fi fi
# Create labels on remote # Create labels on remote
create_labels "$codeberg_repo" create_labels "$forge_repo" "$forge_url"
# Generate VISION.md template # Generate VISION.md template
generate_vision "$repo_root" "$project_name" generate_vision "$repo_root" "$project_name"
@ -535,6 +697,7 @@ p.write_text(text)
echo "Done. Project ${project_name} is ready." echo "Done. Project ${project_name} is ready."
echo " Config: ${toml_path}" echo " Config: ${toml_path}"
echo " Clone: ${repo_root}" echo " Clone: ${repo_root}"
echo " Forge: ${forge_url}/${forge_repo}"
echo " Run 'disinto status' to verify." echo " Run 'disinto status' to verify."
} }
@ -548,8 +711,8 @@ disinto_status() {
[ -f "$toml" ] || continue [ -f "$toml" ] || continue
found=true found=true
# Parse name and repo from TOML # Parse name, repo, forge_url from TOML
local pname prepo local pname prepo pforge_url
pname=$(python3 -c " pname=$(python3 -c "
import sys, tomllib import sys, tomllib
with open(sys.argv[1], 'rb') as f: with open(sys.argv[1], 'rb') as f:
@ -560,6 +723,12 @@ import sys, tomllib
with open(sys.argv[1], 'rb') as f: with open(sys.argv[1], 'rb') as f:
print(tomllib.load(f)['repo']) print(tomllib.load(f)['repo'])
" "$toml" 2>/dev/null) || continue " "$toml" 2>/dev/null) || continue
pforge_url=$(python3 -c "
import sys, tomllib
with open(sys.argv[1], 'rb') as f:
print(tomllib.load(f).get('forge_url', ''))
" "$toml" 2>/dev/null) || pforge_url=""
pforge_url="${pforge_url:-${FORGE_URL:-http://localhost:3000}}"
echo "== ${pname} (${prepo}) ==" echo "== ${pname} (${prepo}) =="
@ -578,24 +747,24 @@ with open(sys.argv[1], 'rb') as f:
fi fi
# Backlog depth via API # Backlog depth via API
if [ -n "${CODEBERG_TOKEN:-}" ]; then if [ -n "${FORGE_TOKEN:-}" ]; then
local api="https://codeberg.org/api/v1/repos/${prepo}" local api="${pforge_url}/api/v1/repos/${prepo}"
local backlog_count pr_count local backlog_count pr_count
backlog_count=$(curl -sf -I \ backlog_count=$(curl -sf -I \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
"${api}/issues?state=open&labels=backlog&limit=1" 2>/dev/null \ "${api}/issues?state=open&labels=backlog&limit=1" 2>/dev/null \
| grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}') || backlog_count="?" | grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}') || backlog_count="?"
echo " Backlog: ${backlog_count:-0} issues" echo " Backlog: ${backlog_count:-0} issues"
pr_count=$(curl -sf -I \ pr_count=$(curl -sf -I \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
"${api}/pulls?state=open&limit=1" 2>/dev/null \ "${api}/pulls?state=open&limit=1" 2>/dev/null \
| grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}') || pr_count="?" | grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}') || pr_count="?"
echo " Open PRs: ${pr_count:-0}" echo " Open PRs: ${pr_count:-0}"
else else
echo " Backlog: (no CODEBERG_TOKEN)" echo " Backlog: (no FORGE_TOKEN)"
echo " Open PRs: (no CODEBERG_TOKEN)" echo " Open PRs: (no FORGE_TOKEN)"
fi fi
echo "" echo ""

View file

@ -19,8 +19,8 @@ while a dev-agent session is active on another issue.
- `dev/phase-test.sh` — Integration test for the phase protocol - `dev/phase-test.sh` — Integration test for the phase protocol
**Environment variables consumed** (via `lib/env.sh` + project TOML): **Environment variables consumed** (via `lib/env.sh` + project TOML):
- `CODEBERG_TOKEN` — Dev-agent token (push, PR creation, merge) — use the dedicated bot account - `FORGE_TOKEN` — Dev-agent token (push, PR creation, merge) — use the dedicated bot account
- `CODEBERG_REPO`, `CODEBERG_API` — Target repository - `FORGE_REPO`, `FORGE_API` — Target repository
- `PROJECT_NAME`, `PROJECT_REPO_ROOT` — Local checkout path - `PROJECT_NAME`, `PROJECT_REPO_ROOT` — Local checkout path
- `PRIMARY_BRANCH` — Branch to merge into (e.g. `main`, `master`) - `PRIMARY_BRANCH` — Branch to merge into (e.g. `main`, `master`)
- `WOODPECKER_REPO_ID` — CI pipeline lookups - `WOODPECKER_REPO_ID` — CI pipeline lookups

View file

@ -34,21 +34,21 @@ git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true
# --- Config --- # --- Config ---
ISSUE="${1:?Usage: dev-agent.sh <issue-number>}" ISSUE="${1:?Usage: dev-agent.sh <issue-number>}"
# shellcheck disable=SC2034 # shellcheck disable=SC2034
REPO="${CODEBERG_REPO}" REPO="${FORGE_REPO}"
# shellcheck disable=SC2034 # shellcheck disable=SC2034
REPO_ROOT="${PROJECT_REPO_ROOT}" REPO_ROOT="${PROJECT_REPO_ROOT}"
API="${CODEBERG_API}" API="${FORGE_API}"
LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock" LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock"
STATUSFILE="/tmp/dev-agent-status-${PROJECT_NAME:-default}" STATUSFILE="/tmp/dev-agent-status-${PROJECT_NAME:-default}"
# Gitea labels API requires []int64 — look up the "backlog" label ID once # Gitea labels API requires []int64 — look up the "backlog" label ID once
BACKLOG_LABEL_ID=$(codeberg_api GET "/labels" 2>/dev/null \ BACKLOG_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
| jq -r '.[] | select(.name == "backlog") | .id' 2>/dev/null || true) | jq -r '.[] | select(.name == "backlog") | .id' 2>/dev/null || true)
BACKLOG_LABEL_ID="${BACKLOG_LABEL_ID:-1300815}" BACKLOG_LABEL_ID="${BACKLOG_LABEL_ID:-1300815}"
# Same for "in-progress" label # Same for "in-progress" label
IN_PROGRESS_LABEL_ID=$(codeberg_api GET "/labels" 2>/dev/null \ IN_PROGRESS_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
| jq -r '.[] | select(.name == "in-progress") | .id' 2>/dev/null || true) | jq -r '.[] | select(.name == "in-progress") | .id' 2>/dev/null || true)
IN_PROGRESS_LABEL_ID="${IN_PROGRESS_LABEL_ID:-1300818}" IN_PROGRESS_LABEL_ID="${IN_PROGRESS_LABEL_ID:-1300818}"
@ -128,14 +128,14 @@ cleanup_worktree() {
cleanup_labels() { cleanup_labels() {
curl -sf -X DELETE \ curl -sf -X DELETE \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${ISSUE}/labels/${IN_PROGRESS_LABEL_ID}" >/dev/null 2>&1 || true "${API}/issues/${ISSUE}/labels/${IN_PROGRESS_LABEL_ID}" >/dev/null 2>&1 || true
} }
restore_to_backlog() { restore_to_backlog() {
cleanup_labels cleanup_labels
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/issues/${ISSUE}/labels" \ "${API}/issues/${ISSUE}/labels" \
-d "{\"labels\":[${BACKLOG_LABEL_ID}]}" >/dev/null 2>&1 || true -d "{\"labels\":[${BACKLOG_LABEL_ID}]}" >/dev/null 2>&1 || true
@ -151,10 +151,10 @@ cleanup() {
if [ "$CLAIMED" = true ] && [ -z "${PR_NUMBER:-}" ]; then if [ "$CLAIMED" = true ] && [ -z "${PR_NUMBER:-}" ]; then
log "cleanup: unclaiming issue (no PR created)" log "cleanup: unclaiming issue (no PR created)"
curl -sf -X DELETE \ curl -sf -X DELETE \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${ISSUE}/labels/${IN_PROGRESS_LABEL_ID}" >/dev/null 2>&1 || true "${API}/issues/${ISSUE}/labels/${IN_PROGRESS_LABEL_ID}" >/dev/null 2>&1 || true
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/issues/${ISSUE}/labels" \ "${API}/issues/${ISSUE}/labels" \
-d "{\"labels\":[${BACKLOG_LABEL_ID}]}" >/dev/null 2>&1 || true -d "{\"labels\":[${BACKLOG_LABEL_ID}]}" >/dev/null 2>&1 || true
@ -198,7 +198,7 @@ echo $$ > "$LOCKFILE"
# FETCH ISSUE # FETCH ISSUE
# ============================================================================= # =============================================================================
status "fetching issue" status "fetching issue"
ISSUE_JSON=$(curl -s -H "Authorization: token ${CODEBERG_TOKEN}" "${API}/issues/${ISSUE}") || true ISSUE_JSON=$(curl -s -H "Authorization: token ${FORGE_TOKEN}" "${API}/issues/${ISSUE}") || true
if [ -z "$ISSUE_JSON" ] || ! echo "$ISSUE_JSON" | jq -e '.id' >/dev/null 2>&1; then if [ -z "$ISSUE_JSON" ] || ! echo "$ISSUE_JSON" | jq -e '.id' >/dev/null 2>&1; then
log "ERROR: failed to fetch issue #${ISSUE} (API down or invalid response)" log "ERROR: failed to fetch issue #${ISSUE} (API down or invalid response)"
exit 1 exit 1
@ -208,17 +208,17 @@ ISSUE_BODY=$(echo "$ISSUE_JSON" | jq -r '.body // ""')
ISSUE_BODY_ORIGINAL="$ISSUE_BODY" ISSUE_BODY_ORIGINAL="$ISSUE_BODY"
# --- Resolve bot username(s) for comment filtering --- # --- Resolve bot username(s) for comment filtering ---
_bot_login=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ _bot_login=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API%%/repos*}/user" | jq -r '.login // empty' 2>/dev/null || true) "${API%%/repos*}/user" | jq -r '.login // empty' 2>/dev/null || true)
# Build list: token owner + any extra names from CODEBERG_BOT_USERNAMES (comma-separated) # Build list: token owner + any extra names from FORGE_BOT_USERNAMES (comma-separated)
_bot_logins="${_bot_login}" _bot_logins="${_bot_login}"
if [ -n "${CODEBERG_BOT_USERNAMES:-}" ]; then if [ -n "${FORGE_BOT_USERNAMES:-}" ]; then
_bot_logins="${_bot_logins:+${_bot_logins},}${CODEBERG_BOT_USERNAMES}" _bot_logins="${_bot_logins:+${_bot_logins},}${FORGE_BOT_USERNAMES}"
fi fi
# Append human comments to issue body (filter out bot accounts) # Append human comments to issue body (filter out bot accounts)
ISSUE_COMMENTS=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ ISSUE_COMMENTS=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${ISSUE}/comments" | \ "${API}/issues/${ISSUE}/comments" | \
jq -r --arg bots "$_bot_logins" \ jq -r --arg bots "$_bot_logins" \
'($bots | split(",") | map(select(. != ""))) as $bl | '($bots | split(",") | map(select(. != ""))) as $bl |
@ -264,7 +264,7 @@ if [ -n "$DEP_NUMBERS" ]; then
while IFS= read -r dep_num; do while IFS= read -r dep_num; do
[ -z "$dep_num" ] && continue [ -z "$dep_num" ] && continue
# Check if dependency issue is closed (= satisfied) # Check if dependency issue is closed (= satisfied)
DEP_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ DEP_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${dep_num}" | jq -r '.state // "unknown"') "${API}/issues/${dep_num}" | jq -r '.state // "unknown"')
if [ "$DEP_STATE" != "closed" ]; then if [ "$DEP_STATE" != "closed" ]; then
@ -280,9 +280,9 @@ if [ "${#BLOCKED_BY[@]}" -gt 0 ]; then
# Find a suggestion: look for the first blocker that itself has no unmet deps # Find a suggestion: look for the first blocker that itself has no unmet deps
SUGGESTION="" SUGGESTION=""
for blocker in "${BLOCKED_BY[@]}"; do for blocker in "${BLOCKED_BY[@]}"; do
BLOCKER_BODY=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ BLOCKER_BODY=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${blocker}" | jq -r '.body // ""') "${API}/issues/${blocker}" | jq -r '.body // ""')
BLOCKER_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ BLOCKER_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${blocker}" | jq -r '.state') "${API}/issues/${blocker}" | jq -r '.state')
if [ "$BLOCKER_STATE" != "open" ]; then if [ "$BLOCKER_STATE" != "open" ]; then
@ -302,7 +302,7 @@ if [ "${#BLOCKED_BY[@]}" -gt 0 ]; then
if [ -n "$BLOCKER_DEPS" ]; then if [ -n "$BLOCKER_DEPS" ]; then
while IFS= read -r bd; do while IFS= read -r bd; do
[ -z "$bd" ] && continue [ -z "$bd" ] && continue
BD_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ BD_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${bd}" | jq -r '.state // "unknown"') "${API}/issues/${bd}" | jq -r '.state // "unknown"')
if [ "$BD_STATE" != "closed" ]; then if [ "$BD_STATE" != "closed" ]; then
BLOCKER_BLOCKED=true BLOCKER_BLOCKED=true
@ -329,7 +329,7 @@ if [ "${#BLOCKED_BY[@]}" -gt 0 ]; then
# Post comment ONLY if last comment isn't already an unmet dependency notice # Post comment ONLY if last comment isn't already an unmet dependency notice
BLOCKED_LIST=$(printf '#%s, ' "${BLOCKED_BY[@]}" | sed 's/, $//') BLOCKED_LIST=$(printf '#%s, ' "${BLOCKED_BY[@]}" | sed 's/, $//')
LAST_COMMENT_IS_BLOCK=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ LAST_COMMENT_IS_BLOCK=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${ISSUE}/comments?limit=1" | \ "${API}/issues/${ISSUE}/comments?limit=1" | \
jq -r '.[0].body // ""' | grep -c 'Dev-agent: Unmet dependency' || true) jq -r '.[0].body // ""' | grep -c 'Dev-agent: Unmet dependency' || true)
@ -352,7 +352,7 @@ This issue depends on ${BLOCKED_LIST}, which $(if [ "${#BLOCKED_BY[@]}" -eq 1 ];
printf '%s' "$BLOCK_COMMENT" > /tmp/block-comment.txt printf '%s' "$BLOCK_COMMENT" > /tmp/block-comment.txt
jq -Rs '{body: .}' < /tmp/block-comment.txt > /tmp/block-comment.json jq -Rs '{body: .}' < /tmp/block-comment.txt > /tmp/block-comment.json
curl -sf -o /dev/null -X POST \ curl -sf -o /dev/null -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/issues/${ISSUE}/comments" \ "${API}/issues/${ISSUE}/comments" \
--data-binary @/tmp/block-comment.json 2>/dev/null || true --data-binary @/tmp/block-comment.json 2>/dev/null || true
@ -373,13 +373,13 @@ log "preflight passed — no explicit unmet dependencies"
# CLAIM ISSUE # CLAIM ISSUE
# ============================================================================= # =============================================================================
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/issues/${ISSUE}/labels" \ "${API}/issues/${ISSUE}/labels" \
-d "{\"labels\":[${IN_PROGRESS_LABEL_ID}]}" >/dev/null 2>&1 || true -d "{\"labels\":[${IN_PROGRESS_LABEL_ID}]}" >/dev/null 2>&1 || true
curl -sf -X DELETE \ curl -sf -X DELETE \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${ISSUE}/labels/${BACKLOG_LABEL_ID}" >/dev/null 2>&1 || true "${API}/issues/${ISSUE}/labels/${BACKLOG_LABEL_ID}" >/dev/null 2>&1 || true
CLAIMED=true CLAIMED=true
@ -393,7 +393,7 @@ RECOVERY_MODE=false
BODY_PR=$(echo "$ISSUE_BODY_ORIGINAL" | grep -oP 'Existing PR:\s*#\K[0-9]+' | head -1) || true BODY_PR=$(echo "$ISSUE_BODY_ORIGINAL" | grep -oP 'Existing PR:\s*#\K[0-9]+' | head -1) || true
if [ -n "$BODY_PR" ]; then if [ -n "$BODY_PR" ]; then
PR_CHECK=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ PR_CHECK=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${BODY_PR}" | jq -r '{state, head_ref: .head.ref}') "${API}/pulls/${BODY_PR}" | jq -r '{state, head_ref: .head.ref}')
PR_CHECK_STATE=$(echo "$PR_CHECK" | jq -r '.state') PR_CHECK_STATE=$(echo "$PR_CHECK" | jq -r '.state')
if [ "$PR_CHECK_STATE" = "open" ]; then if [ "$PR_CHECK_STATE" = "open" ]; then
@ -405,7 +405,7 @@ fi
if [ -z "$EXISTING_PR" ]; then if [ -z "$EXISTING_PR" ]; then
# Priority 1: match by branch name (most reliable) # Priority 1: match by branch name (most reliable)
FOUND_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ FOUND_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls?state=open&limit=20" | \ "${API}/pulls?state=open&limit=20" | \
jq -r --arg branch "$BRANCH" \ jq -r --arg branch "$BRANCH" \
'.[] | select(.head.ref == $branch) | "\(.number) \(.head.ref)"' | head -1) || true '.[] | select(.head.ref == $branch) | "\(.number) \(.head.ref)"' | head -1) || true
@ -418,7 +418,7 @@ fi
if [ -z "$EXISTING_PR" ]; then if [ -z "$EXISTING_PR" ]; then
# Priority 2: match "Fixes #NNN" in PR body # Priority 2: match "Fixes #NNN" in PR body
FOUND_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ FOUND_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls?state=open&limit=20" | \ "${API}/pulls?state=open&limit=20" | \
jq -r --arg issue "ixes #${ISSUE}\\b" \ jq -r --arg issue "ixes #${ISSUE}\\b" \
'.[] | select(.body | test($issue; "i")) | "\(.number) \(.head.ref)"' | head -1) || true '.[] | select(.body | test($issue; "i")) | "\(.number) \(.head.ref)"' | head -1) || true
@ -432,14 +432,14 @@ fi
# Priority 3: check CLOSED PRs for prior art (don't redo work from scratch) # Priority 3: check CLOSED PRs for prior art (don't redo work from scratch)
PRIOR_ART_DIFF="" PRIOR_ART_DIFF=""
if [ -z "$EXISTING_PR" ]; then if [ -z "$EXISTING_PR" ]; then
CLOSED_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ CLOSED_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls?state=closed&limit=30" | \ "${API}/pulls?state=closed&limit=30" | \
jq -r --arg issue "#${ISSUE}" \ jq -r --arg issue "#${ISSUE}" \
'.[] | select(.merged != true) | select((.title | contains($issue)) or (.body // "" | test("ixes " + $issue + "\\b"; "i"))) | "\(.number) \(.head.ref)"' | head -1) || true '.[] | select(.merged != true) | select((.title | contains($issue)) or (.body // "" | test("ixes " + $issue + "\\b"; "i"))) | "\(.number) \(.head.ref)"' | head -1) || true
if [ -n "$CLOSED_PR" ]; then if [ -n "$CLOSED_PR" ]; then
CLOSED_PR_NUM=$(echo "$CLOSED_PR" | awk '{print $1}') CLOSED_PR_NUM=$(echo "$CLOSED_PR" | awk '{print $1}')
log "found closed (unmerged) PR #${CLOSED_PR_NUM} as prior art" log "found closed (unmerged) PR #${CLOSED_PR_NUM} as prior art"
PRIOR_ART_DIFF=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ PRIOR_ART_DIFF=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${CLOSED_PR_NUM}.diff" | head -500) || true "${API}/pulls/${CLOSED_PR_NUM}.diff" | head -500) || true
if [ -n "$PRIOR_ART_DIFF" ]; then if [ -n "$PRIOR_ART_DIFF" ]; then
log "captured prior art diff from PR #${CLOSED_PR_NUM} ($(echo "$PRIOR_ART_DIFF" | wc -l) lines)" log "captured prior art diff from PR #${CLOSED_PR_NUM} ($(echo "$PRIOR_ART_DIFF" | wc -l) lines)"
@ -530,7 +530,7 @@ SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
# ============================================================================= # =============================================================================
# BUILD PROMPT # BUILD PROMPT
# ============================================================================= # =============================================================================
OPEN_ISSUES_SUMMARY=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ OPEN_ISSUES_SUMMARY=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues?state=open&labels=backlog&limit=20&type=issues" | \ "${API}/issues?state=open&labels=backlog&limit=20&type=issues" | \
jq -r '.[] | "#\(.number) \(.title)"' 2>/dev/null || echo "(could not fetch)") jq -r '.[] | "#\(.number) \(.title)"' 2>/dev/null || echo "(could not fetch)")
@ -607,12 +607,12 @@ if [ "$RECOVERY_MODE" = true ]; then
GIT_DIFF_STAT=$(git -C "$WORKTREE" diff "origin/${PRIMARY_BRANCH}..HEAD" --stat 2>/dev/null | head -20 || echo "(no diff)") GIT_DIFF_STAT=$(git -C "$WORKTREE" diff "origin/${PRIMARY_BRANCH}..HEAD" --stat 2>/dev/null | head -20 || echo "(no diff)")
LAST_PHASE=$(read_phase) LAST_PHASE=$(read_phase)
CI_RESULT=$(cat "/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt" 2>/dev/null || echo "") CI_RESULT=$(cat "/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt" 2>/dev/null || echo "")
REVIEW_COMMENTS=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ REVIEW_COMMENTS=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${PR_NUMBER}/comments?limit=10" | \ "${API}/issues/${PR_NUMBER}/comments?limit=10" | \
jq -r '.[-3:] | .[] | "[\(.user.login)] \(.body[:500])"' 2>/dev/null || echo "(none)") jq -r '.[-3:] | .[] | "[\(.user.login)] \(.body[:500])"' 2>/dev/null || echo "(none)")
INITIAL_PROMPT="You are working in a git worktree at ${WORKTREE} on branch ${BRANCH}. INITIAL_PROMPT="You are working in a git worktree at ${WORKTREE} on branch ${BRANCH}.
This is issue #${ISSUE} for the ${CODEBERG_REPO} project. This is issue #${ISSUE} for the ${FORGE_REPO} project.
## Issue: ${ISSUE_TITLE} ## Issue: ${ISSUE_TITLE}
@ -647,7 +647,7 @@ ${PHASE_PROTOCOL_INSTRUCTIONS}"
else else
# Normal mode: initial implementation prompt # Normal mode: initial implementation prompt
INITIAL_PROMPT="You are working in a git worktree at ${WORKTREE} on branch ${BRANCH}. INITIAL_PROMPT="You are working in a git worktree at ${WORKTREE} on branch ${BRANCH}.
You have been assigned issue #${ISSUE} for the ${CODEBERG_REPO} project. You have been assigned issue #${ISSUE} for the ${FORGE_REPO} project.
## Issue: ${ISSUE_TITLE} ## Issue: ${ISSUE_TITLE}
@ -713,7 +713,7 @@ fi
# CREATE MATRIX THREAD (before tmux so MATRIX_THREAD_ID is available for Stop hook) # CREATE MATRIX THREAD (before tmux so MATRIX_THREAD_ID is available for Stop hook)
# ============================================================================= # =============================================================================
if [ ! -f "${THREAD_FILE}" ] || [ -z "$(cat "$THREAD_FILE" 2>/dev/null)" ]; then if [ ! -f "${THREAD_FILE}" ] || [ -z "$(cat "$THREAD_FILE" 2>/dev/null)" ]; then
ISSUE_URL="${CODEBERG_WEB}/issues/${ISSUE}" ISSUE_URL="${FORGE_WEB}/issues/${ISSUE}"
_thread_id=$(matrix_send_ctx "dev" \ _thread_id=$(matrix_send_ctx "dev" \
"🔧 Issue #${ISSUE}: ${ISSUE_TITLE}${ISSUE_URL}" \ "🔧 Issue #${ISSUE}: ${ISSUE_TITLE}${ISSUE_URL}" \
"🔧 <a href='${ISSUE_URL}'>Issue #${ISSUE}</a>: ${ISSUE_TITLE}") || true "🔧 <a href='${ISSUE_URL}'>Issue #${ISSUE}</a>: ${ISSUE_TITLE}") || true
@ -760,11 +760,11 @@ case "${_MONITOR_LOOP_EXIT:-}" in
if [ "${_MONITOR_LOOP_EXIT:-}" = "idle_prompt" ]; then if [ "${_MONITOR_LOOP_EXIT:-}" = "idle_prompt" ]; then
notify_ctx \ notify_ctx \
"session finished without phase signal — killed. Marking blocked." \ "session finished without phase signal — killed. Marking blocked." \
"session finished without phase signal — killed. Marking blocked.${PR_NUMBER:+ PR <a href='${CODEBERG_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}" "session finished without phase signal — killed. Marking blocked.${PR_NUMBER:+ PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}"
else else
notify_ctx \ notify_ctx \
"session idle for 2h — killed. Marking blocked." \ "session idle for 2h — killed. Marking blocked." \
"session idle for 2h — killed. Marking blocked.${PR_NUMBER:+ PR <a href='${CODEBERG_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}" "session idle for 2h — killed. Marking blocked.${PR_NUMBER:+ PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}"
fi fi
# Post diagnostic comment + label issue blocked # Post diagnostic comment + label issue blocked
post_blocked_diagnostic "${_MONITOR_LOOP_EXIT:-idle_timeout}" post_blocked_diagnostic "${_MONITOR_LOOP_EXIT:-idle_timeout}"

View file

@ -22,7 +22,7 @@ source "$(dirname "$0")/../lib/env.sh"
source "$(dirname "$0")/../lib/ci-helpers.sh" source "$(dirname "$0")/../lib/ci-helpers.sh"
# Gitea labels API requires []int64 — look up the "underspecified" label ID once # Gitea labels API requires []int64 — look up the "underspecified" label ID once
UNDERSPECIFIED_LABEL_ID=$(codeberg_api GET "/labels" 2>/dev/null \ UNDERSPECIFIED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
| jq -r '.[] | select(.name == "underspecified") | .id' 2>/dev/null || true) | jq -r '.[] | select(.name == "underspecified") | .id' 2>/dev/null || true)
UNDERSPECIFIED_LABEL_ID="${UNDERSPECIFIED_LABEL_ID:-1300816}" UNDERSPECIFIED_LABEL_ID="${UNDERSPECIFIED_LABEL_ID:-1300816}"
@ -81,7 +81,7 @@ else:
# Check whether an issue already has the "blocked" label # Check whether an issue already has the "blocked" label
is_blocked() { is_blocked() {
local issue="$1" local issue="$1"
codeberg_api GET "/issues/${issue}/labels" 2>/dev/null \ forge_api GET "/issues/${issue}/labels" 2>/dev/null \
| jq -e '.[] | select(.name == "blocked")' >/dev/null 2>&1 | jq -e '.[] | select(.name == "blocked")' >/dev/null 2>&1
} }
@ -103,14 +103,14 @@ _post_ci_blocked_comment() {
| PR | #${pr_num} |" | PR | #${pr_num} |"
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${CODEBERG_API}/issues/${issue_num}/comments" \ "${FORGE_API}/issues/${issue_num}/comments" \
-d "$(jq -nc --arg b "$comment" '{body:$b}')" >/dev/null 2>&1 || true -d "$(jq -nc --arg b "$comment" '{body:$b}')" >/dev/null 2>&1 || true
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${CODEBERG_API}/issues/${issue_num}/labels" \ "${FORGE_API}/issues/${issue_num}/labels" \
-d "{\"labels\":[${blocked_id}]}" >/dev/null 2>&1 || true -d "{\"labels\":[${blocked_id}]}" >/dev/null 2>&1 || true
} }
@ -169,7 +169,7 @@ handle_ci_exhaustion() {
# HELPER: merge an approved PR directly (no Claude needed) # HELPER: merge an approved PR directly (no Claude needed)
# #
# Merging an approved, CI-green PR is a single API call. Spawning dev-agent # Merging an approved, CI-green PR is a single API call. Spawning dev-agent
# for this fails when the issue is already closed (Codeberg auto-closes issues # for this fails when the issue is already closed (forge auto-closes issues
# on PR creation when body contains "Fixes #N"), causing a respawn loop (#344). # on PR creation when body contains "Fixes #N"), causing a respawn loop (#344).
# ============================================================================= # =============================================================================
try_direct_merge() { try_direct_merge() {
@ -179,7 +179,7 @@ try_direct_merge() {
local merge_resp merge_http local merge_resp merge_http
merge_resp=$(curl -sf -w '\n%{http_code}' -X POST \ merge_resp=$(curl -sf -w '\n%{http_code}' -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${API}/pulls/${pr_num}/merge" \ "${API}/pulls/${pr_num}/merge" \
-d '{"Do":"merge","delete_branch_after_merge":true}' 2>/dev/null) || true -d '{"Do":"merge","delete_branch_after_merge":true}' 2>/dev/null) || true
@ -189,15 +189,15 @@ try_direct_merge() {
if [ "${merge_http:-0}" = "200" ] || [ "${merge_http:-0}" = "204" ]; then if [ "${merge_http:-0}" = "200" ] || [ "${merge_http:-0}" = "204" ]; then
log "PR #${pr_num} merged successfully" log "PR #${pr_num} merged successfully"
if [ "$issue_num" -gt 0 ]; then if [ "$issue_num" -gt 0 ]; then
# Close the issue (may already be closed by Codeberg auto-close) # Close the issue (may already be closed by forge auto-close)
curl -sf -X PATCH \ curl -sf -X PATCH \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${API}/issues/${issue_num}" \ "${API}/issues/${issue_num}" \
-d '{"state":"closed"}' >/dev/null 2>&1 || true -d '{"state":"closed"}' >/dev/null 2>&1 || true
# Remove in-progress label # Remove in-progress label
curl -sf -X DELETE \ curl -sf -X DELETE \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true "${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true
# Clean up phase/session artifacts # Clean up phase/session artifacts
rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase" \ rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase" \
@ -215,7 +215,7 @@ try_direct_merge() {
return 1 return 1
} }
API="${CODEBERG_API}" API="${FORGE_API}"
LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock" LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock"
LOGFILE="${FACTORY_ROOT}/dev/dev-agent-${PROJECT_NAME:-default}.log" LOGFILE="${FACTORY_ROOT}/dev/dev-agent-${PROJECT_NAME:-default}.log"
PREFLIGHT_RESULT="/tmp/dev-agent-preflight.json" PREFLIGHT_RESULT="/tmp/dev-agent-preflight.json"
@ -233,7 +233,7 @@ log() {
# (See #531: direct merges should not be blocked by agent lock) # (See #531: direct merges should not be blocked by agent lock)
# ============================================================================= # =============================================================================
log "pre-lock: scanning for mergeable PRs" log "pre-lock: scanning for mergeable PRs"
PL_PRS=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ PL_PRS=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls?state=open&limit=20") "${API}/pulls?state=open&limit=20")
PL_MERGED_ANY=false PL_MERGED_ANY=false
@ -261,7 +261,7 @@ for i in $(seq 0 $(($(echo "$PL_PRS" | jq 'length') - 1))); do
fi fi
fi fi
PL_CI_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ PL_CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/commits/${PL_PR_SHA}/status" | jq -r '.state // "unknown"') || true "${API}/commits/${PL_PR_SHA}/status" | jq -r '.state // "unknown"') || true
# Non-code PRs may have no CI — treat as passed # Non-code PRs may have no CI — treat as passed
@ -274,7 +274,7 @@ for i in $(seq 0 $(($(echo "$PL_PRS" | jq 'length') - 1))); do
fi fi
# Check for approval (non-stale) # Check for approval (non-stale)
PL_REVIEWS=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ PL_REVIEWS=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${PL_PR_NUM}/reviews") || true "${API}/pulls/${PL_PR_NUM}/reviews") || true
PL_HAS_APPROVE=$(echo "$PL_REVIEWS" | \ PL_HAS_APPROVE=$(echo "$PL_REVIEWS" | \
jq -r '[.[] | select(.state == "APPROVED") | select(.stale == false)] | length') || true jq -r '[.[] | select(.state == "APPROVED") | select(.stale == false)] | length') || true
@ -319,7 +319,7 @@ dep_is_merged() {
# Check issue is closed # Check issue is closed
local dep_state local dep_state
dep_state=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ dep_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${dep_num}" | jq -r '.state // "open"') "${API}/issues/${dep_num}" | jq -r '.state // "open"')
if [ "$dep_state" != "closed" ]; then if [ "$dep_state" != "closed" ]; then
return 1 return 1
@ -370,7 +370,7 @@ issue_is_ready() {
# PRIORITY 1: orphaned in-progress issues # PRIORITY 1: orphaned in-progress issues
# ============================================================================= # =============================================================================
log "checking for in-progress issues" log "checking for in-progress issues"
ORPHANS_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ ORPHANS_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues?state=open&labels=in-progress&limit=10&type=issues") "${API}/issues?state=open&labels=in-progress&limit=10&type=issues")
ORPHAN_COUNT=$(echo "$ORPHANS_JSON" | jq 'length') ORPHAN_COUNT=$(echo "$ORPHANS_JSON" | jq 'length')
@ -383,21 +383,21 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then
SKIP_LABEL=$(echo "$ORPHAN_LABELS" | grep -oE '^(formula|action|prediction/backlog|prediction/unreviewed)$' | head -1) || true SKIP_LABEL=$(echo "$ORPHAN_LABELS" | grep -oE '^(formula|action|prediction/backlog|prediction/unreviewed)$' | head -1) || true
if [ -n "$SKIP_LABEL" ]; then if [ -n "$SKIP_LABEL" ]; then
log "issue #${ISSUE_NUM} has '${SKIP_LABEL}' label — removing in-progress, skipping" log "issue #${ISSUE_NUM} has '${SKIP_LABEL}' label — removing in-progress, skipping"
curl -sf -X DELETE -H "Authorization: token ${CODEBERG_TOKEN}" \ curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${ISSUE_NUM}/labels/in-progress" >/dev/null 2>&1 || true "${API}/issues/${ISSUE_NUM}/labels/in-progress" >/dev/null 2>&1 || true
exit 0 exit 0
fi fi
# Check if there's already an open PR for this issue # Check if there's already an open PR for this issue
HAS_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ HAS_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls?state=open&limit=20" | \ "${API}/pulls?state=open&limit=20" | \
jq -r --arg branch "fix/issue-${ISSUE_NUM}" \ jq -r --arg branch "fix/issue-${ISSUE_NUM}" \
'.[] | select(.head.ref == $branch) | .number' | head -1) || true '.[] | select(.head.ref == $branch) | .number' | head -1) || true
if [ -n "$HAS_PR" ]; then if [ -n "$HAS_PR" ]; then
PR_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ PR_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${HAS_PR}" | jq -r '.head.sha') || true "${API}/pulls/${HAS_PR}" | jq -r '.head.sha') || true
CI_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') || true "${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') || true
# Non-code PRs (docs, formulas, evidence) may have no CI — treat as passed # Non-code PRs (docs, formulas, evidence) may have no CI — treat as passed
@ -407,7 +407,7 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then
fi fi
# Check formal reviews (single fetch to avoid race window) # Check formal reviews (single fetch to avoid race window)
REVIEWS_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ REVIEWS_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${HAS_PR}/reviews") || true "${API}/pulls/${HAS_PR}/reviews") || true
HAS_APPROVE=$(echo "$REVIEWS_JSON" | \ HAS_APPROVE=$(echo "$REVIEWS_JSON" | \
jq -r '[.[] | select(.state == "APPROVED") | select(.stale == false)] | length') || true jq -r '[.[] | select(.state == "APPROVED") | select(.stale == false)] | length') || true
@ -482,7 +482,7 @@ fi
# PRIORITY 1.5: any open PR with REQUEST_CHANGES or CI failure (stuck PRs) # PRIORITY 1.5: any open PR with REQUEST_CHANGES or CI failure (stuck PRs)
# ============================================================================= # =============================================================================
log "checking for stuck PRs" log "checking for stuck PRs"
OPEN_PRS=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ OPEN_PRS=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls?state=open&limit=20") "${API}/pulls?state=open&limit=20")
for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do
@ -510,7 +510,7 @@ for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do
fi fi
fi fi
CI_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') || true "${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') || true
# Non-code PRs (docs, formulas, evidence) may have no CI — treat as passed # Non-code PRs (docs, formulas, evidence) may have no CI — treat as passed
@ -520,7 +520,7 @@ for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do
fi fi
# Single fetch to avoid race window between review checks # Single fetch to avoid race window between review checks
REVIEWS_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ REVIEWS_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${PR_NUM}/reviews") || true "${API}/pulls/${PR_NUM}/reviews") || true
HAS_CHANGES=$(echo "$REVIEWS_JSON" | \ HAS_CHANGES=$(echo "$REVIEWS_JSON" | \
jq -r '[.[] | select(.state == "REQUEST_CHANGES") | select(.stale == false)] | length') || true jq -r '[.[] | select(.state == "REQUEST_CHANGES") | select(.stale == false)] | length') || true
@ -601,12 +601,12 @@ log "scanning backlog for ready issues"
ensure_priority_label >/dev/null 2>&1 || true ensure_priority_label >/dev/null 2>&1 || true
# Tier 1: issues with both "priority" and "backlog" labels # Tier 1: issues with both "priority" and "backlog" labels
PRIORITY_BACKLOG_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ PRIORITY_BACKLOG_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues?state=open&labels=priority,backlog&limit=20&type=issues&sort=oldest") || true "${API}/issues?state=open&labels=priority,backlog&limit=20&type=issues&sort=oldest") || true
PRIORITY_BACKLOG_JSON="${PRIORITY_BACKLOG_JSON:-[]}" PRIORITY_BACKLOG_JSON="${PRIORITY_BACKLOG_JSON:-[]}"
# Tier 2: all "backlog" issues (includes priority ones — deduplicated below) # Tier 2: all "backlog" issues (includes priority ones — deduplicated below)
ALL_BACKLOG_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ ALL_BACKLOG_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues?state=open&labels=backlog&limit=20&type=issues&sort=oldest") "${API}/issues?state=open&labels=backlog&limit=20&type=issues&sort=oldest")
# Combine: priority issues first, then remaining backlog issues (deduped) # Combine: priority issues first, then remaining backlog issues (deduped)
@ -644,15 +644,15 @@ for i in $(seq 0 $((BACKLOG_COUNT - 1))); do
fi fi
# Check if there's already an open PR for this issue that needs attention # Check if there's already an open PR for this issue that needs attention
EXISTING_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ EXISTING_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls?state=open&limit=20" | \ "${API}/pulls?state=open&limit=20" | \
jq -r --arg branch "fix/issue-${ISSUE_NUM}" --arg num "#${ISSUE_NUM}" \ jq -r --arg branch "fix/issue-${ISSUE_NUM}" --arg num "#${ISSUE_NUM}" \
'.[] | select((.head.ref == $branch) or (.title | contains($num))) | .number' | head -1) || true '.[] | select((.head.ref == $branch) or (.title | contains($num))) | .number' | head -1) || true
if [ -n "$EXISTING_PR" ]; then if [ -n "$EXISTING_PR" ]; then
PR_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ PR_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${EXISTING_PR}" | jq -r '.head.sha') || true "${API}/pulls/${EXISTING_PR}" | jq -r '.head.sha') || true
CI_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') || true "${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') || true
# Non-code PRs (docs, formulas, evidence) may have no CI — treat as passed # Non-code PRs (docs, formulas, evidence) may have no CI — treat as passed
@ -662,7 +662,7 @@ for i in $(seq 0 $((BACKLOG_COUNT - 1))); do
fi fi
# Single fetch to avoid race window between review checks # Single fetch to avoid race window between review checks
REVIEWS_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ REVIEWS_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${EXISTING_PR}/reviews") || true "${API}/pulls/${EXISTING_PR}/reviews") || true
HAS_APPROVE=$(echo "$REVIEWS_JSON" | \ HAS_APPROVE=$(echo "$REVIEWS_JSON" | \
jq -r '[.[] | select(.state == "APPROVED") | select(.stale == false)] | length') || true jq -r '[.[] | select(.state == "APPROVED") | select(.stale == false)] | length') || true
@ -766,7 +766,7 @@ if [ -f "$PREFLIGHT_RESULT" ]; then
REASON=$(jq -r '.reason // "unspecified"' < "$PREFLIGHT_RESULT" 2>/dev/null || echo "unspecified") REASON=$(jq -r '.reason // "unspecified"' < "$PREFLIGHT_RESULT" 2>/dev/null || echo "unspecified")
log "#${READY_ISSUE} too large: ${REASON}" log "#${READY_ISSUE} too large: ${REASON}"
# Label as underspecified # Label as underspecified
curl -sf -X POST -H "Authorization: token ${CODEBERG_TOKEN}" \ curl -sf -X POST -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/issues/${READY_ISSUE}/labels" \ "${API}/issues/${READY_ISSUE}/labels" \
-d "{\"labels\":[${UNDERSPECIFIED_LABEL_ID}]}" >/dev/null 2>&1 || true -d "{\"labels\":[${UNDERSPECIFIED_LABEL_ID}]}" >/dev/null 2>&1 || true

View file

@ -5,7 +5,7 @@
# Defines: post_refusal_comment(), _on_phase_change(), build_phase_protocol_prompt() # Defines: post_refusal_comment(), _on_phase_change(), build_phase_protocol_prompt()
# #
# Required globals (set by calling agent before or after sourcing): # Required globals (set by calling agent before or after sourcing):
# ISSUE, CODEBERG_TOKEN, API, CODEBERG_WEB, PROJECT_NAME, FACTORY_ROOT # ISSUE, FORGE_TOKEN, API, FORGE_WEB, PROJECT_NAME, FACTORY_ROOT
# BRANCH, PHASE_FILE, WORKTREE, IMPL_SUMMARY_FILE, THREAD_FILE # BRANCH, PHASE_FILE, WORKTREE, IMPL_SUMMARY_FILE, THREAD_FILE
# PRIMARY_BRANCH, SESSION_NAME, LOGFILE, ISSUE_TITLE # PRIMARY_BRANCH, SESSION_NAME, LOGFILE, ISSUE_TITLE
# WOODPECKER_REPO_ID, WOODPECKER_TOKEN, WOODPECKER_SERVER # WOODPECKER_REPO_ID, WOODPECKER_TOKEN, WOODPECKER_SERVER
@ -47,7 +47,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/../lib/ci-helpers.sh"
# in-progress label, and adds the "blocked" label. # in-progress label, and adds the "blocked" label.
# #
# Args: reason [session_name] # Args: reason [session_name]
# Uses globals: ISSUE, SESSION_NAME, PR_NUMBER, CODEBERG_TOKEN, API # Uses globals: ISSUE, SESSION_NAME, PR_NUMBER, FORGE_TOKEN, API
post_blocked_diagnostic() { post_blocked_diagnostic() {
local reason="$1" local reason="$1"
local session="${2:-${SESSION_NAME:-}}" local session="${2:-${SESSION_NAME:-}}"
@ -88,7 +88,7 @@ ${tmux_output}
# Post comment to issue # Post comment to issue
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/issues/${ISSUE}/comments" \ "${API}/issues/${ISSUE}/comments" \
-d "$(jq -nc --arg b "$comment" '{body:$b}')" >/dev/null 2>&1 || true -d "$(jq -nc --arg b "$comment" '{body:$b}')" >/dev/null 2>&1 || true
@ -99,7 +99,7 @@ ${tmux_output}
blocked_id=$(ensure_blocked_label_id) blocked_id=$(ensure_blocked_label_id)
if [ -n "$blocked_id" ]; then if [ -n "$blocked_id" ]; then
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/issues/${ISSUE}/labels" \ "${API}/issues/${ISSUE}/labels" \
-d "{\"labels\":[${blocked_id}]}" >/dev/null 2>&1 || true -d "{\"labels\":[${blocked_id}]}" >/dev/null 2>&1 || true
@ -173,7 +173,7 @@ _PHASE_PROTOCOL_EOF_
} }
# --- Merge helper --- # --- Merge helper ---
# do_merge — attempt to merge PR via Codeberg API. # do_merge — attempt to merge PR via forge API.
# Args: pr_num # Args: pr_num
# Returns: # Returns:
# 0 = merged successfully # 0 = merged successfully
@ -183,7 +183,7 @@ do_merge() {
local pr_num="$1" local pr_num="$1"
local merge_response merge_http_code merge_body local merge_response merge_http_code merge_body
merge_response=$(curl -s -w "\n%{http_code}" -X POST \ merge_response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${API}/pulls/${pr_num}/merge" \ "${API}/pulls/${pr_num}/merge" \
-d '{"Do":"merge","delete_branch_after_merge":true}') || true -d '{"Do":"merge","delete_branch_after_merge":true}') || true
@ -199,7 +199,7 @@ do_merge() {
# Before escalating, check whether the PR was already merged by another agent. # Before escalating, check whether the PR was already merged by another agent.
if [ "$merge_http_code" = "405" ]; then if [ "$merge_http_code" = "405" ]; then
local pr_state local pr_state
pr_state=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ pr_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${pr_num}" | jq -r '.merged // false') || pr_state="false" "${API}/pulls/${pr_num}" | jq -r '.merged // false') || pr_state="false"
if [ "$pr_state" = "true" ]; then if [ "$pr_state" = "true" ]; then
log "do_merge: PR #${pr_num} already merged (detected after HTTP 405) — treating as success" log "do_merge: PR #${pr_num} already merged (detected after HTTP 405) — treating as success"
@ -220,7 +220,7 @@ do_merge() {
post_refusal_comment() { post_refusal_comment() {
local emoji="$1" title="$2" body="$3" local emoji="$1" title="$2" body="$3"
local last_has_title local last_has_title
last_has_title=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ last_has_title=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${ISSUE}/comments?limit=5" | \ "${API}/issues/${ISSUE}/comments?limit=5" | \
jq -r --arg t "Dev-agent: ${title}" '[.[] | .body // ""] | any(contains($t)) | tostring') || true jq -r --arg t "Dev-agent: ${title}" '[.[] | .body // ""] | any(contains($t)) | tostring') || true
if [ "$last_has_title" = "true" ]; then if [ "$last_has_title" = "true" ]; then
@ -237,7 +237,7 @@ ${body}
printf '%s' "$comment" > "/tmp/refusal-comment.txt" printf '%s' "$comment" > "/tmp/refusal-comment.txt"
jq -Rs '{body: .}' < "/tmp/refusal-comment.txt" > "/tmp/refusal-comment.json" jq -Rs '{body: .}' < "/tmp/refusal-comment.txt" > "/tmp/refusal-comment.json"
curl -sf -o /dev/null -X POST \ curl -sf -o /dev/null -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/issues/${ISSUE}/comments" \ "${API}/issues/${ISSUE}/comments" \
--data-binary @"/tmp/refusal-comment.json" 2>/dev/null || \ --data-binary @"/tmp/refusal-comment.json" 2>/dev/null || \
@ -278,7 +278,7 @@ _on_phase_change() {
'{title: $title, body: $body, head: $head, base: $base}' > "/tmp/pr-request-${ISSUE}.json" '{title: $title, body: $body, head: $head, base: $base}' > "/tmp/pr-request-${ISSUE}.json"
PR_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ PR_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/pulls" \ "${API}/pulls" \
--data-binary @"/tmp/pr-request-${ISSUE}.json") --data-binary @"/tmp/pr-request-${ISSUE}.json")
@ -290,13 +290,13 @@ _on_phase_change() {
if [ "$PR_HTTP_CODE" = "201" ] || [ "$PR_HTTP_CODE" = "200" ]; then if [ "$PR_HTTP_CODE" = "201" ] || [ "$PR_HTTP_CODE" = "200" ]; then
PR_NUMBER=$(echo "$PR_RESPONSE_BODY" | jq -r '.number') PR_NUMBER=$(echo "$PR_RESPONSE_BODY" | jq -r '.number')
log "created PR #${PR_NUMBER}" log "created PR #${PR_NUMBER}"
PR_URL="${CODEBERG_WEB}/pulls/${PR_NUMBER}" PR_URL="${FORGE_WEB}/pulls/${PR_NUMBER}"
notify_ctx \ notify_ctx \
"PR #${PR_NUMBER} created: ${ISSUE_TITLE}" \ "PR #${PR_NUMBER} created: ${ISSUE_TITLE}" \
"PR <a href='${PR_URL}'>#${PR_NUMBER}</a> created: ${ISSUE_TITLE}" "PR <a href='${PR_URL}'>#${PR_NUMBER}</a> created: ${ISSUE_TITLE}"
elif [ "$PR_HTTP_CODE" = "409" ]; then elif [ "$PR_HTTP_CODE" = "409" ]; then
# PR already exists (race condition) — find it # PR already exists (race condition) — find it
FOUND_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ FOUND_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls?state=open&limit=20" | \ "${API}/pulls?state=open&limit=20" | \
jq -r --arg branch "$BRANCH" \ jq -r --arg branch "$BRANCH" \
'.[] | select(.head.ref == $branch) | .number' | head -1) || true '.[] | select(.head.ref == $branch) | .number' | head -1) || true
@ -305,7 +305,7 @@ _on_phase_change() {
log "PR already exists: #${PR_NUMBER}" log "PR already exists: #${PR_NUMBER}"
else else
log "ERROR: PR creation got 409 but no existing PR found" log "ERROR: PR creation got 409 but no existing PR found"
agent_inject_into_session "$SESSION_NAME" "ERROR: Could not create PR (HTTP 409, no existing PR found). Check the Codeberg API. Retry by writing PHASE:awaiting_ci again after verifying the branch was pushed." agent_inject_into_session "$SESSION_NAME" "ERROR: Could not create PR (HTTP 409, no existing PR found). Check the forge API. Retry by writing PHASE:awaiting_ci again after verifying the branch was pushed."
return 0 return 0
fi fi
else else
@ -327,7 +327,7 @@ Write PHASE:awaiting_review to the phase file, then stop and wait for review fee
# Poll CI until done or timeout # Poll CI until done or timeout
status "waiting for CI on PR #${PR_NUMBER}" status "waiting for CI on PR #${PR_NUMBER}"
CI_CURRENT_SHA=$(git -C "${WORKTREE}" rev-parse HEAD 2>/dev/null || \ CI_CURRENT_SHA=$(git -C "${WORKTREE}" rev-parse HEAD 2>/dev/null || \
curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha') "${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha')
CI_DONE=false CI_DONE=false
@ -346,7 +346,7 @@ Write PHASE:awaiting_review to the phase file, then stop and wait for review fee
# Re-fetch HEAD — Claude may have pushed new commits since loop started # Re-fetch HEAD — Claude may have pushed new commits since loop started
CI_CURRENT_SHA=$(git -C "${WORKTREE}" rev-parse HEAD 2>/dev/null || echo "$CI_CURRENT_SHA") CI_CURRENT_SHA=$(git -C "${WORKTREE}" rev-parse HEAD 2>/dev/null || echo "$CI_CURRENT_SHA")
CI_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/commits/${CI_CURRENT_SHA}/status" | jq -r '.state // "unknown"') "${API}/commits/${CI_CURRENT_SHA}/status" | jq -r '.state // "unknown"')
if [ "$CI_STATE" = "success" ] || [ "$CI_STATE" = "failure" ] || [ "$CI_STATE" = "error" ]; then if [ "$CI_STATE" = "success" ] || [ "$CI_STATE" = "failure" ] || [ "$CI_STATE" = "error" ]; then
CI_DONE=true CI_DONE=true
@ -370,7 +370,7 @@ Write PHASE:awaiting_review to the phase file, then stop and wait for review fee
echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\"" echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\""
else else
# Fetch CI error details # Fetch CI error details
PIPELINE_NUM=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ PIPELINE_NUM=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/commits/${CI_CURRENT_SHA}/status" | \ "${API}/commits/${CI_CURRENT_SHA}/status" | \
jq -r '.statuses[0].target_url // ""' | grep -oP 'pipeline/\K[0-9]+' | head -1 || true) jq -r '.statuses[0].target_url // ""' | grep -oP 'pipeline/\K[0-9]+' | head -1 || true)
@ -411,7 +411,7 @@ Write PHASE:awaiting_review to the phase file, then stop and wait for review fee
log "CI failure not recoverable after ${CI_FIX_COUNT} fix attempts — escalating" log "CI failure not recoverable after ${CI_FIX_COUNT} fix attempts — escalating"
notify_ctx \ notify_ctx \
"CI exhausted after ${CI_FIX_COUNT} attempts — escalating for human help" \ "CI exhausted after ${CI_FIX_COUNT} attempts — escalating for human help" \
"CI exhausted after ${CI_FIX_COUNT} attempts on PR <a href='${PR_URL:-${CODEBERG_WEB}/pulls/${PR_NUMBER}}'>#${PR_NUMBER}</a> | <a href='${_ci_pipeline_url}'>Pipeline</a><br>Step: <code>${FAILED_STEP:-unknown}</code> — escalating for human help" "CI exhausted after ${CI_FIX_COUNT} attempts on PR <a href='${PR_URL:-${FORGE_WEB}/pulls/${PR_NUMBER}}'>#${PR_NUMBER}</a> | <a href='${_ci_pipeline_url}'>Pipeline</a><br>Step: <code>${FAILED_STEP:-unknown}</code> — escalating for human help"
printf 'PHASE:escalate\nReason: ci_exhausted after %d attempts (step: %s)\n' "$CI_FIX_COUNT" "${FAILED_STEP:-unknown}" > "$PHASE_FILE" printf 'PHASE:escalate\nReason: ci_exhausted after %d attempts (step: %s)\n' "$CI_FIX_COUNT" "${FAILED_STEP:-unknown}" > "$PHASE_FILE"
# Do NOT update LAST_PHASE_MTIME here — let the main loop detect PHASE:escalate # Do NOT update LAST_PHASE_MTIME here — let the main loop detect PHASE:escalate
return 0 return 0
@ -431,7 +431,7 @@ Write PHASE:awaiting_review to the phase file, then stop and wait for review fee
_ci_snippet=$(printf '%s' "${CI_ERROR_LOG:-}" | tail -5 | head -c 500 | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g') _ci_snippet=$(printf '%s' "${CI_ERROR_LOG:-}" | tail -5 | head -c 500 | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
notify_ctx \ notify_ctx \
"CI failed on PR #${PR_NUMBER}: step=${FAILED_STEP:-unknown} (attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES})" \ "CI failed on PR #${PR_NUMBER}: step=${FAILED_STEP:-unknown} (attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES})" \
"CI failed on PR <a href='${PR_URL:-${CODEBERG_WEB}/pulls/${PR_NUMBER}}'>#${PR_NUMBER}</a> | <a href='${_ci_pipeline_url}'>Pipeline #${PIPELINE_NUM:-?}</a><br>Step: <code>${FAILED_STEP:-unknown}</code> (exit ${FAILED_EXIT:-?})<br>Attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES}<br><pre>${_ci_snippet:-no logs}</pre>" "CI failed on PR <a href='${PR_URL:-${FORGE_WEB}/pulls/${PR_NUMBER}}'>#${PR_NUMBER}</a> | <a href='${_ci_pipeline_url}'>Pipeline #${PIPELINE_NUM:-?}</a><br>Step: <code>${FAILED_STEP:-unknown}</code> (exit ${FAILED_EXIT:-?})<br>Attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES}<br><pre>${_ci_snippet:-no logs}</pre>"
agent_inject_into_session "$SESSION_NAME" "CI failed on PR #${PR_NUMBER} (attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES}). agent_inject_into_session "$SESSION_NAME" "CI failed on PR #${PR_NUMBER} (attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES}).
@ -460,7 +460,7 @@ Instructions:
if [ -z "${PR_NUMBER:-}" ]; then if [ -z "${PR_NUMBER:-}" ]; then
log "WARNING: awaiting_review but PR_NUMBER unknown — searching for PR" log "WARNING: awaiting_review but PR_NUMBER unknown — searching for PR"
FOUND_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ FOUND_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls?state=open&limit=20" | \ "${API}/pulls?state=open&limit=20" | \
jq -r --arg branch "$BRANCH" \ jq -r --arg branch "$BRANCH" \
'.[] | select(.head.ref == $branch) | .number' | head -1) || true '.[] | select(.head.ref == $branch) | .number' | head -1) || true
@ -498,9 +498,9 @@ Instructions:
break break
fi fi
REVIEW_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ REVIEW_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha') || true "${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha') || true
REVIEW_COMMENT=$(codeberg_api_all "/issues/${PR_NUMBER}/comments" | \ REVIEW_COMMENT=$(forge_api_all "/issues/${PR_NUMBER}/comments" | \
jq -r --arg sha "$REVIEW_SHA" \ jq -r --arg sha "$REVIEW_SHA" \
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true '[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true
@ -516,9 +516,9 @@ Instructions:
VERDICT=$(echo "$REVIEW_TEXT" | grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*' || true) VERDICT=$(echo "$REVIEW_TEXT" | grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*' || true)
log "review verdict: ${VERDICT:-unknown}" log "review verdict: ${VERDICT:-unknown}"
# Also check formal Codeberg reviews # Also check formal forge reviews
if [ -z "$VERDICT" ]; then if [ -z "$VERDICT" ]; then
VERDICT=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ VERDICT=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${PR_NUMBER}/reviews" | \ "${API}/pulls/${PR_NUMBER}/reviews" | \
jq -r '[.[] | select(.stale == false)] | last | .state // empty' || true) jq -r '[.[] | select(.stale == false)] | last | .state // empty' || true)
if [ "$VERDICT" = "APPROVED" ]; then if [ "$VERDICT" = "APPROVED" ]; then
@ -548,7 +548,7 @@ Instructions:
if [ "$_merge_rc" -eq 0 ]; then if [ "$_merge_rc" -eq 0 ]; then
# Merge succeeded — close issue and signal done # Merge succeeded — close issue and signal done
curl -sf -X PATCH \ curl -sf -X PATCH \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${API}/issues/${ISSUE}" \ "${API}/issues/${ISSUE}" \
-d '{"state":"closed"}' >/dev/null 2>&1 || true -d '{"state":"closed"}' >/dev/null 2>&1 || true
@ -596,7 +596,7 @@ Instructions:
fi fi
# Check if PR was merged or closed externally # Check if PR was merged or closed externally
PR_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ PR_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${PR_NUMBER}") || true "${API}/pulls/${PR_NUMBER}") || true
PR_STATE=$(echo "$PR_JSON" | jq -r '.state // "unknown"') PR_STATE=$(echo "$PR_JSON" | jq -r '.state // "unknown"')
PR_MERGED=$(echo "$PR_JSON" | jq -r '.merged // false') PR_MERGED=$(echo "$PR_JSON" | jq -r '.merged // false')
@ -605,8 +605,8 @@ Instructions:
log "PR #${PR_NUMBER} was merged externally" log "PR #${PR_NUMBER} was merged externally"
notify_ctx \ notify_ctx \
"✅ PR #${PR_NUMBER} merged externally! Issue #${ISSUE} done." \ "✅ PR #${PR_NUMBER} merged externally! Issue #${ISSUE} done." \
"✅ PR <a href='${CODEBERG_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a> merged externally! <a href='${CODEBERG_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> done." "✅ PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a> merged externally! <a href='${FORGE_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> done."
curl -sf -X PATCH -H "Authorization: token ${CODEBERG_TOKEN}" \ curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/issues/${ISSUE}" -d '{"state":"closed"}' >/dev/null 2>&1 || true "${API}/issues/${ISSUE}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
cleanup_labels cleanup_labels
@ -637,9 +637,9 @@ Instructions:
elif [ "$phase" = "PHASE:escalate" ]; then elif [ "$phase" = "PHASE:escalate" ]; then
status "escalated — waiting for human input on issue #${ISSUE}" status "escalated — waiting for human input on issue #${ISSUE}"
ESCALATE_REASON=$(sed -n '2p' "$PHASE_FILE" 2>/dev/null | sed 's/^Reason: //' || echo "") ESCALATE_REASON=$(sed -n '2p' "$PHASE_FILE" 2>/dev/null | sed 's/^Reason: //' || echo "")
_issue_url="${CODEBERG_WEB}/issues/${ISSUE}" _issue_url="${FORGE_WEB}/issues/${ISSUE}"
_pr_link="" _pr_link=""
[ -n "${PR_NUMBER:-}" ] && _pr_link=" | PR <a href='${CODEBERG_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>" [ -n "${PR_NUMBER:-}" ] && _pr_link=" | PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>"
notify_ctx \ notify_ctx \
"⚠️ Issue #${ISSUE} (PR #${PR_NUMBER:-none}) escalated — needs human input.${ESCALATE_REASON:+ Reason: ${ESCALATE_REASON}}" \ "⚠️ Issue #${ISSUE} (PR #${PR_NUMBER:-none}) escalated — needs human input.${ESCALATE_REASON:+ Reason: ${ESCALATE_REASON}}" \
"⚠️ <a href='${_issue_url}'>Issue #${ISSUE}</a>${_pr_link} escalated — needs human input.${ESCALATE_REASON:+ Reason: ${ESCALATE_REASON}}<br>Reply in this thread to send guidance to the agent." "⚠️ <a href='${_issue_url}'>Issue #${ISSUE}</a>${_pr_link} escalated — needs human input.${ESCALATE_REASON:+ Reason: ${ESCALATE_REASON}}<br>Reply in this thread to send guidance to the agent."
@ -653,12 +653,12 @@ Instructions:
status "phase done — PR #${PR_NUMBER} merged, cleaning up" status "phase done — PR #${PR_NUMBER} merged, cleaning up"
notify_ctx \ notify_ctx \
"✅ PR #${PR_NUMBER} merged! Issue #${ISSUE} done." \ "✅ PR #${PR_NUMBER} merged! Issue #${ISSUE} done." \
"✅ PR <a href='${CODEBERG_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a> merged! <a href='${CODEBERG_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> done." "✅ PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a> merged! <a href='${FORGE_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> done."
else else
status "phase done — issue #${ISSUE} complete, cleaning up" status "phase done — issue #${ISSUE} complete, cleaning up"
notify_ctx \ notify_ctx \
"✅ Issue #${ISSUE} done." \ "✅ Issue #${ISSUE} done." \
"✅ <a href='${CODEBERG_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> done." "✅ <a href='${FORGE_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> done."
fi fi
# Belt-and-suspenders: ensure in-progress label removed (idempotent) # Belt-and-suspenders: ensure in-progress label removed (idempotent)
@ -680,10 +680,10 @@ Instructions:
FAILURE_REASON="${FAILURE_REASON:-unspecified}" FAILURE_REASON="${FAILURE_REASON:-unspecified}"
log "phase: failed — reason: ${FAILURE_REASON}" log "phase: failed — reason: ${FAILURE_REASON}"
# Gitea labels API requires []int64 — look up the "backlog" label ID once # Gitea labels API requires []int64 — look up the "backlog" label ID once
BACKLOG_LABEL_ID=$(codeberg_api GET "/labels" 2>/dev/null \ BACKLOG_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
| jq -r '.[] | select(.name == "backlog") | .id' 2>/dev/null || true) | jq -r '.[] | select(.name == "backlog") | .id' 2>/dev/null || true)
BACKLOG_LABEL_ID="${BACKLOG_LABEL_ID:-1300815}" BACKLOG_LABEL_ID="${BACKLOG_LABEL_ID:-1300815}"
UNDERSPECIFIED_LABEL_ID=$(codeberg_api GET "/labels" 2>/dev/null \ UNDERSPECIFIED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
| jq -r '.[] | select(.name == "underspecified") | .id' 2>/dev/null || true) | jq -r '.[] | select(.name == "underspecified") | .id' 2>/dev/null || true)
UNDERSPECIFIED_LABEL_ID="${UNDERSPECIFIED_LABEL_ID:-1300816}" UNDERSPECIFIED_LABEL_ID="${UNDERSPECIFIED_LABEL_ID:-1300816}"
@ -703,7 +703,7 @@ Instructions:
# Unclaim issue (restore backlog label, remove in-progress) # Unclaim issue (restore backlog label, remove in-progress)
cleanup_labels cleanup_labels
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/issues/${ISSUE}/labels" \ "${API}/issues/${ISSUE}/labels" \
-d "{\"labels\":[${BACKLOG_LABEL_ID}]}" >/dev/null 2>&1 || true -d "{\"labels\":[${BACKLOG_LABEL_ID}]}" >/dev/null 2>&1 || true
@ -732,12 +732,12 @@ ${REASON}
### Next steps ### Next steps
A maintainer should split this issue or add more detail to the spec." A maintainer should split this issue or add more detail to the spec."
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/issues/${ISSUE}/labels" \ "${API}/issues/${ISSUE}/labels" \
-d "{\"labels\":[${UNDERSPECIFIED_LABEL_ID}]}" >/dev/null 2>&1 || true -d "{\"labels\":[${UNDERSPECIFIED_LABEL_ID}]}" >/dev/null 2>&1 || true
curl -sf -X DELETE \ curl -sf -X DELETE \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${ISSUE}/labels/${BACKLOG_LABEL_ID}" >/dev/null 2>&1 || true "${API}/issues/${ISSUE}/labels/${BACKLOG_LABEL_ID}" >/dev/null 2>&1 || true
notify "refused #${ISSUE}: too large — ${REASON}" notify "refused #${ISSUE}: too large — ${REASON}"
;; ;;
@ -749,7 +749,7 @@ ${REASON}
Closing as already implemented." Closing as already implemented."
curl -sf -X PATCH \ curl -sf -X PATCH \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API}/issues/${ISSUE}" \ "${API}/issues/${ISSUE}" \
-d '{"state":"closed"}' >/dev/null 2>&1 || true -d '{"state":"closed"}' >/dev/null 2>&1 || true
@ -779,7 +779,7 @@ $(printf '%s' "$REFUSAL_JSON" | head -c 2000)
log "session failed: ${FAILURE_REASON}" log "session failed: ${FAILURE_REASON}"
notify_ctx \ notify_ctx \
"❌ Issue #${ISSUE} session failed: ${FAILURE_REASON}" \ "❌ Issue #${ISSUE} session failed: ${FAILURE_REASON}" \
"❌ <a href='${CODEBERG_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> session failed: ${FAILURE_REASON}${PR_NUMBER:+ | PR <a href='${CODEBERG_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}" "❌ <a href='${FORGE_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> session failed: ${FAILURE_REASON}${PR_NUMBER:+ | PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}"
post_blocked_diagnostic "$FAILURE_REASON" post_blocked_diagnostic "$FAILURE_REASON"
agent_kill_session "$SESSION_NAME" agent_kill_session "$SESSION_NAME"
@ -801,7 +801,7 @@ $(printf '%s' "$REFUSAL_JSON" | head -c 2000)
log "session crashed for issue #${ISSUE}" log "session crashed for issue #${ISSUE}"
notify_ctx \ notify_ctx \
"session crashed unexpectedly — marking blocked" \ "session crashed unexpectedly — marking blocked" \
"session crashed unexpectedly — marking blocked${PR_NUMBER:+ | PR <a href='${CODEBERG_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}" "session crashed unexpectedly — marking blocked${PR_NUMBER:+ | PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}"
post_blocked_diagnostic "crashed" post_blocked_diagnostic "crashed"
[ -z "${PR_NUMBER:-}" ] && cleanup_worktree [ -z "${PR_NUMBER:-}" ] && cleanup_worktree
[ -n "${PR_NUMBER:-}" ] && log "keeping worktree (PR #${PR_NUMBER} still open)" [ -n "${PR_NUMBER:-}" ] && log "keeping worktree (PR #${PR_NUMBER} still open)"

View file

@ -24,7 +24,7 @@ Different domains have different platforms:
| Domain | Platform | What it tracks | Status | | Domain | Platform | What it tracks | Status |
|--------|----------|---------------|--------| |--------|----------|---------------|--------|
| Code | Codeberg | Issues, PRs, reviews | **Implemented** — Live | | Code | forge | Issues, PRs, reviews | **Implemented** — Live |
| CI/CD | Woodpecker | Build/test results | **Implemented** — Live | | CI/CD | Woodpecker | Build/test results | **Implemented** — Live |
| Protocol | Ponder / GraphQL | On-chain state, trades, positions | **Partial** — Live (not yet wired to evidence) | | Protocol | Ponder / GraphQL | On-chain state, trades, positions | **Partial** — Live (not yet wired to evidence) |
| Infrastructure | DigitalOcean / system stats | CPU, RAM, disk, containers | **Planned** — Supervisor monitors, no evidence output yet | | Infrastructure | DigitalOcean / system stats | CPU, RAM, disk, containers | **Planned** — Supervisor monitors, no evidence output yet |

View file

@ -82,7 +82,7 @@ PHASE:escalate → send Matrix notification with context (issue/PR link,
on reply → matrix_listener.sh injects reply into tmux session on reply → matrix_listener.sh injects reply into tmux session
on timeout → 24h: label issue blocked, kill session on timeout → 24h: label issue blocked, kill session
PHASE:done → verify PR merged on Codeberg PHASE:done → verify PR merged on forge
if merged → kill tmux session, clean labels, close issue if merged → kill tmux session, clean labels, close issue
if not → inject "PR not merged yet" into session if not → inject "PR not merged yet" into session

View file

@ -24,12 +24,12 @@ a `mode` field. Two modes are supported:
In this mode, skip the normal tech-debt grooming pipeline. Instead: In this mode, skip the normal tech-debt grooming pipeline. Instead:
a. Fetch the target issue: a. Fetch the target issue:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues/<target_issue>" "$FORGE_API/issues/<target_issue>"
b. Fetch ALL comments on the target issue to understand scope and b. Fetch ALL comments on the target issue to understand scope and
prior bounce reasons: prior bounce reasons:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues/<target_issue>/comments?limit=50" "$FORGE_API/issues/<target_issue>/comments?limit=50"
c. Read the affected files listed in the issue body to understand c. Read the affected files listed in the issue body to understand
the actual code scope. the actual code scope.
d. Break the issue into 2-5 sub-issues, each sized for a single d. Break the issue into 2-5 sub-issues, each sized for a single
@ -63,8 +63,8 @@ description = """
This step only runs in grooming mode. Skip if in breakdown mode. This step only runs in grooming mode. Skip if in breakdown mode.
Fetch all open tech-debt issues: Fetch all open tech-debt issues:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?type=issues&state=open&limit=50" | \ "$FORGE_API/issues?type=issues&state=open&limit=50" | \
jq '[.[] | select(.labels | map(.name) | any(. == "tech-debt"))]' jq '[.[] | select(.labels | map(.name) | any(. == "tech-debt"))]'
For each issue compute a triage score: For each issue compute a triage score:
@ -94,14 +94,14 @@ These are issues that block backlog items but are not themselves labeled backlog
The dev-agent is completely starved until they are promoted or resolved. The dev-agent is completely starved until they are promoted or resolved.
For each tier-0 issue: For each tier-0 issue:
- Read the full body: curl -sf -H "Authorization: token $CODEBERG_TOKEN" "$CODEBERG_API/issues/{number}" - Read the full body: curl -sf -H "Authorization: token $FORGE_TOKEN" "$FORGE_API/issues/{number}"
- If resolvable: promote to backlog add acceptance criteria, affected files, relabel - If resolvable: promote to backlog add acceptance criteria, affected files, relabel
- If needs human decision: add to ESCALATE block - If needs human decision: add to ESCALATE block
- If invalid / wontfix: close with explanation comment - If invalid / wontfix: close with explanation comment
After completing all tier-0, re-fetch to check for new blockers: After completing all tier-0, re-fetch to check for new blockers:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?type=issues&state=open&limit=50" | \ "$FORGE_API/issues?type=issues&state=open&limit=50" | \
jq '[.[] | select(.labels | map(.name) | any(. == "tech-debt"))]' jq '[.[] | select(.labels | map(.name) | any(. == "tech-debt"))]'
If new tier-0 blockers appeared, process those too. If new tier-0 blockers appeared, process those too.
@ -172,8 +172,8 @@ id = "verify"
title = "Verify completion and loop until zero tech-debt" title = "Verify completion and loop until zero tech-debt"
description = """ description = """
Re-fetch ALL open tech-debt issues and count them: Re-fetch ALL open tech-debt issues and count them:
REMAINING=$(curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ REMAINING=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?type=issues&state=open&limit=50" | \ "$FORGE_API/issues?type=issues&state=open&limit=50" | \
jq '[.[] | select(.labels | map(.name) | any(. == "tech-debt"))] | length') jq '[.[] | select(.labels | map(.name) | any(. == "tech-debt"))] | length')
echo "Remaining tech-debt: $REMAINING" echo "Remaining tech-debt: $REMAINING"

View file

@ -78,25 +78,25 @@ If you discover pre-existing issues (NOT introduced by this PR), create
tech-debt issues via API so they are tracked separately: tech-debt issues via API so they are tracked separately:
# Look up tech-debt label ID (create if missing): # Look up tech-debt label ID (create if missing):
TECH_DEBT_ID=$(curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ TECH_DEBT_ID=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/labels" | jq -r '.[] | select(.name=="tech-debt") | .id') "$FORGE_API/labels" | jq -r '.[] | select(.name=="tech-debt") | .id')
if [ -z "$TECH_DEBT_ID" ]; then if [ -z "$TECH_DEBT_ID" ]; then
TECH_DEBT_ID=$(curl -sf -X POST \ TECH_DEBT_ID=$(curl -sf -X POST \
-H "Authorization: token $CODEBERG_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/labels" \ "$FORGE_API/labels" \
-d '{"name":"tech-debt","color":"#6B7280","description":"Pre-existing tech debt flagged by AI review"}' | jq -r '.id') -d '{"name":"tech-debt","color":"#6B7280","description":"Pre-existing tech debt flagged by AI review"}' | jq -r '.id')
fi fi
# Check for duplicate before creating: # Check for duplicate before creating:
EXISTING=$(curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ EXISTING=$(curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?state=open&labels=tech-debt&limit=50" | \ "$FORGE_API/issues?state=open&labels=tech-debt&limit=50" | \
jq --arg t "TITLE" '[.[] | select(.title == $t)] | length') jq --arg t "TITLE" '[.[] | select(.title == $t)] | length')
# Create only if no duplicate: # Create only if no duplicate:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" "$CODEBERG_API/issues" \ -H "Content-Type: application/json" "$FORGE_API/issues" \
-d '{"title":"...","body":"Flagged by AI reviewer in PR #NNN.\n\n## Problem\n...\n\n---\n*Auto-created from AI review*","labels":[TECH_DEBT_ID]}' -d '{"title":"...","body":"Flagged by AI reviewer in PR #NNN.\n\n## Problem\n...\n\n---\n*Auto-created from AI review*","labels":[TECH_DEBT_ID]}'
Only create follow-ups for clear, actionable tech debt. Do not create Only create follow-ups for clear, actionable tech debt. Do not create
@ -138,5 +138,5 @@ For a re-review, structure the markdown as:
After writing the JSON file, signal completion: After writing the JSON file, signal completion:
echo "PHASE:done" > "$PHASE_FILE" echo "PHASE:done" > "$PHASE_FILE"
Then STOP and wait. The orchestrator will post your review to Codeberg. Then STOP and wait. The orchestrator will post your review to the forge.
""" """

View file

@ -56,8 +56,8 @@ Groom the open issue backlog. This step is the core Claude-driven analysis
Pre-checks (bash, zero tokens detect problems before invoking Claude): Pre-checks (bash, zero tokens detect problems before invoking Claude):
1. Fetch all open issues: 1. Fetch all open issues:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?state=open&type=issues&limit=50&sort=updated&direction=desc" "$FORGE_API/issues?state=open&type=issues&limit=50&sort=updated&direction=desc"
2. Duplicate detection: compare issue titles pairwise. Normalize 2. Duplicate detection: compare issue titles pairwise. Normalize
(lowercase, strip prefixes like feat:/fix:/refactor:, collapse whitespace) (lowercase, strip prefixes like feat:/fix:/refactor:, collapse whitespace)
@ -162,7 +162,7 @@ Sibling dependency rule (CRITICAL):
If either section is missing: If either section is missing:
a. Write a comment action to the manifest: a. Write a comment action to the manifest:
echo '{"action":"comment","issue":NNN,"body":"This issue is missing required sections. Please use the issue templates at `.codeberg/ISSUE_TEMPLATE/` — needs: <missing items>."}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl" echo '{"action":"comment","issue":NNN,"body":"This issue is missing required sections. Please use the issue templates at `.forgejo/ISSUE_TEMPLATE/` — needs: <missing items>."}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
Where <missing items> is a comma-separated list of what's absent Where <missing items> is a comma-separated list of what's absent
(e.g. "acceptance criteria, affected files" or just "affected files"). (e.g. "acceptance criteria, affected files" or just "affected files").
b. Write a remove_label action to the manifest: b. Write a remove_label action to the manifest:
@ -249,19 +249,19 @@ Review all issues labeled 'blocked' and decide their fate.
(See issue #352 for the blocked label convention.) (See issue #352 for the blocked label convention.)
1. Fetch all blocked issues: 1. Fetch all blocked issues:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?state=open&type=issues&labels=blocked&limit=50" "$FORGE_API/issues?state=open&type=issues&labels=blocked&limit=50"
2. For each blocked issue, read the full body and comments: 2. For each blocked issue, read the full body and comments:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues/<number>" "$FORGE_API/issues/<number>"
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues/<number>/comments" "$FORGE_API/issues/<number>/comments"
3. Check dependencies extract issue numbers from ## Dependencies / 3. Check dependencies extract issue numbers from ## Dependencies /
## Depends on / ## Blocked by sections. For each dependency: ## Depends on / ## Blocked by sections. For each dependency:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues/<dep_number>" "$FORGE_API/issues/<dep_number>"
Check if the dependency is now closed. Check if the dependency is now closed.
4. For each blocked issue, choose ONE action: 4. For each blocked issue, choose ONE action:
@ -459,9 +459,9 @@ executes them after the PR merges.
git push -u origin "$BRANCH" git push -u origin "$BRANCH"
g. Create a PR: g. Create a PR:
PR_RESPONSE=$(curl -sf -X POST \ PR_RESPONSE=$(curl -sf -X POST \
-H "Authorization: token $CODEBERG_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/pulls" \ "$FORGE_API/pulls" \
-d '{"title":"chore: gardener housekeeping", -d '{"title":"chore: gardener housekeeping",
"head":"'"$BRANCH"'","base":"'"$PRIMARY_BRANCH"'", "head":"'"$BRANCH"'","base":"'"$PRIMARY_BRANCH"'",
"body":"Automated gardener housekeeping — AGENTS.md updates + pending actions manifest.\\n\\nReview `gardener/pending-actions.json` for proposed grooming actions (label changes, closures, comments). These execute after merge."}') "body":"Automated gardener housekeeping — AGENTS.md updates + pending actions manifest.\\n\\nReview `gardener/pending-actions.json` for proposed grooming actions (label changes, closures, comments). These execute after merge."}')

View file

@ -60,8 +60,8 @@ Evidence from the preflight step informs whether each prediction is valid
(e.g. "red-team stale since March 12" is confirmed by evidence/ timestamps). (e.g. "red-team stale since March 12" is confirmed by evidence/ timestamps).
1. Fetch unreviewed predictions: 1. Fetch unreviewed predictions:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?state=open&type=issues&labels=prediction%2Funreviewed&limit=50" "$FORGE_API/issues?state=open&type=issues&labels=prediction%2Funreviewed&limit=50"
If there are none, note that and skip to step 3b (label resolution If there are none, note that and skip to step 3b (label resolution
is still required the file-at-constraints step needs label IDs). is still required the file-at-constraints step needs label IDs).
@ -72,10 +72,10 @@ Evidence from the preflight step informs whether each prediction is valid
Project formulas are dispatched via action issues on the project repo. Project formulas are dispatched via action issues on the project repo.
3. Fetch all open issues to check for overlap: 3. Fetch all open issues to check for overlap:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?state=open&type=issues&limit=50" "$FORGE_API/issues?state=open&type=issues&limit=50"
3b. Resolve label IDs needed for triage AND filing (fetch via $CODEBERG_API/labels). 3b. Resolve label IDs needed for triage AND filing (fetch via $FORGE_API/labels).
ALWAYS execute this step, even if there are no predictions to triage ALWAYS execute this step, even if there are no predictions to triage
the file-at-constraints step depends on these IDs: the file-at-constraints step depends on these IDs:
- <unreviewed_label_id> prediction/unreviewed - <unreviewed_label_id> prediction/unreviewed
@ -120,65 +120,65 @@ Evidence from the preflight step informs whether each prediction is valid
Example body structure: Example body structure:
## Problem\n<what the prediction identified>\n\n## Proposed solution\n<approach>\n\n## Affected files\n- <file1>\n- <file2>\n\n## Acceptance criteria\n- [ ] <criterion 1>\n- [ ] CI green ## Problem\n<what the prediction identified>\n\n## Proposed solution\n<approach>\n\n## Affected files\n- <file1>\n- <file2>\n\n## Acceptance criteria\n- [ ] <criterion 1>\n- [ ] CI green
Create the issue: Create the issue:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" "$CODEBERG_API/issues" \ -H "Content-Type: application/json" "$FORGE_API/issues" \
-d '{"title":"...","body":"...","labels":[<label_id>]}' -d '{"title":"...","body":"...","labels":[<label_id>]}'
Extract the issue number from the response (jq -r '.number'). Extract the issue number from the response (jq -r '.number').
a2. Verify the label was applied (Codeberg may silently drop labels a2. Verify the label was applied (the forge may silently drop labels
on creation). Re-apply via a separate POST if missing: on creation). Re-apply via a separate POST if missing:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues/<new_issue_num>/labels" \ "$FORGE_API/issues/<new_issue_num>/labels" \
-d '{"labels":[<label_id>]}' -d '{"labels":[<label_id>]}'
b. Comment on the prediction with "Actioned as #NNN — <reasoning>": b. Comment on the prediction with "Actioned as #NNN — <reasoning>":
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues/<pred_num>/comments" \ "$FORGE_API/issues/<pred_num>/comments" \
-d '{"body":"Actioned as #NNN — <reasoning>"}' -d '{"body":"Actioned as #NNN — <reasoning>"}'
c. Relabel: remove prediction/unreviewed, add prediction/actioned: c. Relabel: remove prediction/unreviewed, add prediction/actioned:
curl -sf -X DELETE -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X DELETE -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues/<pred_num>/labels/<unreviewed_label_id>" "$FORGE_API/issues/<pred_num>/labels/<unreviewed_label_id>"
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues/<pred_num>/labels" \ "$FORGE_API/issues/<pred_num>/labels" \
-d '{"labels":[<actioned_label_id>]}' -d '{"labels":[<actioned_label_id>]}'
d. Close the prediction: d. Close the prediction:
curl -sf -X PATCH -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X PATCH -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues/<pred_num>" \ "$FORGE_API/issues/<pred_num>" \
-d '{"state":"closed"}' -d '{"state":"closed"}'
For WATCH: For WATCH:
a. Comment with reasoning why not urgent: a. Comment with reasoning why not urgent:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues/<pred_num>/comments" \ "$FORGE_API/issues/<pred_num>/comments" \
-d '{"body":"Watching — <reasoning>"}' -d '{"body":"Watching — <reasoning>"}'
b. Replace prediction/unreviewed label with prediction/backlog: b. Replace prediction/unreviewed label with prediction/backlog:
curl -sf -X DELETE -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X DELETE -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues/<pred_num>/labels/<unreviewed_label_id>" "$FORGE_API/issues/<pred_num>/labels/<unreviewed_label_id>"
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues/<pred_num>/labels" \ "$FORGE_API/issues/<pred_num>/labels" \
-d '{"labels":[<prediction_backlog_label_id>]}' -d '{"labels":[<prediction_backlog_label_id>]}'
For DISMISS: For DISMISS:
a. Comment with explicit reasoning: a. Comment with explicit reasoning:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues/<pred_num>/comments" \ "$FORGE_API/issues/<pred_num>/comments" \
-d '{"body":"Dismissed — <reasoning>"}' -d '{"body":"Dismissed — <reasoning>"}'
b. Relabel: remove prediction/unreviewed, add prediction/actioned: b. Relabel: remove prediction/unreviewed, add prediction/actioned:
curl -sf -X DELETE -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X DELETE -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues/<pred_num>/labels/<unreviewed_label_id>" "$FORGE_API/issues/<pred_num>/labels/<unreviewed_label_id>"
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues/<pred_num>/labels" \ "$FORGE_API/issues/<pred_num>/labels" \
-d '{"labels":[<actioned_label_id>]}' -d '{"labels":[<actioned_label_id>]}'
c. Close the prediction: c. Close the prediction:
curl -sf -X PATCH -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X PATCH -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues/<pred_num>" \ "$FORGE_API/issues/<pred_num>" \
-d '{"state":"closed"}' -d '{"state":"closed"}'
6. Track promoted predictions they are added to the prerequisite tree 6. Track promoted predictions they are added to the prerequisite tree
@ -208,8 +208,8 @@ Read these inputs:
- $PROJECT_REPO_ROOT/formulas/*.toml project-specific formulas - $PROJECT_REPO_ROOT/formulas/*.toml project-specific formulas
- Open issues (fetched via API, or reuse from prediction-triage) - Open issues (fetched via API, or reuse from prediction-triage)
- Closed issues (fetch recently closed to detect resolved prerequisites): - Closed issues (fetch recently closed to detect resolved prerequisites):
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?state=closed&type=issues&limit=50&sort=updated&direction=desc" "$FORGE_API/issues?state=closed&type=issues&limit=50&sort=updated&direction=desc"
- Planner memory (loaded in preflight) - Planner memory (loaded in preflight)
- Promoted predictions from prediction-triage (add as prerequisites if relevant) - Promoted predictions from prediction-triage (add as prerequisites if relevant)
@ -218,8 +218,8 @@ Read these inputs:
For each issue referenced in the prerequisite tree (by #number), fetch its For each issue referenced in the prerequisite tree (by #number), fetch its
recent comments to detect signals that the issue is stuck or bouncing: recent comments to detect signals that the issue is stuck or bouncing:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues/<number>/comments?limit=10" "$FORGE_API/issues/<number>/comments?limit=10"
Scan each comment body for these signals: Scan each comment body for these signals:
@ -394,17 +394,17 @@ Filing gate — for each constraint (that is NOT stuck):
## Problem\n<what this prerequisite is and which objectives it blocks>\n\n## Proposed solution\n<rough approach>\n\n## Affected files\n- <file1>\n- <file2>\n\n## Acceptance criteria\n- [ ] <criterion derived from the constraint>\n- [ ] CI green\n\n## Dependencies\n- #NNN (if depends on other open issues) ## Problem\n<what this prerequisite is and which objectives it blocks>\n\n## Proposed solution\n<rough approach>\n\n## Affected files\n- <file1>\n- <file2>\n\n## Acceptance criteria\n- [ ] <criterion derived from the constraint>\n- [ ] CI green\n\n## Dependencies\n- #NNN (if depends on other open issues)
Create the issue: Create the issue:
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token $CODEBERG_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues" \ "$FORGE_API/issues" \
-d '{"title":"...","body":"...","labels":[<backlog_label_id>]}' -d '{"title":"...","body":"...","labels":[<backlog_label_id>]}'
Extract the issue number from the response (jq -r '.number'). Extract the issue number from the response (jq -r '.number').
2b. Verify the label was applied (Codeberg may silently drop labels 2b. Verify the label was applied (the forge may silently drop labels
on creation). Always re-apply via a separate POST to be safe: on creation). Always re-apply via a separate POST to be safe:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues/<new_issue_num>/labels" \ "$FORGE_API/issues/<new_issue_num>/labels" \
-d '{"labels":[<backlog_label_id>]}' -d '{"labels":[<backlog_label_id>]}'
3. If an issue already exists and is open, skip it no duplicate filing. 3. If an issue already exists and is open, skip it no duplicate filing.
@ -422,20 +422,20 @@ is purely additive.
5. **Add `priority` to top-5 constraint issues:** 5. **Add `priority` to top-5 constraint issues:**
For each of the top 5 constraint issues (whether just filed or already For each of the top 5 constraint issues (whether just filed or already
existing), check if it already has the `priority` label. If not, add it: existing), check if it already has the `priority` label. If not, add it:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues/<issue_number>/labels" \ "$FORGE_API/issues/<issue_number>/labels" \
-d '{"labels":[<priority_label_id>]}' -d '{"labels":[<priority_label_id>]}'
6. **Remove `priority` from issues no longer in top 5:** 6. **Remove `priority` from issues no longer in top 5:**
Fetch all open issues that currently have the `priority` label: Fetch all open issues that currently have the `priority` label:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?state=open&labels=priority&type=issues&limit=50" "$FORGE_API/issues?state=open&labels=priority&type=issues&limit=50"
For each issue in this list that is NOT one of the current top 5 For each issue in this list that is NOT one of the current top 5
constraint issues, remove the `priority` label (demote back to plain constraint issues, remove the `priority` label (demote back to plain
`backlog`): `backlog`):
curl -sf -X DELETE -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X DELETE -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues/<issue_number>/labels/<priority_label_id>" "$FORGE_API/issues/<issue_number>/labels/<priority_label_id>"
This keeps the priority set current only the active bottleneck issues This keeps the priority set current only the active bottleneck issues
get priority, not stale constraints from previous runs. get priority, not stale constraints from previous runs.
@ -634,9 +634,9 @@ run — only file changes (tree, journal, MEMORY.md) need the PR.
git push -u origin "$BRANCH" git push -u origin "$BRANCH"
g. Create a PR: g. Create a PR:
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token $CODEBERG_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/pulls" \ "$FORGE_API/pulls" \
-d '{"title":"chore: planner run — prerequisite tree update", -d '{"title":"chore: planner run — prerequisite tree update",
"head":"<branch>","base":"<primary-branch>", "head":"<branch>","base":"<primary-branch>",
"body":"Automated planner run — prerequisite tree update and journal entry."}' "body":"Automated planner run — prerequisite tree update and journal entry."}'

View file

@ -3,7 +3,7 @@
# Goal: find the project's biggest weakness. Explore when uncertain, # Goal: find the project's biggest weakness. Explore when uncertain,
# exploit when confident (dispatch a formula to prove the theory). # exploit when confident (dispatch a formula to prove the theory).
# #
# Memory: previous predictions on Codeberg ARE the memory. # Memory: previous predictions on the forge ARE the memory.
# No separate memory file — the issue tracker is the source of truth. # No separate memory file — the issue tracker is the source of truth.
# #
# Executed by predictor/predictor-run.sh via cron — no action issues. # Executed by predictor/predictor-run.sh via cron — no action issues.
@ -33,12 +33,12 @@ Set up the working environment and load your prediction history.
git pull --ff-only origin "$PRIMARY_BRANCH" --quiet git pull --ff-only origin "$PRIMARY_BRANCH" --quiet
2. Fetch ALL your previous predictions (open + recently closed): 2. Fetch ALL your previous predictions (open + recently closed):
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?state=open&type=issues&labels=prediction%2Funreviewed&limit=50" "$FORGE_API/issues?state=open&type=issues&labels=prediction%2Funreviewed&limit=50"
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?state=open&type=issues&labels=prediction%2Fbacklog&limit=50" "$FORGE_API/issues?state=open&type=issues&labels=prediction%2Fbacklog&limit=50"
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/issues?state=closed&type=issues&labels=prediction%2Factioned&limit=50&sort=updated&direction=desc" "$FORGE_API/issues?state=closed&type=issues&labels=prediction%2Factioned&limit=50&sort=updated&direction=desc"
For each prediction, note: For each prediction, note:
- What you predicted (title + body) - What you predicted (title + body)
@ -150,21 +150,21 @@ For each weakness you identify, choose one:
## Filing ## Filing
1. Look up label IDs: 1. Look up label IDs:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/labels" | jq '[.[] | select(.name | startswith("prediction")) | {name, id}]' "$FORGE_API/labels" | jq '[.[] | select(.name | startswith("prediction")) | {name, id}]'
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$CODEBERG_API/labels" | jq '.[] | select(.name == "action") | .id' "$FORGE_API/labels" | jq '.[] | select(.name == "action") | .id'
2. File predictions: 2. File predictions:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues" \ "$FORGE_API/issues" \
-d '{"title":"<title>","body":"<body>","labels":[<prediction_unreviewed_id>]}' -d '{"title":"<title>","body":"<body>","labels":[<prediction_unreviewed_id>]}'
3. File action dispatches (if exploiting): 3. File action dispatches (if exploiting):
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \ curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$CODEBERG_API/issues" \ "$FORGE_API/issues" \
-d '{"title":"action: test prediction #NNN — <formula> <focus>","body":"<body>","labels":[<action_label_id>]}' -d '{"title":"action: test prediction #NNN — <formula> <focus>","body":"<body>","labels":[<action_label_id>]}'
4. Do NOT duplicate existing open predictions. If your theory matches 4. Do NOT duplicate existing open predictions. If your theory matches

View file

@ -28,7 +28,7 @@ runs directly from cron like the planner, predictor, and supervisor.
PR, reviewed alongside AGENTS.md changes, executed by gardener-run.sh after merge. PR, reviewed alongside AGENTS.md changes, executed by gardener-run.sh after merge.
**Environment variables consumed**: **Environment variables consumed**:
- `CODEBERG_TOKEN`, `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT` - `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by gardener-run.sh) - `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by gardener-run.sh)
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` - `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER`

View file

@ -105,7 +105,7 @@ If no file changes in commit-and-pr:
echo 'PHASE:done' > '${PHASE_FILE}'" echo 'PHASE:done' > '${PHASE_FILE}'"
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor # shellcheck disable=SC2034 # consumed by run_formula_and_monitor
PROMPT="You are the issue gardener for ${CODEBERG_REPO}. Work through the formula below. Follow the phase protocol: if the commit-and-pr step creates a PR, write PHASE:awaiting_ci and wait for orchestrator CI/review/merge handling. If no file changes, write PHASE:done. The orchestrator will time you out if you return to the prompt without signalling. PROMPT="You are the issue gardener for ${FORGE_REPO}. Work through the formula below. Follow the phase protocol: if the commit-and-pr step creates a PR, write PHASE:awaiting_ci and wait for orchestrator CI/review/merge handling. If no file changes, write PHASE:done. The orchestrator will time you out if you return to the prompt without signalling.
You have full shell access and --dangerously-skip-permissions. You have full shell access and --dangerously-skip-permissions.
Fix what you can. Escalate what you cannot. Do NOT ask permission — act first, report after. Fix what you can. Escalate what you cannot. Do NOT ask permission — act first, report after.
@ -162,13 +162,13 @@ _gardener_execute_manifest() {
add_label) add_label)
local label label_id local label label_id
label=$(jq -r ".[$i].label" "$manifest_file") label=$(jq -r ".[$i].label" "$manifest_file")
label_id=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/labels" | jq -r --arg n "$label" \ "${FORGE_API}/labels" | jq -r --arg n "$label" \
'.[] | select(.name == $n) | .id') || true '.[] | select(.name == $n) | .id') || true
if [ -n "$label_id" ]; then if [ -n "$label_id" ]; then
if curl -sf -X POST -H "Authorization: token ${CODEBERG_TOKEN}" \ if curl -sf -X POST -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${CODEBERG_API}/issues/${issue}/labels" \ "${FORGE_API}/issues/${issue}/labels" \
-d "{\"labels\":[${label_id}]}" >/dev/null 2>&1; then -d "{\"labels\":[${label_id}]}" >/dev/null 2>&1; then
log "manifest: add_label '${label}' to #${issue}" log "manifest: add_label '${label}' to #${issue}"
else else
@ -182,12 +182,12 @@ _gardener_execute_manifest() {
remove_label) remove_label)
local label label_id local label label_id
label=$(jq -r ".[$i].label" "$manifest_file") label=$(jq -r ".[$i].label" "$manifest_file")
label_id=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/labels" | jq -r --arg n "$label" \ "${FORGE_API}/labels" | jq -r --arg n "$label" \
'.[] | select(.name == $n) | .id') || true '.[] | select(.name == $n) | .id') || true
if [ -n "$label_id" ]; then if [ -n "$label_id" ]; then
if curl -sf -X DELETE -H "Authorization: token ${CODEBERG_TOKEN}" \ if curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/issues/${issue}/labels/${label_id}" >/dev/null 2>&1; then "${FORGE_API}/issues/${issue}/labels/${label_id}" >/dev/null 2>&1; then
log "manifest: remove_label '${label}' from #${issue}" log "manifest: remove_label '${label}' from #${issue}"
else else
log "manifest: FAILED remove_label '${label}' from #${issue}" log "manifest: FAILED remove_label '${label}' from #${issue}"
@ -200,9 +200,9 @@ _gardener_execute_manifest() {
close) close)
local reason local reason
reason=$(jq -r ".[$i].reason // empty" "$manifest_file") reason=$(jq -r ".[$i].reason // empty" "$manifest_file")
if curl -sf -X PATCH -H "Authorization: token ${CODEBERG_TOKEN}" \ if curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${CODEBERG_API}/issues/${issue}" \ "${FORGE_API}/issues/${issue}" \
-d '{"state":"closed"}' >/dev/null 2>&1; then -d '{"state":"closed"}' >/dev/null 2>&1; then
log "manifest: closed #${issue} (${reason})" log "manifest: closed #${issue} (${reason})"
else else
@ -214,9 +214,9 @@ _gardener_execute_manifest() {
local body escaped_body local body escaped_body
body=$(jq -r ".[$i].body" "$manifest_file") body=$(jq -r ".[$i].body" "$manifest_file")
escaped_body=$(printf '%s' "$body" | jq -Rs '.') escaped_body=$(printf '%s' "$body" | jq -Rs '.')
if curl -sf -X POST -H "Authorization: token ${CODEBERG_TOKEN}" \ if curl -sf -X POST -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${CODEBERG_API}/issues/${issue}/comments" \ "${FORGE_API}/issues/${issue}/comments" \
-d "{\"body\":${escaped_body}}" >/dev/null 2>&1; then -d "{\"body\":${escaped_body}}" >/dev/null 2>&1; then
log "manifest: commented on #${issue}" log "manifest: commented on #${issue}"
else else
@ -235,8 +235,8 @@ _gardener_execute_manifest() {
label_ids="[]" label_ids="[]"
if [ -n "$labels" ]; then if [ -n "$labels" ]; then
local all_labels ids_json="" local all_labels ids_json=""
all_labels=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ all_labels=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/labels") || true "${FORGE_API}/labels") || true
while IFS= read -r lname; do while IFS= read -r lname; do
local lid local lid
lid=$(echo "$all_labels" | jq -r --arg n "$lname" \ lid=$(echo "$all_labels" | jq -r --arg n "$lname" \
@ -245,9 +245,9 @@ _gardener_execute_manifest() {
done <<< "$labels" done <<< "$labels"
[ -n "$ids_json" ] && label_ids="[${ids_json}]" [ -n "$ids_json" ] && label_ids="[${ids_json}]"
fi fi
if curl -sf -X POST -H "Authorization: token ${CODEBERG_TOKEN}" \ if curl -sf -X POST -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${CODEBERG_API}/issues" \ "${FORGE_API}/issues" \
-d "{\"title\":${escaped_title},\"body\":${escaped_body},\"labels\":${label_ids}}" >/dev/null 2>&1; then -d "{\"title\":${escaped_title},\"body\":${escaped_body},\"labels\":${label_ids}}" >/dev/null 2>&1; then
log "manifest: created issue '${title}'" log "manifest: created issue '${title}'"
else else
@ -259,9 +259,9 @@ _gardener_execute_manifest() {
local body escaped_body local body escaped_body
body=$(jq -r ".[$i].body" "$manifest_file") body=$(jq -r ".[$i].body" "$manifest_file")
escaped_body=$(printf '%s' "$body" | jq -Rs '.') escaped_body=$(printf '%s' "$body" | jq -Rs '.')
if curl -sf -X PATCH -H "Authorization: token ${CODEBERG_TOKEN}" \ if curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${CODEBERG_API}/issues/${issue}" \ "${FORGE_API}/issues/${issue}" \
-d "{\"body\":${escaped_body}}" >/dev/null 2>&1; then -d "{\"body\":${escaped_body}}" >/dev/null 2>&1; then
log "manifest: edited body of #${issue}" log "manifest: edited body of #${issue}"
else else
@ -284,9 +284,9 @@ _gardener_execute_manifest() {
_gardener_merge() { _gardener_merge() {
local merge_response merge_http_code local merge_response merge_http_code
merge_response=$(curl -s -w "\n%{http_code}" -X POST \ merge_response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${CODEBERG_API}/pulls/${_GARDENER_PR}/merge" \ "${FORGE_API}/pulls/${_GARDENER_PR}/merge" \
-d '{"Do":"merge","delete_branch_after_merge":true}') || true -d '{"Do":"merge","delete_branch_after_merge":true}') || true
merge_http_code=$(echo "$merge_response" | tail -1) merge_http_code=$(echo "$merge_response" | tail -1)
@ -300,8 +300,8 @@ _gardener_merge() {
# Already merged (race)? # Already merged (race)?
if [ "$merge_http_code" = "405" ]; then if [ "$merge_http_code" = "405" ]; then
local pr_merged local pr_merged
pr_merged=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ pr_merged=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/pulls/${_GARDENER_PR}" | jq -r '.merged // false') || true "${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.merged // false') || true
if [ "$pr_merged" = "true" ]; then if [ "$pr_merged" = "true" ]; then
log "gardener PR #${_GARDENER_PR} already merged" log "gardener PR #${_GARDENER_PR} already merged"
_gardener_execute_manifest _gardener_execute_manifest
@ -329,9 +329,9 @@ _gardener_timeout_cleanup() {
log "gardener merge-through timed out (${_GARDENER_MERGE_TIMEOUT}s) — closing PR" log "gardener merge-through timed out (${_GARDENER_MERGE_TIMEOUT}s) — closing PR"
if [ -n "$_GARDENER_PR" ]; then if [ -n "$_GARDENER_PR" ]; then
curl -sf -X PATCH \ curl -sf -X PATCH \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
"${CODEBERG_API}/pulls/${_GARDENER_PR}" \ "${FORGE_API}/pulls/${_GARDENER_PR}" \
-d '{"state":"closed"}' >/dev/null 2>&1 || true -d '{"state":"closed"}' >/dev/null 2>&1 || true
fi fi
printf 'PHASE:failed\nReason: merge-through timeout (%ss)\n' \ printf 'PHASE:failed\nReason: merge-through timeout (%ss)\n' \
@ -360,8 +360,8 @@ _gardener_handle_ci() {
fi fi
# Fallback: search for open gardener PRs # Fallback: search for open gardener PRs
if [ -z "$_GARDENER_PR" ]; then if [ -z "$_GARDENER_PR" ]; then
_GARDENER_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ _GARDENER_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/pulls?state=open&limit=10" | \ "${FORGE_API}/pulls?state=open&limit=10" | \
jq -r '[.[] | select(.head.ref | startswith("chore/gardener-"))] | .[0].number // empty') || true jq -r '[.[] | select(.head.ref | startswith("chore/gardener-"))] | .[0].number // empty') || true
fi fi
if [ -z "$_GARDENER_PR" ]; then if [ -z "$_GARDENER_PR" ]; then
@ -395,8 +395,8 @@ Write PHASE:awaiting_review to the phase file, then stop and wait:
# Get HEAD SHA from PR # Get HEAD SHA from PR
local head_sha local head_sha
head_sha=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ head_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true "${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true
if [ -z "$head_sha" ]; then if [ -z "$head_sha" ]; then
log "WARNING: could not get HEAD SHA for PR #${_GARDENER_PR}" log "WARNING: could not get HEAD SHA for PR #${_GARDENER_PR}"
@ -426,11 +426,11 @@ Write PHASE:awaiting_review to the phase file, then stop and wait:
fi fi
# Re-fetch HEAD in case Claude pushed new commits # Re-fetch HEAD in case Claude pushed new commits
head_sha=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ head_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true "${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true
ci_state=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ ci_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/commits/${head_sha}/status" | jq -r '.state // "unknown"') || ci_state="unknown" "${FORGE_API}/commits/${head_sha}/status" | jq -r '.state // "unknown"') || ci_state="unknown"
case "$ci_state" in case "$ci_state" in
success|failure|error) ci_done=true; break ;; success|failure|error) ci_done=true; break ;;
@ -463,8 +463,8 @@ Write PHASE:awaiting_review to the phase file, then stop and wait:
# Get error details # Get error details
local pipeline_num ci_error_log local pipeline_num ci_error_log
pipeline_num=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ pipeline_num=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/commits/${head_sha}/status" | \ "${FORGE_API}/commits/${head_sha}/status" | \
jq -r '.statuses[0].target_url // ""' | grep -oP 'pipeline/\K[0-9]+' | head -1 || true) jq -r '.statuses[0].target_url // ""' | grep -oP 'pipeline/\K[0-9]+' | head -1 || true)
ci_error_log="" ci_error_log=""
@ -518,10 +518,10 @@ _gardener_handle_review() {
# Check for review on current HEAD # Check for review on current HEAD
local review_sha review_comment local review_sha review_comment
review_sha=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ review_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true "${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true
review_comment=$(codeberg_api_all "/issues/${_GARDENER_PR}/comments" 2>/dev/null | \ review_comment=$(forge_api_all "/issues/${_GARDENER_PR}/comments" 2>/dev/null | \
jq -r --arg sha "${review_sha:-none}" \ jq -r --arg sha "${review_sha:-none}" \
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true '[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true
@ -536,10 +536,10 @@ _gardener_handle_review() {
verdict=$(echo "$review_text" | grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*' || true) verdict=$(echo "$review_text" | grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*' || true)
# Check formal Codeberg reviews as fallback # Check formal forge reviews as fallback
if [ -z "$verdict" ]; then if [ -z "$verdict" ]; then
verdict=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ verdict=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/pulls/${_GARDENER_PR}/reviews" | \ "${FORGE_API}/pulls/${_GARDENER_PR}/reviews" | \
jq -r '[.[] | select(.stale == false)] | last | .state // empty' || true) jq -r '[.[] | select(.stale == false)] | last | .state // empty' || true)
[ "$verdict" = "APPROVED" ] && verdict="APPROVE" [ "$verdict" = "APPROVED" ] && verdict="APPROVE"
[[ "$verdict" != "REQUEST_CHANGES" && "$verdict" != "APPROVE" ]] && verdict="" [[ "$verdict" != "REQUEST_CHANGES" && "$verdict" != "APPROVE" ]] && verdict=""
@ -576,8 +576,8 @@ Then stop and wait."
# Check if PR was merged or closed externally # Check if PR was merged or closed externally
local pr_json pr_state pr_merged local pr_json pr_state pr_merged
pr_json=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ pr_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${CODEBERG_API}/pulls/${_GARDENER_PR}") || true "${FORGE_API}/pulls/${_GARDENER_PR}") || true
pr_state=$(echo "$pr_json" | jq -r '.state // "unknown"') pr_state=$(echo "$pr_json" | jq -r '.state // "unknown"')
pr_merged=$(echo "$pr_json" | jq -r '.merged // false') pr_merged=$(echo "$pr_json" | jq -r '.merged // false')

View file

@ -6,10 +6,10 @@ sourced as needed.
| File | What it provides | Sourced by | | File | What it provides | Sourced by |
|---|---|---| |---|---|---|
| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`CODEBERG_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `codeberg_api()`, `codeberg_api_all()` (accepts optional second TOKEN parameter, defaults to `$CODEBERG_TOKEN`), `woodpecker_api()`, `wpdb()`, `matrix_send()`, `matrix_send_ctx()`. Auto-loads project TOML if `PROJECT_TOML` is set. | Every agent | | `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`), `woodpecker_api()`, `wpdb()`, `matrix_send()`, `matrix_send_ctx()`. Auto-loads project TOML if `PROJECT_TOML` is set. | 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`. | 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`. | 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-debug.sh` | CLI tool for Woodpecker CI: `list`, `status`, `logs`, `failures` subcommands. Not sourced — run directly. | Humans / dev-agent (tool access) |
| `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `CODEBERG_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, Matrix config, etc.). | env.sh (when `PROJECT_TOML` is set), supervisor-poll (per-project iteration) | | `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `FORGE_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, Matrix config, etc.). | env.sh (when `PROJECT_TOML` is set), supervisor-poll (per-project iteration) |
| `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` / `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/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/matrix_listener.sh` | Long-poll Matrix sync daemon. Dispatches thread replies to the correct agent via tmux session injection (dev, action, vault, review) or well-known files (`/tmp/{agent}-escalation-reply` for supervisor/gardener). Handles all agent reply routing. Run as systemd service. | Standalone daemon | | `lib/matrix_listener.sh` | Long-poll Matrix sync daemon. Dispatches thread replies to the correct agent via tmux session injection (dev, action, vault, review) or well-known files (`/tmp/{agent}-escalation-reply` for supervisor/gardener). Handles all agent reply routing. Run as systemd service. | Standalone daemon |
| `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `build_context_block()`, `consume_escalation_reply()`, `start_formula_session()`, `formula_phase_callback()`, `build_prompt_footer()`, `run_formula_and_monitor(AGENT [TIMEOUT] [CALLBACK])` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, prompt assembly, tmux session, monitor loop, crash recovery). `formula_phase_callback()` handles `PHASE:escalate` (unified escalation path — kills the session; callers may follow up via Matrix). `run_formula_and_monitor` accepts an optional CALLBACK (default: `formula_phase_callback`) so callers can install custom merge-through or escalation handlers. | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh, action-agent.sh | | `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `build_context_block()`, `consume_escalation_reply()`, `start_formula_session()`, `formula_phase_callback()`, `build_prompt_footer()`, `run_formula_and_monitor(AGENT [TIMEOUT] [CALLBACK])` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, prompt assembly, tmux session, monitor loop, crash recovery). `formula_phase_callback()` handles `PHASE:escalate` (unified escalation path — kills the session; callers may follow up via Matrix). `run_formula_and_monitor` accepts an optional CALLBACK (default: `formula_phase_callback`) so callers can install custom merge-through or escalation handlers. | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh, action-agent.sh |

View file

@ -13,7 +13,7 @@ set -euo pipefail
source "$(dirname "$0")/../lib/env.sh" source "$(dirname "$0")/../lib/env.sh"
# WOODPECKER_TOKEN loaded from .env via env.sh # WOODPECKER_TOKEN loaded from .env via env.sh
REPO="${CODEBERG_REPO}" REPO="${FORGE_REPO}"
API="${WOODPECKER_SERVER}/api/repos/${WOODPECKER_REPO_ID}" API="${WOODPECKER_SERVER}/api/repos/${WOODPECKER_REPO_ID}"
api() { api() {

View file

@ -8,19 +8,19 @@ set -euo pipefail
# ensure_blocked_label_id — look up (or create) the "blocked" label, print its ID. # 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. # Caches the result in _BLOCKED_LABEL_ID to avoid repeated API calls.
# Requires: CODEBERG_TOKEN, CODEBERG_API (from env.sh), codeberg_api() # Requires: FORGE_TOKEN, FORGE_API (from env.sh), forge_api()
ensure_blocked_label_id() { ensure_blocked_label_id() {
if [ -n "${_BLOCKED_LABEL_ID:-}" ]; then if [ -n "${_BLOCKED_LABEL_ID:-}" ]; then
printf '%s' "$_BLOCKED_LABEL_ID" printf '%s' "$_BLOCKED_LABEL_ID"
return 0 return 0
fi fi
_BLOCKED_LABEL_ID=$(codeberg_api GET "/labels" 2>/dev/null \ _BLOCKED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
| jq -r '.[] | select(.name == "blocked") | .id' 2>/dev/null || true) | jq -r '.[] | select(.name == "blocked") | .id' 2>/dev/null || true)
if [ -z "$_BLOCKED_LABEL_ID" ]; then if [ -z "$_BLOCKED_LABEL_ID" ]; then
_BLOCKED_LABEL_ID=$(curl -sf -X POST \ _BLOCKED_LABEL_ID=$(curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${CODEBERG_API}/labels" \ "${FORGE_API}/labels" \
-d '{"name":"blocked","color":"#e11d48"}' 2>/dev/null \ -d '{"name":"blocked","color":"#e11d48"}' 2>/dev/null \
| jq -r '.id // empty' 2>/dev/null || true) | jq -r '.id // empty' 2>/dev/null || true)
fi fi
@ -29,19 +29,19 @@ ensure_blocked_label_id() {
# ensure_priority_label — look up (or create) the "priority" label, print its 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. # Caches the result in _PRIORITY_LABEL_ID to avoid repeated API calls.
# Requires: CODEBERG_TOKEN, CODEBERG_API (from env.sh), codeberg_api() # Requires: FORGE_TOKEN, FORGE_API (from env.sh), forge_api()
ensure_priority_label() { ensure_priority_label() {
if [ -n "${_PRIORITY_LABEL_ID:-}" ]; then if [ -n "${_PRIORITY_LABEL_ID:-}" ]; then
printf '%s' "$_PRIORITY_LABEL_ID" printf '%s' "$_PRIORITY_LABEL_ID"
return 0 return 0
fi fi
_PRIORITY_LABEL_ID=$(codeberg_api GET "/labels" 2>/dev/null \ _PRIORITY_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
| jq -r '.[] | select(.name == "priority") | .id' 2>/dev/null || true) | jq -r '.[] | select(.name == "priority") | .id' 2>/dev/null || true)
if [ -z "$_PRIORITY_LABEL_ID" ]; then if [ -z "$_PRIORITY_LABEL_ID" ]; then
_PRIORITY_LABEL_ID=$(curl -sf -X POST \ _PRIORITY_LABEL_ID=$(curl -sf -X POST \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${CODEBERG_API}/labels" \ "${FORGE_API}/labels" \
-d '{"name":"priority","color":"#f59e0b"}' 2>/dev/null \ -d '{"name":"priority","color":"#f59e0b"}' 2>/dev/null \
| jq -r '.id // empty' 2>/dev/null || true) | jq -r '.id // empty' 2>/dev/null || true)
fi fi
@ -68,7 +68,7 @@ diff_has_code_files() {
ci_required_for_pr() { ci_required_for_pr() {
local pr_num="$1" local pr_num="$1"
local files all_json local files all_json
all_json=$(codeberg_api_all "/pulls/${pr_num}/files") || return 0 all_json=$(forge_api_all "/pulls/${pr_num}/files") || return 0
files=$(printf '%s' "$all_json" | jq -r '.[].filename' 2>/dev/null) || return 0 files=$(printf '%s' "$all_json" | jq -r '.[].filename' 2>/dev/null) || return 0
if [ -z "$files" ]; then if [ -z "$files" ]; then
return 0 # empty file list — require CI as safety default return 0 # empty file list — require CI as safety default
@ -113,7 +113,7 @@ ci_failed() {
is_infra_step() { is_infra_step() {
local sname="$1" ecode="$2" log_data="${3:-}" local sname="$1" ecode="$2" log_data="${3:-}"
# Clone/git step exit 128 → Codeberg connection failure / rate limit # Clone/git step exit 128 → forge connection failure / rate limit
if { [[ "$sname" == *clone* ]] || [[ "$sname" == git* ]]; } && [ "$ecode" = "128" ]; then if { [[ "$sname" == *clone* ]] || [[ "$sname" == git* ]]; } && [ "$ecode" = "128" ]; then
echo "${sname} exit 128 (connection failure)" echo "${sname} exit 128 (connection failure)"
return 0 return 0

View file

@ -24,17 +24,33 @@ if [ -n "${PROJECT_TOML:-}" ] && [ -f "$PROJECT_TOML" ]; then
source "${FACTORY_ROOT}/lib/load-project.sh" "$PROJECT_TOML" source "${FACTORY_ROOT}/lib/load-project.sh" "$PROJECT_TOML"
fi fi
# Codeberg token: env var > ~/.netrc # Forge token: new FORGE_TOKEN > legacy CODEBERG_TOKEN > ~/.netrc
if [ -z "${CODEBERG_TOKEN:-}" ]; then if [ -z "${FORGE_TOKEN:-}" ]; then
CODEBERG_TOKEN="$(awk '/codeberg.org/{getline;getline;print $2}' ~/.netrc 2>/dev/null || true)" FORGE_TOKEN="${CODEBERG_TOKEN:-}"
fi fi
export CODEBERG_TOKEN if [ -z "${FORGE_TOKEN:-}" ]; then
FORGE_TOKEN="$(awk '/codeberg.org/{getline;getline;print $2}' ~/.netrc 2>/dev/null || true)"
fi
export FORGE_TOKEN
export CODEBERG_TOKEN="${FORGE_TOKEN}" # backwards compat
# Project config # Review bot token: FORGE_REVIEW_TOKEN > legacy REVIEW_BOT_TOKEN
export CODEBERG_REPO="${CODEBERG_REPO:-}" export FORGE_REVIEW_TOKEN="${FORGE_REVIEW_TOKEN:-${REVIEW_BOT_TOKEN:-}}"
export CODEBERG_API="${CODEBERG_API:-https://codeberg.org/api/v1/repos/${CODEBERG_REPO}}" export REVIEW_BOT_TOKEN="${FORGE_REVIEW_TOKEN}" # backwards compat
export CODEBERG_WEB="https://codeberg.org/${CODEBERG_REPO}"
export PROJECT_NAME="${PROJECT_NAME:-${CODEBERG_REPO##*/}}" # Bot usernames filter: FORGE_BOT_USERNAMES > legacy CODEBERG_BOT_USERNAMES
export FORGE_BOT_USERNAMES="${FORGE_BOT_USERNAMES:-${CODEBERG_BOT_USERNAMES:-}}"
export CODEBERG_BOT_USERNAMES="${FORGE_BOT_USERNAMES}" # backwards compat
# Project config (FORGE_* preferred, CODEBERG_* fallback)
export FORGE_REPO="${FORGE_REPO:-${CODEBERG_REPO:-}}"
export CODEBERG_REPO="${FORGE_REPO}" # backwards compat
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
export PROJECT_NAME="${PROJECT_NAME:-${FORGE_REPO##*/}}"
export PROJECT_REPO_ROOT="${PROJECT_REPO_ROOT:-/home/${USER}/${PROJECT_NAME}}" export PROJECT_REPO_ROOT="${PROJECT_REPO_ROOT:-/home/${USER}/${PROJECT_NAME}}"
export PRIMARY_BRANCH="${PRIMARY_BRANCH:-master}" export PRIMARY_BRANCH="${PRIMARY_BRANCH:-master}"
export WOODPECKER_REPO_ID="${WOODPECKER_REPO_ID:-}" export WOODPECKER_REPO_ID="${WOODPECKER_REPO_ID:-}"
@ -46,23 +62,25 @@ log() {
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*"
} }
# Codeberg API helper — usage: codeberg_api GET /issues?state=open # Forge API helper — usage: forge_api GET /issues?state=open
codeberg_api() { forge_api() {
local method="$1" path="$2" local method="$1" path="$2"
shift 2 shift 2
curl -sf -X "$method" \ curl -sf -X "$method" \
-H "Authorization: token ${CODEBERG_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${CODEBERG_API}${path}" "$@" "${FORGE_API}${path}" "$@"
} }
# Backwards-compat alias
codeberg_api() { forge_api "$@"; }
# Paginate a Codeberg API GET endpoint and return all items as a merged JSON array. # Paginate a Forge API GET endpoint and return all items as a merged JSON array.
# Usage: codeberg_api_all /path (no existing query params) # Usage: forge_api_all /path (no existing query params)
# codeberg_api_all /path?a=b (with existing params — appends &limit=50&page=N) # forge_api_all /path?a=b (with existing params — appends &limit=50&page=N)
# codeberg_api_all /path TOKEN (optional second arg: token; defaults to $CODEBERG_TOKEN) # forge_api_all /path TOKEN (optional second arg: token; defaults to $FORGE_TOKEN)
codeberg_api_all() { forge_api_all() {
local path_prefix="$1" local path_prefix="$1"
local CODEBERG_TOKEN="${2:-${CODEBERG_TOKEN}}" local FORGE_TOKEN="${2:-${FORGE_TOKEN}}"
local sep page page_items count all_items="[]" local sep page page_items count all_items="[]"
case "$path_prefix" in case "$path_prefix" in
*"?"*) sep="&" ;; *"?"*) sep="&" ;;
@ -70,7 +88,7 @@ codeberg_api_all() {
esac esac
page=1 page=1
while true; do while true; do
page_items=$(codeberg_api GET "${path_prefix}${sep}limit=50&page=${page}") page_items=$(forge_api GET "${path_prefix}${sep}limit=50&page=${page}")
count=$(printf '%s' "$page_items" | jq 'length') count=$(printf '%s' "$page_items" | jq 'length')
[ "$count" -eq 0 ] && break [ "$count" -eq 0 ] && break
all_items=$(printf '%s\n%s' "$all_items" "$page_items" | jq -s 'add') all_items=$(printf '%s\n%s' "$all_items" "$page_items" | jq -s 'add')
@ -79,6 +97,8 @@ codeberg_api_all() {
done done
printf '%s' "$all_items" printf '%s' "$all_items"
} }
# Backwards-compat alias
codeberg_api_all() { forge_api_all "$@"; }
# Woodpecker API helper # Woodpecker API helper
woodpecker_api() { woodpecker_api() {

View file

@ -2,7 +2,7 @@
# file-action-issue.sh — File an action issue for a formula run # file-action-issue.sh — File an action issue for a formula run
# #
# Usage: source this file, then call file_action_issue. # Usage: source this file, then call file_action_issue.
# Requires: codeberg_api() from lib/env.sh, jq, lib/secret-scan.sh # Requires: forge_api() from lib/env.sh, jq, lib/secret-scan.sh
# #
# file_action_issue <formula_name> <title> <body> # file_action_issue <formula_name> <title> <body>
# Sets FILED_ISSUE_NUM on success. # Sets FILED_ISSUE_NUM on success.
@ -24,7 +24,7 @@ file_action_issue() {
# Dedup: skip if an open action issue for this formula already exists # Dedup: skip if an open action issue for this formula already exists
local open_actions local open_actions
open_actions=$(codeberg_api_all "/issues?state=open&type=issues&labels=action" 2>/dev/null || true) open_actions=$(forge_api_all "/issues?state=open&type=issues&labels=action" 2>/dev/null || true)
if [ -n "$open_actions" ] && [ "$open_actions" != "null" ]; then if [ -n "$open_actions" ] && [ "$open_actions" != "null" ]; then
local existing local existing
existing=$(printf '%s' "$open_actions" | \ existing=$(printf '%s' "$open_actions" | \
@ -36,7 +36,7 @@ file_action_issue() {
# Fetch 'action' label ID # Fetch 'action' label ID
local action_label_id local action_label_id
action_label_id=$(codeberg_api GET "/labels" 2>/dev/null | \ action_label_id=$(forge_api GET "/labels" 2>/dev/null | \
jq -r '.[] | select(.name == "action") | .id' 2>/dev/null || true) jq -r '.[] | select(.name == "action") | .id' 2>/dev/null || true)
if [ -z "$action_label_id" ]; then if [ -z "$action_label_id" ]; then
return 2 return 2
@ -50,7 +50,7 @@ file_action_issue() {
--argjson labels "[$action_label_id]" \ --argjson labels "[$action_label_id]" \
'{title: $title, body: $body, labels: $labels}') '{title: $title, body: $body, labels: $labels}')
result=$(codeberg_api POST "/issues" -d "$payload" 2>/dev/null || true) 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) FILED_ISSUE_NUM=$(printf '%s' "$result" | jq -r '.number // empty' 2>/dev/null || true)
if [ -z "$FILED_ISSUE_NUM" ]; then if [ -z "$FILED_ISSUE_NUM" ]; then

View file

@ -208,21 +208,21 @@ read_scratch_context() {
# ── Prompt + monitor helpers ────────────────────────────────────────────── # ── Prompt + monitor helpers ──────────────────────────────────────────────
# build_prompt_footer [EXTRA_API_LINES] # build_prompt_footer [EXTRA_API_LINES]
# Assembles the common Codeberg API reference + environment + phase protocol # Assembles the common forge API reference + environment + phase protocol
# block for formula prompts. Sets PROMPT_FOOTER. # block for formula prompts. Sets PROMPT_FOOTER.
# Pass additional API endpoint lines (pre-formatted, newline-prefixed) via $1. # Pass additional API endpoint lines (pre-formatted, newline-prefixed) via $1.
# Requires globals: CODEBERG_API, FACTORY_ROOT, PROJECT_REPO_ROOT, # Requires globals: FORGE_API, FACTORY_ROOT, PROJECT_REPO_ROOT,
# PRIMARY_BRANCH, PHASE_FILE. # PRIMARY_BRANCH, PHASE_FILE.
build_prompt_footer() { build_prompt_footer() {
local extra_api="${1:-}" local extra_api="${1:-}"
# shellcheck disable=SC2034 # consumed by the calling script's PROMPT # shellcheck disable=SC2034 # consumed by the calling script's PROMPT
PROMPT_FOOTER="## Codeberg API reference PROMPT_FOOTER="## Forge API reference
Base URL: ${CODEBERG_API} Base URL: ${FORGE_API}
Auth header: -H \"Authorization: token \$CODEBERG_TOKEN\" Auth header: -H \"Authorization: token \${FORGE_TOKEN}\"
Read issue: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" '${CODEBERG_API}/issues/{number}' | jq '.body' Read issue: curl -sf -H \"Authorization: token \${FORGE_TOKEN}\" '${FORGE_API}/issues/{number}' | jq '.body'
Create issue: curl -sf -X POST -H \"Authorization: token \$CODEBERG_TOKEN\" -H 'Content-Type: application/json' '${CODEBERG_API}/issues' -d '{\"title\":\"...\",\"body\":\"...\",\"labels\":[LABEL_ID]}'${extra_api} Create issue: curl -sf -X POST -H \"Authorization: token \${FORGE_TOKEN}\" -H 'Content-Type: application/json' '${FORGE_API}/issues' -d '{\"title\":\"...\",\"body\":\"...\",\"labels\":[LABEL_ID]}'${extra_api}
List labels: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" '${CODEBERG_API}/labels' List labels: curl -sf -H \"Authorization: token \${FORGE_TOKEN}\" '${FORGE_API}/labels'
NEVER echo or include the actual token value in output — always reference \$CODEBERG_TOKEN. NEVER echo or include the actual token value in output — always reference \${FORGE_TOKEN}.
## Environment ## Environment
FACTORY_ROOT=${FACTORY_ROOT} FACTORY_ROOT=${FACTORY_ROOT}
@ -240,7 +240,7 @@ On unrecoverable error:
# run_formula_and_monitor AGENT_NAME [TIMEOUT] # run_formula_and_monitor AGENT_NAME [TIMEOUT]
# Starts the formula session, injects PROMPT, monitors phase, and logs result. # Starts the formula session, injects PROMPT, monitors phase, and logs result.
# Requires globals: SESSION_NAME, PHASE_FILE, PROJECT_REPO_ROOT, PROMPT, # Requires globals: SESSION_NAME, PHASE_FILE, PROJECT_REPO_ROOT, PROMPT,
# CODEBERG_REPO, CLAUDE_MODEL (exported). # FORGE_REPO, CLAUDE_MODEL (exported).
# shellcheck disable=SC2154 # SESSION_NAME, PHASE_FILE, PROJECT_REPO_ROOT, PROMPT set by caller # shellcheck disable=SC2154 # SESSION_NAME, PHASE_FILE, PROJECT_REPO_ROOT, PROMPT set by caller
run_formula_and_monitor() { run_formula_and_monitor() {
local agent_name="$1" local agent_name="$1"
@ -258,7 +258,7 @@ run_formula_and_monitor() {
agent_inject_into_session "$SESSION_NAME" "$PROMPT" agent_inject_into_session "$SESSION_NAME" "$PROMPT"
log "Prompt sent to tmux session" log "Prompt sent to tmux session"
matrix_send "$agent_name" "${agent_name^} session started for ${CODEBERG_REPO}" 2>/dev/null || true matrix_send "$agent_name" "${agent_name^} session started for ${FORGE_REPO}" 2>/dev/null || true
log "Monitoring phase file: ${PHASE_FILE}" log "Monitoring phase file: ${PHASE_FILE}"
_FORMULA_CRASH_COUNT=0 _FORMULA_CRASH_COUNT=0

View file

@ -5,7 +5,7 @@
# Blocks: # Blocks:
# - git push --force / -f to primary branch # - git push --force / -f to primary branch
# - rm -rf targeting paths outside the worktree # - rm -rf targeting paths outside the worktree
# - Direct Codeberg API merge calls (should go through phase protocol) # - Direct forge API merge calls (should go through phase protocol)
# - Direct issue close calls (should go through phase protocol) # - Direct issue close calls (should go through phase protocol)
# - git checkout / git switch to primary branch (stay on feature branch) # - git checkout / git switch to primary branch (stay on feature branch)
# - FACTORY_ROOT access from worktrees (formula agents exempted) # - FACTORY_ROOT access from worktrees (formula agents exempted)
@ -88,7 +88,7 @@ if [ -n "$worktree_path" ] \
fi fi
fi fi
# --- Guard 3: Direct Codeberg API merge calls --- # --- Guard 3: Direct forge API merge calls ---
if printf '%s' "$command_str" | grep -qE '/pulls/[0-9]+/merge'; then if printf '%s' "$command_str" | grep -qE '/pulls/[0-9]+/merge'; then
printf 'BLOCKED: Direct API merge calls must go through the phase protocol. Push your changes and write PHASE:awaiting_ci — the orchestrator handles merges.\n' printf 'BLOCKED: Direct API merge calls must go through the phase protocol. Push your changes and write PHASE:awaiting_ci — the orchestrator handles merges.\n'
exit 2 exit 2

View file

@ -5,9 +5,11 @@
# source lib/load-project.sh projects/harb.toml # source lib/load-project.sh projects/harb.toml
# #
# Exports: # Exports:
# PROJECT_NAME, CODEBERG_REPO, CODEBERG_API, PROJECT_REPO_ROOT, # PROJECT_NAME, FORGE_REPO, FORGE_API, FORGE_WEB, FORGE_URL,
# PRIMARY_BRANCH, WOODPECKER_REPO_ID, PROJECT_CONTAINERS, # PROJECT_REPO_ROOT, PRIMARY_BRANCH, WOODPECKER_REPO_ID,
# CHECK_PRS, CHECK_DEV_AGENT, CHECK_PIPELINE_STALL, CI_STALE_MINUTES # PROJECT_CONTAINERS, CHECK_PRS, CHECK_DEV_AGENT,
# CHECK_PIPELINE_STALL, CI_STALE_MINUTES
# (plus backwards-compat aliases: CODEBERG_REPO, CODEBERG_API, CODEBERG_WEB)
# #
# If no argument given, does nothing (allows poll scripts to work with # If no argument given, does nothing (allows poll scripts to work with
# plain .env fallback for backwards compatibility). # plain .env fallback for backwards compatibility).
@ -35,7 +37,8 @@ def emit(key, val):
# Top-level # Top-level
emit('PROJECT_NAME', cfg.get('name', '')) emit('PROJECT_NAME', cfg.get('name', ''))
emit('CODEBERG_REPO', cfg.get('repo', '')) emit('FORGE_REPO', cfg.get('repo', ''))
emit('FORGE_URL', cfg.get('forge_url', ''))
if 'repo_root' in cfg: if 'repo_root' in cfg:
emit('PROJECT_REPO_ROOT', cfg['repo_root']) emit('PROJECT_REPO_ROOT', cfg['repo_root'])
@ -79,11 +82,17 @@ while IFS='=' read -r _key _val; do
export "$_key=$_val" export "$_key=$_val"
done <<< "$_PROJECT_VARS" done <<< "$_PROJECT_VARS"
# Derive CODEBERG_API and CODEBERG_WEB if repo changed # Derive FORGE_API and FORGE_WEB from forge_url + repo
if [ -n "$CODEBERG_REPO" ]; then # FORGE_URL: TOML forge_url > existing FORGE_URL > default
export CODEBERG_API="https://codeberg.org/api/v1/repos/${CODEBERG_REPO}" export FORGE_URL="${FORGE_URL:-http://localhost:3000}"
export CODEBERG_WEB="https://codeberg.org/${CODEBERG_REPO}" if [ -n "$FORGE_REPO" ]; then
export FORGE_API="${FORGE_URL}/api/v1/repos/${FORGE_REPO}"
export FORGE_WEB="${FORGE_URL}/${FORGE_REPO}"
fi 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 # Derive PROJECT_REPO_ROOT if not explicitly set
if [ -z "${PROJECT_REPO_ROOT:-}" ] && [ -n "${PROJECT_NAME:-}" ]; then if [ -z "${PROJECT_REPO_ROOT:-}" ] && [ -n "${PROJECT_NAME:-}" ]; then

View file

@ -33,8 +33,9 @@ _SAFE_PATTERNS=(
# Git SHAs in typical git contexts (commit refs, not standalone secrets) # Git SHAs in typical git contexts (commit refs, not standalone secrets)
'commit [0-9a-f]{40}' 'commit [0-9a-f]{40}'
'Merge [0-9a-f]{40}' 'Merge [0-9a-f]{40}'
# Codeberg/GitHub URLs with short hex (PR refs, commit links) # Forge/GitHub URLs with short hex (PR refs, commit links)
'codeberg\.org/[^[:space:]]+' 'codeberg\.org/[^[:space:]]+'
'localhost:3000/[^[:space:]]+'
# ShellCheck directive codes # ShellCheck directive codes
'SC[0-9]{4}' 'SC[0-9]{4}'
) )

View file

@ -61,6 +61,6 @@ prerequisite tree but NOT as issues. This prevents the "spray issues across
all milestones" pattern that produced premature work in planner v1/v2. all milestones" pattern that produced premature work in planner v1/v2.
**Environment variables consumed**: **Environment variables consumed**:
- `CODEBERG_TOKEN`, `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT` - `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to opus by planner-run.sh) - `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to opus by planner-run.sh)
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` - `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER`

View file

@ -80,13 +80,13 @@ SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
# ── Build prompt ───────────────────────────────────────────────────────── # ── Build prompt ─────────────────────────────────────────────────────────
build_prompt_footer " build_prompt_footer "
Relabel: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X PUT -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}/labels' -d '{\"labels\":[LABEL_ID]}' Relabel: curl -sf -H \"Authorization: token \${FORGE_TOKEN}\" -X PUT -H 'Content-Type: application/json' '${FORGE_API}/issues/{number}/labels' -d '{\"labels\":[LABEL_ID]}'
Comment: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X POST -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}/comments' -d '{\"body\":\"...\"}' Comment: curl -sf -H \"Authorization: token \${FORGE_TOKEN}\" -X POST -H 'Content-Type: application/json' '${FORGE_API}/issues/{number}/comments' -d '{\"body\":\"...\"}'
Close: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X PATCH -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}' -d '{\"state\":\"closed\"}' Close: curl -sf -H \"Authorization: token \${FORGE_TOKEN}\" -X PATCH -H 'Content-Type: application/json' '${FORGE_API}/issues/{number}' -d '{\"state\":\"closed\"}'
" "
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor # shellcheck disable=SC2034 # consumed by run_formula_and_monitor
PROMPT="You are the strategic planner for ${CODEBERG_REPO}. Work through the formula below. You MUST write PHASE:done to '${PHASE_FILE}' when finished — the orchestrator will time you out if you return to the prompt without signalling. PROMPT="You are the strategic planner for ${FORGE_REPO}. Work through the formula below. You MUST write PHASE:done to '${PHASE_FILE}' when finished — the orchestrator will time you out if you return to the prompt without signalling.
## Project context ## Project context
${CONTEXT_BLOCK}${MEMORY_BLOCK}${JOURNAL_BLOCK} ${CONTEXT_BLOCK}${MEMORY_BLOCK}${JOURNAL_BLOCK}

View file

@ -28,7 +28,7 @@ memory check (skips if available RAM < 2000 MB).
**Key files**: **Key files**:
- `predictor/predictor-run.sh` — Cron wrapper + orchestrator: lock, memory guard, - `predictor/predictor-run.sh` — Cron wrapper + orchestrator: lock, memory guard,
sources disinto project config, builds prompt with formula + Codeberg API sources disinto project config, builds prompt with formula + forge API
reference, creates tmux session (sonnet), monitors phase file, handles crash reference, creates tmux session (sonnet), monitors phase file, handles crash
recovery via `run_formula_and_monitor` recovery via `run_formula_and_monitor`
- `formulas/run-predictor.toml` — Execution spec: two steps (preflight, - `formulas/run-predictor.toml` — Execution spec: two steps (preflight,
@ -37,7 +37,7 @@ memory check (skips if available RAM < 2000 MB).
interactive session interactive session
**Environment variables consumed**: **Environment variables consumed**:
- `CODEBERG_TOKEN`, `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT` - `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by predictor-run.sh) - `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by predictor-run.sh)
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` — Notifications (optional) - `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` — Notifications (optional)

View file

@ -55,7 +55,7 @@ SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
build_prompt_footer build_prompt_footer
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor # shellcheck disable=SC2034 # consumed by run_formula_and_monitor
PROMPT="You are the prediction agent (goblin) for ${CODEBERG_REPO}. Work through the formula below. You MUST write PHASE:done to '${PHASE_FILE}' when finished — the orchestrator will time you out if you return to the prompt without signalling. PROMPT="You are the prediction agent (goblin) for ${FORGE_REPO}. Work through the formula below. You MUST write PHASE:done to '${PHASE_FILE}' when finished — the orchestrator will time you out if you return to the prompt without signalling.
Your role: abstract adversary. Find the project's biggest weakness, challenge Your role: abstract adversary. Find the project's biggest weakness, challenge
planner claims, and generate evidence. Explore when uncertain (file a prediction), planner claims, and generate evidence. Explore when uncertain (file a prediction),

View file

@ -1,10 +1,11 @@
# projects/disinto.toml.example — Template for disinto self-management # projects/disinto.toml.example — Template for disinto self-management
# #
# Copy to projects/disinto.toml and fill in box-specific values, # Copy to projects/disinto.toml and fill in box-specific values,
# or run: disinto init https://codeberg.org/johba/disinto # or run: disinto init johba/disinto
name = "disinto" name = "disinto"
repo = "johba/disinto" repo = "johba/disinto"
forge_url = "http://localhost:3000"
repo_root = "/home/YOU/dark-factory" repo_root = "/home/YOU/dark-factory"
primary_branch = "main" primary_branch = "main"

View file

@ -1,10 +1,11 @@
# projects/harb.toml.example — Template for johba/harb # projects/harb.toml.example — Template for johba/harb
# #
# Copy to projects/harb.toml and fill in box-specific values, # Copy to projects/harb.toml and fill in box-specific values,
# or run: disinto init https://codeberg.org/johba/harb # or run: disinto init johba/harb
name = "harb" name = "harb"
repo = "johba/harb" repo = "johba/harb"
forge_url = "http://localhost:3000"
repo_root = "/home/YOU/harb" repo_root = "/home/YOU/harb"
primary_branch = "master" primary_branch = "master"

View file

@ -1,10 +1,11 @@
# projects/versi.toml.example — Template for johba/versi # projects/versi.toml.example — Template for johba/versi
# #
# Copy to projects/versi.toml and fill in box-specific values, # Copy to projects/versi.toml and fill in box-specific values,
# or run: disinto init https://codeberg.org/johba/versi # or run: disinto init johba/versi
name = "versi" name = "versi"
repo = "johba/versi" repo = "johba/versi"
forge_url = "http://localhost:3000"
repo_root = "/home/YOU/versi" repo_root = "/home/YOU/versi"
primary_branch = "main" primary_branch = "main"

View file

@ -2,7 +2,7 @@
# Review Agent # Review Agent
**Role**: AI-powered PR review — post structured findings and formal **Role**: AI-powered PR review — post structured findings and formal
approve/request-changes verdicts to Codeberg. approve/request-changes verdicts to forge.
**Trigger**: `review-poll.sh` runs every 10 min via cron. It scans open PRs **Trigger**: `review-poll.sh` runs every 10 min via cron. It scans open PRs
whose CI has passed and that lack a review for the current HEAD SHA, then whose CI has passed and that lack a review for the current HEAD SHA, then
@ -10,11 +10,11 @@ spawns `review-pr.sh <pr-number>`.
**Key files**: **Key files**:
- `review/review-poll.sh` — Cron scheduler: finds unreviewed PRs with passing CI - `review/review-poll.sh` — Cron scheduler: finds unreviewed PRs with passing CI
- `review/review-pr.sh` — Creates/reuses a tmux session (`review-{project}-{pr}`), injects PR diff, waits for Claude to write structured JSON output, posts markdown review + formal Codeberg review, auto-creates follow-up issues for pre-existing tech debt - `review/review-pr.sh` — Creates/reuses a tmux session (`review-{project}-{pr}`), injects PR diff, waits for Claude to write structured JSON output, posts markdown review + formal forge review, auto-creates follow-up issues for pre-existing tech debt
**Environment variables consumed**: **Environment variables consumed**:
- `CODEBERG_TOKEN` — Dev-agent token (must not be the same account as REVIEW_BOT_TOKEN) - `FORGE_TOKEN` — Dev-agent token (must not be the same account as FORGE_REVIEW_TOKEN)
- `REVIEW_BOT_TOKEN` — Review-agent token for approvals (use human/admin account; branch protection: in approvals whitelist) - `FORGE_REVIEW_TOKEN` — Review-agent token for approvals (use human/admin account; branch protection: in approvals whitelist)
- `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT` - `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
- `PRIMARY_BRANCH`, `WOODPECKER_REPO_ID` - `PRIMARY_BRANCH`, `WOODPECKER_REPO_ID`
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` - `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER`

View file

@ -15,7 +15,7 @@ source "$(dirname "$0")/../lib/ci-helpers.sh"
REPO_ROOT="${PROJECT_REPO_ROOT}" REPO_ROOT="${PROJECT_REPO_ROOT}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
API_BASE="${CODEBERG_API}" API_BASE="${FORGE_API}"
LOGFILE="$SCRIPT_DIR/review.log" LOGFILE="$SCRIPT_DIR/review.log"
MAX_REVIEWS=3 MAX_REVIEWS=3
REVIEW_IDLE_TIMEOUT=14400 # 4h: kill review session if idle REVIEW_IDLE_TIMEOUT=14400 # 4h: kill review session if idle
@ -44,7 +44,7 @@ if [ -n "$REVIEW_SESSIONS" ]; then
phase_file="/tmp/review-session-${PROJECT_NAME}-${pr_num}.phase" phase_file="/tmp/review-session-${PROJECT_NAME}-${pr_num}.phase"
# Check if PR is still open # Check if PR is still open
pr_state=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ pr_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API_BASE}/pulls/${pr_num}" | jq -r '.state // "unknown"' 2>/dev/null) || true "${API_BASE}/pulls/${pr_num}" | jq -r '.state // "unknown"' 2>/dev/null) || true
if [ "$pr_state" != "open" ]; then if [ "$pr_state" != "open" ]; then
@ -92,7 +92,7 @@ if [ -n "$REVIEW_SESSIONS" ]; then
done <<< "$REVIEW_SESSIONS" done <<< "$REVIEW_SESSIONS"
fi fi
PRS=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ PRS=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API_BASE}/pulls?state=open&limit=20" | \ "${API_BASE}/pulls?state=open&limit=20" | \
jq -r --arg branch "${PRIMARY_BRANCH}" '.[] | select(.base.ref == $branch) | select(.draft != true) | select(.title | test("^\\[?WIP[\\]:]"; "i") | not) | "\(.number) \(.head.sha) \(.head.ref)"') jq -r --arg branch "${PRIMARY_BRANCH}" '.[] | select(.base.ref == $branch) | select(.draft != true) | select(.title | test("^\\[?WIP[\\]:]"; "i") | not) | "\(.number) \(.head.sha) \(.head.ref)"')
@ -124,7 +124,7 @@ inject_review_into_dev_session() {
[ "${current_phase}" = "PHASE:awaiting_review" ] || return 0 [ "${current_phase}" = "PHASE:awaiting_review" ] || return 0
local review_comment local review_comment
review_comment=$(codeberg_api_all "/issues/${pr_num}/comments" | \ review_comment=$(forge_api_all "/issues/${pr_num}/comments" | \
jq -r --arg sha "${pr_sha}" \ jq -r --arg sha "${pr_sha}" \
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true '[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true
if [ -z "${review_comment}" ] || [ "${review_comment}" = "null" ]; then if [ -z "${review_comment}" ] || [ "${review_comment}" = "null" ]; then
@ -185,7 +185,7 @@ if [ -n "${REVIEW_SESSIONS:-}" ]; then
reviewed_sha=$(sed -n 's/^SHA://p' "$phase_file" 2>/dev/null | tr -d '[:space:]' || true) reviewed_sha=$(sed -n 's/^SHA://p' "$phase_file" 2>/dev/null | tr -d '[:space:]' || true)
[ -n "$reviewed_sha" ] || continue [ -n "$reviewed_sha" ] || continue
pr_json=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ pr_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API_BASE}/pulls/${pr_num}" 2>/dev/null || true) "${API_BASE}/pulls/${pr_num}" 2>/dev/null || true)
[ -n "$pr_json" ] || continue [ -n "$pr_json" ] || continue
@ -196,7 +196,7 @@ if [ -n "${REVIEW_SESSIONS:-}" ]; then
pr_branch=$(printf '%s' "$pr_json" | jq -r '.head.ref // ""') pr_branch=$(printf '%s' "$pr_json" | jq -r '.head.ref // ""')
if [ -z "$current_sha" ] || [ "$current_sha" = "$reviewed_sha" ]; then continue; fi if [ -z "$current_sha" ] || [ "$current_sha" = "$reviewed_sha" ]; then continue; fi
ci_state=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ ci_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API_BASE}/commits/${current_sha}/status" | jq -r '.state // "unknown"') "${API_BASE}/commits/${current_sha}/status" | jq -r '.state // "unknown"')
if ! ci_passed "$ci_state"; then if ! ci_passed "$ci_state"; then
@ -210,7 +210,7 @@ if [ -n "${REVIEW_SESSIONS:-}" ]; then
if "${SCRIPT_DIR}/review-pr.sh" "$pr_num" 2>&1; then if "${SCRIPT_DIR}/review-pr.sh" "$pr_num" 2>&1; then
REVIEWED=$((REVIEWED + 1)) REVIEWED=$((REVIEWED + 1))
FRESH_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ FRESH_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API_BASE}/pulls/${pr_num}" | jq -r '.head.sha // ""') || true "${API_BASE}/pulls/${pr_num}" | jq -r '.head.sha // ""') || true
inject_review_into_dev_session "$pr_num" "${FRESH_SHA:-$current_sha}" "$pr_branch" inject_review_into_dev_session "$pr_num" "${FRESH_SHA:-$current_sha}" "$pr_branch"
else else
@ -227,7 +227,7 @@ while IFS= read -r line; do
PR_SHA=$(echo "$line" | awk '{print $2}') PR_SHA=$(echo "$line" | awk '{print $2}')
PR_BRANCH=$(echo "$line" | awk '{print $3}') PR_BRANCH=$(echo "$line" | awk '{print $3}')
CI_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API_BASE}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') "${API_BASE}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"')
# Skip if CI is running/failed. Allow "success", no CI configured, or non-code PRs # Skip if CI is running/failed. Allow "success", no CI configured, or non-code PRs
@ -240,8 +240,8 @@ while IFS= read -r line; do
log " #${PR_NUM} CI=${CI_STATE} but no code files — proceeding" log " #${PR_NUM} CI=${CI_STATE} but no code files — proceeding"
fi fi
# Check formal Codeberg reviews (not comment markers) # Check formal forge reviews (not comment markers)
HAS_REVIEW=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ HAS_REVIEW=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API_BASE}/pulls/${PR_NUM}/reviews" | \ "${API_BASE}/pulls/${PR_NUM}/reviews" | \
jq -r --arg sha "$PR_SHA" \ jq -r --arg sha "$PR_SHA" \
'[.[] | select(.commit_id == $sha) | select(.state != "COMMENT")] | length') '[.[] | select(.commit_id == $sha) | select(.state != "COMMENT")] | length')
@ -259,7 +259,7 @@ while IFS= read -r line; do
# Re-fetch current SHA: review-pr.sh fetches the PR independently and tags its # Re-fetch current SHA: review-pr.sh fetches the PR independently and tags its
# comment with whatever SHA it saw. If a commit arrived while review-pr.sh was # comment with whatever SHA it saw. If a commit arrived while review-pr.sh was
# running those two SHA captures diverge and we would miss the comment. # running those two SHA captures diverge and we would miss the comment.
FRESH_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ FRESH_SHA=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API_BASE}/pulls/${PR_NUM}" | jq -r '.head.sha // ""') || true "${API_BASE}/pulls/${PR_NUM}" | jq -r '.head.sha // ""') || true
inject_review_into_dev_session "$PR_NUM" "${FRESH_SHA:-$PR_SHA}" "$PR_BRANCH" inject_review_into_dev_session "$PR_NUM" "${FRESH_SHA:-$PR_SHA}" "$PR_BRANCH"
else else

View file

@ -10,7 +10,7 @@ git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true
PR_NUMBER="${1:?Usage: review-pr.sh <pr-number> [--force]}" PR_NUMBER="${1:?Usage: review-pr.sh <pr-number> [--force]}"
FORCE="${2:-}" FORCE="${2:-}"
API="${CODEBERG_API}" API="${FORGE_API}"
LOGFILE="${FACTORY_ROOT}/review/review.log" LOGFILE="${FACTORY_ROOT}/review/review.log"
SESSION="review-${PROJECT_NAME}-${PR_NUMBER}" SESSION="review-${PROJECT_NAME}-${PR_NUMBER}"
PHASE_FILE="/tmp/review-session-${PROJECT_NAME}-${PR_NUMBER}.phase" PHASE_FILE="/tmp/review-session-${PROJECT_NAME}-${PR_NUMBER}.phase"
@ -37,7 +37,7 @@ if [ -f "$LOCKFILE" ]; then
fi fi
echo $$ > "$LOCKFILE" echo $$ > "$LOCKFILE"
status "fetching metadata" status "fetching metadata"
PR_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" "${API}/pulls/${PR_NUMBER}") PR_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" "${API}/pulls/${PR_NUMBER}")
PR_TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title') PR_TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title')
PR_BODY=$(printf '%s' "$PR_JSON" | jq -r '.body // ""') PR_BODY=$(printf '%s' "$PR_JSON" | jq -r '.body // ""')
PR_HEAD=$(printf '%s' "$PR_JSON" | jq -r '.head.ref') PR_HEAD=$(printf '%s' "$PR_JSON" | jq -r '.head.ref')
@ -50,16 +50,16 @@ if [ "$PR_STATE" != "open" ]; then
cd "${PROJECT_REPO_ROOT}"; git worktree remove "$WORKTREE" --force 2>/dev/null || true cd "${PROJECT_REPO_ROOT}"; git worktree remove "$WORKTREE" --force 2>/dev/null || true
rm -rf "$WORKTREE" "$PHASE_FILE" "$OUTPUT_FILE" 2>/dev/null || true; exit 0 rm -rf "$WORKTREE" "$PHASE_FILE" "$OUTPUT_FILE" 2>/dev/null || true; exit 0
fi fi
CI_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ CI_STATE=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"') "${API}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"')
CI_NOTE=""; if ! ci_passed "$CI_STATE"; then CI_NOTE=""; if ! ci_passed "$CI_STATE"; then
ci_required_for_pr "$PR_NUMBER" && { log "SKIP: CI=${CI_STATE}"; exit 0; } ci_required_for_pr "$PR_NUMBER" && { log "SKIP: CI=${CI_STATE}"; exit 0; }
CI_NOTE=" (not required — non-code PR)"; fi CI_NOTE=" (not required — non-code PR)"; fi
ALL_COMMENTS=$(codeberg_api_all "/issues/${PR_NUMBER}/comments") ALL_COMMENTS=$(forge_api_all "/issues/${PR_NUMBER}/comments")
HAS_CMT=$(printf '%s' "$ALL_COMMENTS" | jq --arg s "$PR_SHA" \ HAS_CMT=$(printf '%s' "$ALL_COMMENTS" | jq --arg s "$PR_SHA" \
'[.[]|select(.body|contains("<!-- reviewed: "+$s+" -->"))]|length') '[.[]|select(.body|contains("<!-- reviewed: "+$s+" -->"))]|length')
[ "${HAS_CMT:-0}" -gt 0 ] && [ "$FORCE" != "--force" ] && { log "SKIP: reviewed ${PR_SHA:0:7}"; exit 0; } [ "${HAS_CMT:-0}" -gt 0 ] && [ "$FORCE" != "--force" ] && { log "SKIP: reviewed ${PR_SHA:0:7}"; exit 0; }
HAS_FML=$(codeberg_api_all "/pulls/${PR_NUMBER}/reviews" | jq --arg s "$PR_SHA" \ HAS_FML=$(forge_api_all "/pulls/${PR_NUMBER}/reviews" | jq --arg s "$PR_SHA" \
'[.[]|select(.commit_id==$s)|select(.state!="COMMENT")]|length') '[.[]|select(.commit_id==$s)|select(.state!="COMMENT")]|length')
[ "${HAS_FML:-0}" -gt 0 ] && [ "$FORCE" != "--force" ] && { log "SKIP: formal review"; exit 0; } [ "${HAS_FML:-0}" -gt 0 ] && [ "$FORCE" != "--force" ] && { log "SKIP: formal review"; exit 0; }
PREV_CONTEXT="" IS_RE_REVIEW=false PREV_SHA="" PREV_CONTEXT="" IS_RE_REVIEW=false PREV_SHA=""
@ -81,7 +81,7 @@ if [ -n "$PREV_REV" ] && [ "$PREV_REV" != "null" ]; then
fi fi
fi fi
status "fetching diff" status "fetching diff"
curl -s -H "Authorization: token ${CODEBERG_TOKEN}" \ curl -s -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${PR_NUMBER}.diff" > "${REVIEW_TMPDIR}/full.diff" "${API}/pulls/${PR_NUMBER}.diff" > "${REVIEW_TMPDIR}/full.diff"
FSIZE=$(stat -c%s "${REVIEW_TMPDIR}/full.diff" 2>/dev/null || echo 0) FSIZE=$(stat -c%s "${REVIEW_TMPDIR}/full.diff" 2>/dev/null || echo 0)
DIFF=$(head -c "$MAX_DIFF" "${REVIEW_TMPDIR}/full.diff") DIFF=$(head -c "$MAX_DIFF" "${REVIEW_TMPDIR}/full.diff")
@ -97,15 +97,15 @@ status "preparing review session"
FORMULA=$(cat "${FACTORY_ROOT}/formulas/review-pr.toml") FORMULA=$(cat "${FACTORY_ROOT}/formulas/review-pr.toml")
{ {
printf 'You are the review agent for %s. Follow the formula to review PR #%s.\nYou MUST write PHASE:done to '\''%s'\'' when finished.\n\n' \ printf 'You are the review agent for %s. Follow the formula to review PR #%s.\nYou MUST write PHASE:done to '\''%s'\'' when finished.\n\n' \
"${CODEBERG_REPO}" "${PR_NUMBER}" "${PHASE_FILE}" "${FORGE_REPO}" "${PR_NUMBER}" "${PHASE_FILE}"
printf '## PR Context\n**%s** (%s → %s) | SHA: %s | CI: %s%s\nRe-review: %s\n\n' \ printf '## PR Context\n**%s** (%s → %s) | SHA: %s | CI: %s%s\nRe-review: %s\n\n' \
"$PR_TITLE" "$PR_HEAD" "$PR_BASE" "$PR_SHA" "$CI_STATE" "$CI_NOTE" "$IS_RE_REVIEW" "$PR_TITLE" "$PR_HEAD" "$PR_BASE" "$PR_SHA" "$CI_STATE" "$CI_NOTE" "$IS_RE_REVIEW"
printf '### Description\n%s\n\n### Changed Files\n%s\n\n### Diff%s\n```diff\n%s\n```\n' \ printf '### Description\n%s\n\n### Changed Files\n%s\n\n### Diff%s\n```diff\n%s\n```\n' \
"$PR_BODY" "$FILES" "$DNOTE" "$DIFF" "$PR_BODY" "$FILES" "$DNOTE" "$DIFF"
[ -n "$PREV_CONTEXT" ] && printf '%s\n' "$PREV_CONTEXT" [ -n "$PREV_CONTEXT" ] && printf '%s\n' "$PREV_CONTEXT"
printf '\n## Formula\n%s\n\n## Environment\nREVIEW_OUTPUT_FILE=%s\nPHASE_FILE=%s\nCODEBERG_API=%s\nPR_NUMBER=%s\nFACTORY_ROOT=%s\n' \ printf '\n## Formula\n%s\n\n## Environment\nREVIEW_OUTPUT_FILE=%s\nPHASE_FILE=%s\nFORGE_API=%s\nPR_NUMBER=%s\nFACTORY_ROOT=%s\n' \
"$FORMULA" "$OUTPUT_FILE" "$PHASE_FILE" "$API" "$PR_NUMBER" "$FACTORY_ROOT" "$FORMULA" "$OUTPUT_FILE" "$PHASE_FILE" "$API" "$PR_NUMBER" "$FACTORY_ROOT"
printf 'NEVER echo the actual token — always reference $CODEBERG_TOKEN or $REVIEW_BOT_TOKEN.\n' printf 'NEVER echo the actual token — always reference ${FORGE_TOKEN} or ${FORGE_REVIEW_TOKEN}.\n'
} > "${REVIEW_TMPDIR}/prompt.md" } > "${REVIEW_TMPDIR}/prompt.md"
PROMPT=$(cat "${REVIEW_TMPDIR}/prompt.md") PROMPT=$(cat "${REVIEW_TMPDIR}/prompt.md")
@ -142,7 +142,7 @@ fi
if [ -z "$REVIEW_JSON" ]; then if [ -z "$REVIEW_JSON" ]; then
log "ERROR: no valid review output" log "ERROR: no valid review output"
jq -n --arg b "## AI Review — Error\n<!-- review-error: ${PR_SHA} -->\nReview failed.\n---\n*${PR_SHA:0:7}*" \ jq -n --arg b "## AI Review — Error\n<!-- review-error: ${PR_SHA} -->\nReview failed.\n---\n*${PR_SHA:0:7}*" \
'{body: $b}' | curl -sf -o /dev/null -X POST -H "Authorization: token ${CODEBERG_TOKEN}" \ '{body: $b}' | curl -sf -o /dev/null -X POST -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" "${API}/issues/${PR_NUMBER}/comments" -d @- || true -H "Content-Type: application/json" "${API}/issues/${PR_NUMBER}/comments" -d @- || true
matrix_send "review" "PR #${PR_NUMBER} review failed" 2>/dev/null || true; exit 1 matrix_send "review" "PR #${PR_NUMBER} review failed" 2>/dev/null || true; exit 1
fi fi
@ -163,7 +163,7 @@ COMMENT_BODY=$(printf '## AI %s\n<!-- reviewed: %s -->\n\n%s\n\n### Verdict\n**%
printf '%s' "$COMMENT_BODY" > "${REVIEW_TMPDIR}/body.txt" printf '%s' "$COMMENT_BODY" > "${REVIEW_TMPDIR}/body.txt"
jq -Rs '{body: .}' < "${REVIEW_TMPDIR}/body.txt" > "${REVIEW_TMPDIR}/comment.json" jq -Rs '{body: .}' < "${REVIEW_TMPDIR}/body.txt" > "${REVIEW_TMPDIR}/comment.json"
POST_RC=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ POST_RC=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${REVIEW_BOT_TOKEN}" -H "Content-Type: application/json" \ -H "Authorization: token ${FORGE_REVIEW_TOKEN}" -H "Content-Type: application/json" \
"${API}/issues/${PR_NUMBER}/comments" --data-binary @"${REVIEW_TMPDIR}/comment.json") "${API}/issues/${PR_NUMBER}/comments" --data-binary @"${REVIEW_TMPDIR}/comment.json")
[ "$POST_RC" != "201" ] && { log "ERROR: comment HTTP ${POST_RC}"; exit 1; } [ "$POST_RC" != "201" ] && { log "ERROR: comment HTTP ${POST_RC}"; exit 1; }
log "posted review comment" log "posted review comment"
@ -171,19 +171,19 @@ log "posted review comment"
REVENT="COMMENT" REVENT="COMMENT"
case "$VERDICT" in APPROVE) REVENT="APPROVED" ;; REQUEST_CHANGES|DISCUSS) REVENT="REQUEST_CHANGES" ;; esac case "$VERDICT" in APPROVE) REVENT="APPROVED" ;; REQUEST_CHANGES|DISCUSS) REVENT="REQUEST_CHANGES" ;; esac
if [ "$REVENT" = "APPROVED" ]; then if [ "$REVENT" = "APPROVED" ]; then
BLOGIN=$(curl -sf -H "Authorization: token ${REVIEW_BOT_TOKEN}" \ BLOGIN=$(curl -sf -H "Authorization: token ${FORGE_REVIEW_TOKEN}" \
"${API%%/repos*}/user" 2>/dev/null | jq -r '.login // empty' || true) "${API%%/repos*}/user" 2>/dev/null | jq -r '.login // empty' || true)
[ -n "$BLOGIN" ] && codeberg_api_all "/pulls/${PR_NUMBER}/reviews" "$REVIEW_BOT_TOKEN" 2>/dev/null | \ [ -n "$BLOGIN" ] && forge_api_all "/pulls/${PR_NUMBER}/reviews" "${FORGE_REVIEW_TOKEN}" 2>/dev/null | \
jq -r --arg l "$BLOGIN" '.[]|select(.state=="REQUEST_CHANGES")|select(.user.login==$l)|.id' | \ jq -r --arg l "$BLOGIN" '.[]|select(.state=="REQUEST_CHANGES")|select(.user.login==$l)|.id' | \
while IFS= read -r rid; do while IFS= read -r rid; do
curl -sf -o /dev/null -X POST -H "Authorization: token ${REVIEW_BOT_TOKEN}" \ curl -sf -o /dev/null -X POST -H "Authorization: token ${FORGE_REVIEW_TOKEN}" \
-H "Content-Type: application/json" "${API}/pulls/${PR_NUMBER}/reviews/${rid}/dismissals" \ -H "Content-Type: application/json" "${API}/pulls/${PR_NUMBER}/reviews/${rid}/dismissals" \
-d '{"message":"Superseded by approval"}' || true; log "dismissed review ${rid}" -d '{"message":"Superseded by approval"}' || true; log "dismissed review ${rid}"
done || true done || true
fi fi
jq -n --arg b "AI ${RTYPE}: **${VERDICT}** — ${REASON}" --arg e "$REVENT" --arg s "$PR_SHA" \ jq -n --arg b "AI ${RTYPE}: **${VERDICT}** — ${REASON}" --arg e "$REVENT" --arg s "$PR_SHA" \
'{body: $b, event: $e, commit_id: $s}' > "${REVIEW_TMPDIR}/formal.json" '{body: $b, event: $e, commit_id: $s}' > "${REVIEW_TMPDIR}/formal.json"
curl -s -o /dev/null -X POST -H "Authorization: token ${REVIEW_BOT_TOKEN}" \ curl -s -o /dev/null -X POST -H "Authorization: token ${FORGE_REVIEW_TOKEN}" \
-H "Content-Type: application/json" "${API}/pulls/${PR_NUMBER}/reviews" \ -H "Content-Type: application/json" "${API}/pulls/${PR_NUMBER}/reviews" \
--data-binary @"${REVIEW_TMPDIR}/formal.json" >/dev/null 2>&1 || true --data-binary @"${REVIEW_TMPDIR}/formal.json" >/dev/null 2>&1 || true
log "formal ${REVENT} submitted" log "formal ${REVENT} submitted"

View file

@ -2,7 +2,7 @@
# ============================================================================= # =============================================================================
# collect-metrics.sh — Collect factory metrics and write JSON for the dashboard # collect-metrics.sh — Collect factory metrics and write JSON for the dashboard
# #
# Queries Codeberg API for PR/issue stats across all managed projects, # Queries forge API for PR/issue stats across all managed projects,
# counts vault decisions, and checks CI pass rates. Writes a JSON snapshot # counts vault decisions, and checks CI pass rates. Writes a JSON snapshot
# to the live site directory so the dashboard can fetch it. # to the live site directory so the dashboard can fetch it.
# #
@ -47,12 +47,15 @@ collect_project_metrics() {
repo=$(grep '^repo ' "$project_toml" | head -1 | sed 's/.*= *"//;s/"//') repo=$(grep '^repo ' "$project_toml" | head -1 | sed 's/.*= *"//;s/"//')
repo_name=$(grep '^name ' "$project_toml" | head -1 | sed 's/.*= *"//;s/"//') repo_name=$(grep '^name ' "$project_toml" | head -1 | sed 's/.*= *"//;s/"//')
local api_base="https://codeberg.org/api/v1/repos/${repo}" local forge_url
forge_url=$(grep '^forge_url ' "$project_toml" | head -1 | sed 's/.*= *"//;s/"//') 2>/dev/null || true
forge_url="${forge_url:-${FORGE_URL:-http://localhost:3000}}"
local api_base="${forge_url}/api/v1/repos/${repo}"
# PRs merged (all time via state=closed + merged marker) # PRs merged (all time via state=closed + merged marker)
local prs_merged_week=0 prs_merged_month=0 prs_merged_total=0 local prs_merged_week=0 prs_merged_month=0 prs_merged_total=0
local closed_prs local closed_prs
closed_prs=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ closed_prs=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${api_base}/pulls?state=closed&sort=updated&limit=50" 2>/dev/null || echo "[]") "${api_base}/pulls?state=closed&sort=updated&limit=50" 2>/dev/null || echo "[]")
prs_merged_total=$(printf '%s' "$closed_prs" | jq '[.[] | select(.merged)] | length' 2>/dev/null || echo 0) prs_merged_total=$(printf '%s' "$closed_prs" | jq '[.[] | select(.merged)] | length' 2>/dev/null || echo 0)
@ -69,7 +72,7 @@ collect_project_metrics() {
# Issues closed # Issues closed
local issues_closed_week=0 issues_closed_month=0 local issues_closed_week=0 issues_closed_month=0
local closed_issues local closed_issues
closed_issues=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ closed_issues=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${api_base}/issues?state=closed&sort=updated&type=issues&limit=50" 2>/dev/null || echo "[]") "${api_base}/issues?state=closed&sort=updated&type=issues&limit=50" 2>/dev/null || echo "[]")
if [ -n "$WEEK_AGO" ]; then if [ -n "$WEEK_AGO" ]; then
@ -82,19 +85,19 @@ collect_project_metrics() {
fi fi
local total_closed_header local total_closed_header
total_closed_header=$(curl -sf -I -H "Authorization: token ${CODEBERG_TOKEN}" \ total_closed_header=$(curl -sf -I -H "Authorization: token ${FORGE_TOKEN}" \
"${api_base}/issues?state=closed&type=issues&limit=1" 2>/dev/null | grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}' || echo "0") "${api_base}/issues?state=closed&type=issues&limit=1" 2>/dev/null | grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}' || echo "0")
local issues_closed_total="${total_closed_header:-0}" local issues_closed_total="${total_closed_header:-0}"
# Open issues by label # Open issues by label
local backlog_count in_progress_count blocked_count local backlog_count in_progress_count blocked_count
backlog_count=$(curl -sf -I -H "Authorization: token ${CODEBERG_TOKEN}" \ backlog_count=$(curl -sf -I -H "Authorization: token ${FORGE_TOKEN}" \
"${api_base}/issues?state=open&labels=backlog&type=issues&limit=1" 2>/dev/null | \ "${api_base}/issues?state=open&labels=backlog&type=issues&limit=1" 2>/dev/null | \
grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}' || echo "0") grep -i 'x-total-count' | tr -d '\r' | awk '{print $2}' || echo "0")
in_progress_count=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ in_progress_count=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${api_base}/issues?state=open&labels=in-progress&type=issues&limit=50" 2>/dev/null | \ "${api_base}/issues?state=open&labels=in-progress&type=issues&limit=50" 2>/dev/null | \
jq 'length' 2>/dev/null || echo 0) jq 'length' 2>/dev/null || echo 0)
blocked_count=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ blocked_count=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${api_base}/issues?state=open&labels=blocked&type=issues&limit=50" 2>/dev/null | \ "${api_base}/issues?state=open&labels=blocked&type=issues&limit=50" 2>/dev/null | \
jq 'length' 2>/dev/null || echo 0) jq 'length' 2>/dev/null || echo 0)

View file

@ -355,7 +355,7 @@
<div class="footer"> <div class="footer">
<div>Data refreshed every 6 hours by the metrics collector.</div> <div>Data refreshed every 6 hours by the metrics collector.</div>
<div style="margin-top:0.5rem"> <div style="margin-top:0.5rem">
<a href="https://codeberg.org/johba/disinto">source</a> <a href="http://localhost:3000/johba/disinto">source</a>
</div> </div>
</div> </div>
@ -421,7 +421,7 @@
var card = el('div', 'project'); var card = el('div', 'project');
var nameDiv = el('div', 'name'); var nameDiv = el('div', 'name');
var nameLink = document.createElement('a'); var nameLink = document.createElement('a');
nameLink.href = 'https://codeberg.org/' + p.repo; nameLink.href = 'http://localhost:3000/' + p.repo;
nameLink.textContent = p.name; nameLink.textContent = p.name;
nameDiv.appendChild(nameLink); nameDiv.appendChild(nameLink);
card.appendChild(nameDiv); card.appendChild(nameDiv);

View file

@ -365,7 +365,7 @@
<!-- Eight Agents --> <!-- Eight Agents -->
<div class="section"> <div class="section">
<h2>Eight agents</h2> <h2>Eight agents</h2>
<p>Each agent has a single responsibility. They communicate through git, the Codeberg API, and the filesystem.</p> <p>Each agent has a single responsibility. They communicate through git, the forge API, and the filesystem.</p>
<div class="agents-grid"> <div class="agents-grid">
<div class="agent-card"> <div class="agent-card">
<div class="name">dev-agent</div> <div class="name">dev-agent</div>
@ -480,7 +480,7 @@
<p><strong>Bash scripts</strong> &mdash; every agent is a shell script. No compiled binaries, no runtimes to install.</p> <p><strong>Bash scripts</strong> &mdash; every agent is a shell script. No compiled binaries, no runtimes to install.</p>
<p><strong>Claude CLI</strong> &mdash; AI is invoked via <code>claude -p</code> (one-shot) or <code>claude</code> (persistent tmux sessions).</p> <p><strong>Claude CLI</strong> &mdash; AI is invoked via <code>claude -p</code> (one-shot) or <code>claude</code> (persistent tmux sessions).</p>
<p><strong>Cron</strong> &mdash; agents are triggered by cron jobs, not a daemon. Pull-based, not push-based.</p> <p><strong>Cron</strong> &mdash; agents are triggered by cron jobs, not a daemon. Pull-based, not push-based.</p>
<p><strong>Codeberg + Woodpecker</strong> &mdash; git hosting and CI. All state lives in git and the issue tracker. No external databases.</p> <p><strong>Forgejo + Woodpecker</strong> &mdash; git hosting and CI. All state lives in git and the issue tracker. No external databases.</p>
<p><strong>Single VPS</strong> &mdash; runs on an 8 GB server. Flat cost, no scaling surprises.</p> <p><strong>Single VPS</strong> &mdash; runs on an 8 GB server. Flat cost, no scaling surprises.</p>
</div> </div>
</div> </div>
@ -538,7 +538,7 @@ disinto/
<div class="footer"> <div class="footer">
<a href="/">&larr; disinto.ai</a> &middot; <a href="/">&larr; disinto.ai</a> &middot;
<a href="/docs/quickstart">Quickstart</a> &middot; <a href="/docs/quickstart">Quickstart</a> &middot;
<a href="https://codeberg.org/johba/disinto">Source</a> <a href="http://localhost:3000/johba/disinto">Source</a>
</div> </div>
</div> </div>

View file

@ -328,8 +328,8 @@
<div class="label">Prerequisites</div> <div class="label">Prerequisites</div>
<ul> <ul>
<li><strong>A VPS or server</strong> &mdash; 8 GB RAM minimum (Ubuntu/Debian recommended)</li> <li><strong>A VPS or server</strong> &mdash; 8 GB RAM minimum (Ubuntu/Debian recommended)</li>
<li><strong>A Codeberg account</strong> &mdash; with a repo and at least one issue</li> <li><strong>A forge instance</strong> &mdash; with a repo and at least one issue</li>
<li><strong>A second Codeberg account</strong> &mdash; for the review bot (branch protection requires a different reviewer)</li> <li><strong>A second forge instance</strong> &mdash; for the review bot (branch protection requires a different reviewer)</li>
<li><strong>Woodpecker CI</strong> &mdash; running and connected to your repo</li> <li><strong>Woodpecker CI</strong> &mdash; running and connected to your repo</li>
<li><strong>An Anthropic API key</strong> &mdash; with the <code>claude</code> CLI installed and authenticated</li> <li><strong>An Anthropic API key</strong> &mdash; with the <code>claude</code> CLI installed and authenticated</li>
<li><strong>tmux</strong> &mdash; for persistent dev sessions</li> <li><strong>tmux</strong> &mdash; for persistent dev sessions</li>
@ -343,13 +343,13 @@
Clone the factory Clone the factory
</div> </div>
<p>Clone disinto onto your server. This is the factory &mdash; the code that runs your agents.</p> <p>Clone disinto onto your server. This is the factory &mdash; the code that runs your agents.</p>
<pre><code>git clone https://codeberg.org/johba/disinto.git ~/disinto <pre><code>git clone http://localhost:3000/johba/disinto.git ~/disinto
cd ~/disinto cd ~/disinto
cp .env.example .env</code></pre> cp .env.example .env</code></pre>
<p>Edit <code>.env</code> with your tokens:</p> <p>Edit <code>.env</code> with your tokens:</p>
<pre><code><span class="comment"># Required</span> <pre><code><span class="comment"># Required</span>
CODEBERG_TOKEN=your_codeberg_token FORGE_TOKEN=your_codeberg_token
REVIEW_BOT_TOKEN=your_review_bot_token FORGE_REVIEW_TOKEN=your_review_bot_token
<span class="comment"># Woodpecker CI</span> <span class="comment"># Woodpecker CI</span>
WOODPECKER_TOKEN=your_woodpecker_token WOODPECKER_TOKEN=your_woodpecker_token
@ -365,14 +365,14 @@ CLAUDE_TIMEOUT=7200</code></pre>
<span class="step-num">2</span> <span class="step-num">2</span>
Initialize your project Initialize your project
</div> </div>
<p><code>disinto init</code> sets up everything: clones the repo, creates the project config, adds Codeberg labels, and installs cron jobs.</p> <p><code>disinto init</code> provisions a local Forgejo instance, clones the repo, creates the project config, adds labels, and installs cron jobs.</p>
<pre><code>bin/disinto init https://codeberg.org/you/your-project</code></pre> <pre><code>bin/disinto init http://localhost:3000/you/your-project</code></pre>
<div class="expected"> <div class="expected">
<div class="label">Expected output</div> <div class="label">Expected output</div>
<code>=== disinto init === <code>=== disinto init ===
Project: you/your-project Project: you/your-project
Name: your-project Name: your-project
Cloning: https://codeberg.org/you/your-project.git -> /home/you/your-project Cloning: http://localhost:3000/you/your-project.git -> /home/you/your-project
Branch: main Branch: main
Created: /home/you/disinto/projects/your-project.toml Created: /home/you/disinto/projects/your-project.toml
Creating labels on you/your-project... Creating labels on you/your-project...
@ -406,7 +406,7 @@ Done. Project your-project is ready.</code>
<ol> <ol>
<li><strong>A CI pipeline</strong> &mdash; at least one <code>.woodpecker/*.yml</code> file. Agents wait for CI before reviewing or merging.</li> <li><strong>A CI pipeline</strong> &mdash; at least one <code>.woodpecker/*.yml</code> file. Agents wait for CI before reviewing or merging.</li>
<li><strong>A CLAUDE.md</strong> &mdash; project context that agents read before every task. Describe your tech stack, how to build/test, coding conventions, and directory layout.</li> <li><strong>A CLAUDE.md</strong> &mdash; project context that agents read before every task. Describe your tech stack, how to build/test, coding conventions, and directory layout.</li>
<li><strong>Branch protection</strong> &mdash; on Codeberg, require PR reviews and add the review bot as a write collaborator.</li> <li><strong>Branch protection</strong> &mdash; on Forgejo, require PR reviews and add the review bot as a write collaborator.</li>
</ol> </ol>
<pre><code><span class="comment"># Create CLAUDE.md in your project</span> <pre><code><span class="comment"># Create CLAUDE.md in your project</span>
cat > ~/your-project/CLAUDE.md &lt;&lt;'EOF' cat > ~/your-project/CLAUDE.md &lt;&lt;'EOF'
@ -434,7 +434,7 @@ git push</code></pre>
<span class="step-num">4</span> <span class="step-num">4</span>
File your first issue File your first issue
</div> </div>
<p>Create an issue on Codeberg with the <code>backlog</code> label. Be specific &mdash; the dev-agent works best with clear acceptance criteria.</p> <p>Create an issue on the forge with the <code>backlog</code> label. Be specific &mdash; the dev-agent works best with clear acceptance criteria.</p>
<pre><code><span class="comment"># Title: Add health check endpoint</span> <pre><code><span class="comment"># Title: Add health check endpoint</span>
<span class="comment"># Label: backlog</span> <span class="comment"># Label: backlog</span>
<span class="comment"># Body:</span> <span class="comment"># Body:</span>
@ -523,7 +523,7 @@ git log --oneline -5</code></pre>
<div class="footer"> <div class="footer">
<a href="/">&larr; disinto.ai</a> &middot; <a href="/">&larr; disinto.ai</a> &middot;
<a href="/docs/architecture">Architecture</a> &middot; <a href="/docs/architecture">Architecture</a> &middot;
<a href="https://codeberg.org/johba/disinto">Source</a> <a href="http://localhost:3000/johba/disinto">Source</a>
</div> </div>
</div> </div>

View file

@ -661,7 +661,7 @@
<p> <p>
<strong>Bash scripts and Claude.</strong> No Kubernetes, no microservices, <strong>Bash scripts and Claude.</strong> No Kubernetes, no microservices,
no SaaS dependencies. Runs on an 8GB VPS. no SaaS dependencies. Runs on an 8GB VPS.
Point it at a <strong>Codeberg repo</strong> with a Point it at a <strong>forge repo</strong> with a
<strong>Woodpecker CI</strong> pipeline and it starts building. <strong>Woodpecker CI</strong> pipeline and it starts building.
</p> </p>
<p> <p>
@ -691,8 +691,8 @@
<div class="cta-links"> <div class="cta-links">
<a href="/docs/quickstart">Quickstart</a> <a href="/docs/quickstart">Quickstart</a>
<a href="/docs/architecture">Architecture</a> <a href="/docs/architecture">Architecture</a>
<a href="https://codeberg.org/johba/disinto">Browse the source</a> <a href="http://localhost:3000/johba/disinto">Browse the source</a>
<a href="https://codeberg.org/johba/disinto/issues">See active issues</a> <a href="http://localhost:3000/johba/disinto/issues">See active issues</a>
<a href="/dashboard">Live dashboard</a> <a href="/dashboard">Live dashboard</a>
</div> </div>
</div> </div>
@ -702,7 +702,7 @@
<div class="links"> <div class="links">
<a href="/docs/quickstart">quickstart</a> <a href="/docs/quickstart">quickstart</a>
<a href="/docs/architecture">architecture</a> <a href="/docs/architecture">architecture</a>
<a href="https://codeberg.org/johba/disinto">source</a> <a href="http://localhost:3000/johba/disinto">source</a>
<a href="/dashboard">dashboard</a> <a href="/dashboard">dashboard</a>
</div> </div>
<div class="under-hood"> <div class="under-hood">

View file

@ -42,7 +42,7 @@ Matrix listener routes thread replies to `/tmp/supervisor-escalation-reply`,
which `supervisor-run.sh` consumes atomically on each run. which `supervisor-run.sh` consumes atomically on each run.
**Environment variables consumed**: **Environment variables consumed**:
- `CODEBERG_TOKEN`, `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT` - `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by supervisor-run.sh) - `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by supervisor-run.sh)
- `WOODPECKER_TOKEN`, `WOODPECKER_SERVER`, `WOODPECKER_DB_PASSWORD`, `WOODPECKER_DB_USER`, `WOODPECKER_DB_HOST`, `WOODPECKER_DB_NAME` — CI database queries - `WOODPECKER_TOKEN`, `WOODPECKER_SERVER`, `WOODPECKER_DB_PASSWORD`, `WOODPECKER_DB_USER`, `WOODPECKER_DB_HOST`, `WOODPECKER_DB_NAME` — CI database queries
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` — Matrix notifications + human input - `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` — Matrix notifications + human input

View file

@ -1,6 +1,6 @@
# Supervisor Agent # Supervisor Agent
You are the supervisor agent for `$CODEBERG_REPO`. You were called because You are the supervisor agent for `$FORGE_REPO`. You were called because
`supervisor-poll.sh` detected an issue it couldn't auto-fix. `supervisor-poll.sh` detected an issue it couldn't auto-fix.
## Priority Order ## Priority Order
@ -19,7 +19,7 @@ Before acting, read the relevant best-practices file:
- Memory issues → `cat ${FACTORY_ROOT}/supervisor/best-practices/memory.md` - Memory issues → `cat ${FACTORY_ROOT}/supervisor/best-practices/memory.md`
- Disk issues → `cat ${FACTORY_ROOT}/supervisor/best-practices/disk.md` - Disk issues → `cat ${FACTORY_ROOT}/supervisor/best-practices/disk.md`
- CI issues → `cat ${FACTORY_ROOT}/supervisor/best-practices/ci.md` - CI issues → `cat ${FACTORY_ROOT}/supervisor/best-practices/ci.md`
- Codeberg / rate limits → `cat ${FACTORY_ROOT}/supervisor/best-practices/codeberg.md` - forge / rate limits → `cat ${FACTORY_ROOT}/supervisor/best-practices/forge.md`
- Dev-agent issues → `cat ${FACTORY_ROOT}/supervisor/best-practices/dev-agent.md` - Dev-agent issues → `cat ${FACTORY_ROOT}/supervisor/best-practices/dev-agent.md`
- Review-agent issues → `cat ${FACTORY_ROOT}/supervisor/best-practices/review-agent.md` - Review-agent issues → `cat ${FACTORY_ROOT}/supervisor/best-practices/review-agent.md`
- Git issues → `cat ${FACTORY_ROOT}/supervisor/best-practices/git.md` - Git issues → `cat ${FACTORY_ROOT}/supervisor/best-practices/git.md`
@ -32,10 +32,10 @@ source ${FACTORY_ROOT}/lib/env.sh
``` ```
This gives you: This gives you:
- `codeberg_api GET "/pulls?state=open"` — Codeberg API (uses $CODEBERG_TOKEN) - `forge_api GET "/pulls?state=open"` — forge API (uses $FORGE_TOKEN)
- `wpdb -c "SELECT ..."` — Woodpecker Postgres (uses $WOODPECKER_DB_PASSWORD) - `wpdb -c "SELECT ..."` — Woodpecker Postgres (uses $WOODPECKER_DB_PASSWORD)
- `woodpecker_api "/repos/$WOODPECKER_REPO_ID/pipelines"` — Woodpecker REST API (uses $WOODPECKER_TOKEN) - `woodpecker_api "/repos/$WOODPECKER_REPO_ID/pipelines"` — Woodpecker REST API (uses $WOODPECKER_TOKEN)
- `$REVIEW_BOT_TOKEN` — for posting reviews as the review_bot account - `$FORGE_REVIEW_TOKEN` — for posting reviews as the review_bot account
- `$PROJECT_REPO_ROOT` — path to the target project repo - `$PROJECT_REPO_ROOT` — path to the target project repo
- `$PROJECT_NAME` — short project name (for worktree prefixes, container names) - `$PROJECT_NAME` — short project name (for worktree prefixes, container names)
- `$PRIMARY_BRANCH` — main branch (master or main) - `$PRIMARY_BRANCH` — main branch (master or main)
@ -48,20 +48,20 @@ This gives you:
When you see "Circular dependency deadlock: #A -> #B -> #A", the backlog is permanently When you see "Circular dependency deadlock: #A -> #B -> #A", the backlog is permanently
stuck. Your job: figure out the correct dependency direction and fix the wrong one. stuck. Your job: figure out the correct dependency direction and fix the wrong one.
1. Read both issue bodies: `codeberg_api GET "/issues/A"`, `codeberg_api GET "/issues/B"` 1. Read both issue bodies: `forge_api GET "/issues/A"`, `forge_api GET "/issues/B"`
2. Read the referenced source files in `$PROJECT_REPO_ROOT` to understand which change 2. Read the referenced source files in `$PROJECT_REPO_ROOT` to understand which change
actually depends on which actually depends on which
3. Edit the issue that has the incorrect dep to remove the `#NNN` reference from its 3. Edit the issue that has the incorrect dep to remove the `#NNN` reference from its
`## Dependencies` section (replace with `- None` if it was the only dep) `## Dependencies` section (replace with `- None` if it was the only dep)
4. If the correct direction is unclear from code, escalate with both issue summaries 4. If the correct direction is unclear from code, escalate with both issue summaries
Use the Codeberg API to edit issue bodies: Use the forge API to edit issue bodies:
```bash ```bash
# Read current body # Read current body
BODY=$(codeberg_api GET "/issues/NNN" | jq -r '.body') BODY=$(forge_api GET "/issues/NNN" | jq -r '.body')
# Edit (remove the circular ref, keep other deps) # Edit (remove the circular ref, keep other deps)
NEW_BODY=$(echo "$BODY" | sed 's/- #XXX/- None/') NEW_BODY=$(echo "$BODY" | sed 's/- #XXX/- None/')
codeberg_api PATCH "/issues/NNN" -d "$(jq -nc --arg b "$NEW_BODY" '{body:$b}')" forge_api PATCH "/issues/NNN" -d "$(jq -nc --arg b "$NEW_BODY" '{body:$b}')"
``` ```
### Stale dependencies (P3) ### Stale dependencies (P3)

View file

@ -26,13 +26,13 @@
- Modifying pipeline configs in `.woodpecker/` directory - Modifying pipeline configs in `.woodpecker/` directory
## Known Issues ## Known Issues
- Codeberg rate-limits SSH clones. `git` step fails with exit 128. Retrigger usually works. - forge rate-limits SSH clones. `git` step fails with exit 128. Retrigger usually works.
- `log_entries` table grows fast (was 5.6GB once). Truncate periodically. - `log_entries` table grows fast (was 5.6GB once). Truncate periodically.
- Example (harb): Running CI + harb stack = 14+ containers on 8GB. Memory pressure is real. - Example (harb): Running CI + harb stack = 14+ containers on 8GB. Memory pressure is real.
- CI images take hours to rebuild. Never run `docker system prune -a`. - CI images take hours to rebuild. Never run `docker system prune -a`.
## Lessons Learned ## Lessons Learned
- Exit code 128 on git step = Codeberg rate limit, not a code problem. Retrigger. - Exit code 128 on git step = forge rate limit, not a code problem. Retrigger.
- Exit code 137 = OOM kill. Check memory, kill stale processes, retrigger. - Exit code 137 = OOM kill. Check memory, kill stale processes, retrigger.
- `node-quality` step fails on eslint/typescript errors — these need code fixes, not CI fixes. - `node-quality` step fails on eslint/typescript errors — these need code fixes, not CI fixes.

View file

@ -14,7 +14,7 @@
- Clean worktree: `cd $PROJECT_REPO_ROOT && git worktree remove /tmp/${PROJECT_NAME}-worktree-<N> --force` - Clean worktree: `cd $PROJECT_REPO_ROOT && git worktree remove /tmp/${PROJECT_NAME}-worktree-<N> --force`
- Remove `in-progress` label if agent died without cleanup: - Remove `in-progress` label if agent died without cleanup:
```bash ```bash
codeberg_api DELETE "/issues/<N>/labels/in-progress" forge_api DELETE "/issues/<N>/labels/in-progress"
``` ```
## Dangerous (escalate) ## Dangerous (escalate)
@ -41,7 +41,7 @@
**Trust closed state.** If a dependency issue is closed, the code is on the primary branch. Period. **Trust closed state.** If a dependency issue is closed, the code is on the primary branch. Period.
DO NOT try to find the specific PR that closed an issue. This is over-engineering that causes false negatives: DO NOT try to find the specific PR that closed an issue. This is over-engineering that causes false negatives:
- Codeberg shares issue/PR numbering — no guaranteed relationship - forge shares issue/PR numbering — no guaranteed relationship
- PRs don't always mention the issue number in title/body - PRs don't always mention the issue number in title/body
- Searching last N closed PRs misses older merges - Searching last N closed PRs misses older merges
- The dev-agent closes issues after merging, so closed = merged - The dev-agent closes issues after merging, so closed = merged
@ -52,7 +52,7 @@ The only check needed: `issue.state == "closed"`.
The supervisor-poll alert 'status unchanged for Nmin' is a false positive for complex implementation tasks. The status is set to 'claude assessing + implementing' at the START of the `timeout 7200 claude -p ...` call and only updates after Claude finishes. Normal complex tasks (multi-file Solidity changes + forge test) take 45-90 minutes. To distinguish a false positive from a real stuck agent: check that the claude PID is alive (`ps -p <PID>`), consuming CPU (>0%), and has active threads (`pstree -p <PID>`). If the process is alive and using CPU, do NOT restart it — this wastes completed work. The supervisor-poll alert 'status unchanged for Nmin' is a false positive for complex implementation tasks. The status is set to 'claude assessing + implementing' at the START of the `timeout 7200 claude -p ...` call and only updates after Claude finishes. Normal complex tasks (multi-file Solidity changes + forge test) take 45-90 minutes. To distinguish a false positive from a real stuck agent: check that the claude PID is alive (`ps -p <PID>`), consuming CPU (>0%), and has active threads (`pstree -p <PID>`). If the process is alive and using CPU, do NOT restart it — this wastes completed work.
### False Positive: 'Waiting for CI + Review' Alert ### False Positive: 'Waiting for CI + Review' Alert
The 'status unchanged for Nmin' alert is also a false positive when status is 'waiting for CI + review on PR #N (round R)'. This is an intentional sleep/poll loop — the agent is waiting for CI to pass and then for review-poll to post a review. CI can take 2040 minutes; review follows. Do NOT restart the agent. Confirm by checking: (1) agent PID is alive, (2) CI commit status via `codeberg_api GET /commits/<sha>/status`, (3) review-poll log shows it will pick up the PR on next cycle. The 'status unchanged for Nmin' alert is also a false positive when status is 'waiting for CI + review on PR #N (round R)'. This is an intentional sleep/poll loop — the agent is waiting for CI to pass and then for review-poll to post a review. CI can take 2040 minutes; review follows. Do NOT restart the agent. Confirm by checking: (1) agent PID is alive, (2) CI commit status via `forge_api GET /commits/<sha>/status`, (3) review-poll log shows it will pick up the PR on next cycle.
### False Positive: Shared Status File Causes Giant Age (29M+ min) ### False Positive: Shared Status File Causes Giant Age (29M+ min)
When the status file `/tmp/dev-agent-status` doesn't exist, `stat -c %Y` fails and the supervisor falls back to epoch 0. The computed age is then `NOW_EPOCH/60 ≈ 29,567,290 min`, which is unmistakably a false positive. When the status file `/tmp/dev-agent-status` doesn't exist, `stat -c %Y` fails and the supervisor falls back to epoch 0. The computed age is then `NOW_EPOCH/60 ≈ 29,567,290 min`, which is unmistakably a false positive.
@ -73,7 +73,7 @@ Symptom: agent in awaiting_review with PR CI=failure and push CI=success.
Fix: inject with explicit pipeline #623 (the pull_request event pipeline), point to the failing step and the specific duplicate blocks to fix. Use: woodpecker_api /repos/4/pipelines?event=pull_request (or look for event=pull_request in recent pipelines list) to find the correct pipeline number before injecting. Fix: inject with explicit pipeline #623 (the pull_request event pipeline), point to the failing step and the specific duplicate blocks to fix. Use: woodpecker_api /repos/4/pipelines?event=pull_request (or look for event=pull_request in recent pipelines list) to find the correct pipeline number before injecting.
### Race Condition: Review Posted Before PHASE:awaiting_review Transitions ### Race Condition: Review Posted Before PHASE:awaiting_review Transitions
**Symptom:** Dev-agent status unchanged at 'waiting for review on PR #N', no `review-injected-disinto-N` sentinel, but a formal review already exists on Codeberg and `/tmp/disinto-review-output-N.json` was written before the phase file updated. **Symptom:** Dev-agent status unchanged at 'waiting for review on PR #N', no `review-injected-disinto-N` sentinel, but a formal review already exists on forge and `/tmp/disinto-review-output-N.json` was written before the phase file updated.
**Root cause:** review-pr.sh runs while the dev-agent is still in PHASE:awaiting_ci. inject_review_into_dev_session returns early (phase check fails). On subsequent review-poll cycles, the PR is skipped (formal review already exists for SHA), so inject is never called again. **Root cause:** review-pr.sh runs while the dev-agent is still in PHASE:awaiting_ci. inject_review_into_dev_session returns early (phase check fails). On subsequent review-poll cycles, the PR is skipped (formal review already exists for SHA), so inject is never called again.
@ -84,7 +84,7 @@ PROJECT_TOML=/home/debian/dark-factory/projects/disinto.toml
source /home/debian/dark-factory/lib/load-project.sh "$PROJECT_TOML" source /home/debian/dark-factory/lib/load-project.sh "$PROJECT_TOML"
PHASE_FILE="/tmp/dev-session-${PROJECT_NAME}-<ISSUE>.phase" PHASE_FILE="/tmp/dev-session-${PROJECT_NAME}-<ISSUE>.phase"
PR_NUM=<N>; PR_BRANCH="fix/issue-<ISSUE>"; PR_SHA=$(cat /tmp/dev-session-${PROJECT_NAME}-<ISSUE>.phase | grep SHA | cut -d: -f2 || git -C $PROJECT_REPO_ROOT rev-parse origin/$PR_BRANCH) PR_NUM=<N>; PR_BRANCH="fix/issue-<ISSUE>"; PR_SHA=$(cat /tmp/dev-session-${PROJECT_NAME}-<ISSUE>.phase | grep SHA | cut -d: -f2 || git -C $PROJECT_REPO_ROOT rev-parse origin/$PR_BRANCH)
REVIEW_TEXT=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" "${CODEBERG_API}/issues/${PR_NUM}/comments?limit=50" | jq -r --arg sha "$PR_SHA" '[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty | .body') REVIEW_TEXT=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" "${FORGE_API}/issues/${PR_NUM}/comments?limit=50" | jq -r --arg sha "$PR_SHA" '[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty | .body')
INJECT_MSG="Review: REQUEST_CHANGES on PR #${PR_NUM}:\n\n${REVIEW_TEXT}\n\nInstructions:\n1. Address each piece of feedback carefully.\n2. Run lint and tests when done.\n3. Commit your changes and push: git push origin ${PR_BRANCH}\n4. Write: echo PHASE:awaiting_ci > "${PHASE_FILE}"\n5. Stop and wait for the next CI result." INJECT_MSG="Review: REQUEST_CHANGES on PR #${PR_NUM}:\n\n${REVIEW_TEXT}\n\nInstructions:\n1. Address each piece of feedback carefully.\n2. Run lint and tests when done.\n3. Commit your changes and push: git push origin ${PR_BRANCH}\n4. Write: echo PHASE:awaiting_ci > "${PHASE_FILE}"\n5. Stop and wait for the next CI result."
INJECT_TMP=$(mktemp); printf '%s' "$INJECT_MSG" > "$INJECT_TMP" INJECT_TMP=$(mktemp); printf '%s' "$INJECT_MSG" > "$INJECT_TMP"
tmux load-buffer -b inject "$INJECT_TMP" && tmux paste-buffer -t "dev-${PROJECT_NAME}-<ISSUE>" -b inject && sleep 0.5 && tmux send-keys -t "dev-${PROJECT_NAME}-<ISSUE>" '' Enter tmux load-buffer -b inject "$INJECT_TMP" && tmux paste-buffer -t "dev-${PROJECT_NAME}-<ISSUE>" -b inject && sleep 0.5 && tmux send-keys -t "dev-${PROJECT_NAME}-<ISSUE>" '' Enter

View file

@ -1,7 +1,7 @@
# Codeberg Best Practices # Forge Best Practices
## Rate Limiting ## Rate Limiting
Codeberg rate-limits SSH and HTTPS clones. Symptoms: The forge (Forgejo/Gitea) may rate-limit SSH and HTTPS clones. Symptoms:
- Woodpecker `git` step fails with exit code 128 - Woodpecker `git` step fails with exit code 128
- Multiple pipelines fail in quick succession with the same error - Multiple pipelines fail in quick succession with the same error
- Retriggers make it WORSE by adding more clone attempts - Retriggers make it WORSE by adding more clone attempts
@ -26,10 +26,10 @@ cd <worktree> && git commit --allow-empty -m "ci: retrigger" --no-verify && git
- One pipeline at a time is ideal on this VPS (resource + rate limit reasons). - One pipeline at a time is ideal on this VPS (resource + rate limit reasons).
- If >3 pipelines are pending/running, do NOT create more work. - If >3 pipelines are pending/running, do NOT create more work.
## OAuth Tokens ## API Tokens
- OAuth tokens expire ~2h. If Codeberg is down during refresh, re-login required. - API token is in `.env` as `FORGE_TOKEN` — loaded via env.sh.
- API token is in `~/.netrc` — read via `awk` in env.sh. - Review bot has a separate token (`$FORGE_REVIEW_TOKEN`) for formal reviews.
- Review bot has a separate token ($REVIEW_BOT_TOKEN) for formal reviews. - With local Forgejo, tokens don't expire. For remote forges, check provider docs.
## Lessons Learned ## Lessons Learned
- Retrigger storm on 2026-03-12: supervisor + dev-agent both retriggered during rate limit, caused 5+ failed pipelines. Added cooldown awareness. - Retrigger storm on 2026-03-12: supervisor + dev-agent both retriggered during rate limit, caused 5+ failed pipelines. Added cooldown awareness.

View file

@ -3,7 +3,7 @@
## Architecture ## Architecture
- `review-poll.sh` (cron */10) → finds open PRs with CI pass + no review → spawns `review-pr.sh` - `review-poll.sh` (cron */10) → finds open PRs with CI pass + no review → spawns `review-pr.sh`
- `review-pr.sh` uses `claude -p` to review the diff, posts structured comment - `review-pr.sh` uses `claude -p` to review the diff, posts structured comment
- Uses `review_bot` Codeberg account for formal reviews (separate from main account) - Uses `review_bot` forge account for formal reviews (separate from main account)
- Skips WIP/draft PRs (`[WIP]` in title or draft flag) - Skips WIP/draft PRs (`[WIP]` in title or draft flag)
## Safe Fixes ## Safe Fixes
@ -27,4 +27,4 @@
- Review bot must output JSON — prevents self-narration collapse - Review bot must output JSON — prevents self-narration collapse
- DISCUSS verdict should be treated same as REQUEST_CHANGES by dev-agent - DISCUSS verdict should be treated same as REQUEST_CHANGES by dev-agent
- Error comments must NOT include `<!-- reviewed: SHA -->` — would falsely mark as reviewed - Error comments must NOT include `<!-- reviewed: SHA -->` — would falsely mark as reviewed
- Review bot uses Codeberg formal reviews API — branch protection requires different user than PR author - Review bot uses forge formal reviews API — branch protection requires different user than PR author

View file

@ -132,16 +132,16 @@ echo ""
# ── Open PRs ────────────────────────────────────────────────────────────── # ── Open PRs ──────────────────────────────────────────────────────────────
echo "## Open PRs (${PROJECT_NAME})" echo "## Open PRs (${PROJECT_NAME})"
_open_prs=$(codeberg_api GET "/pulls?state=open&limit=10" 2>/dev/null || echo "[]") _open_prs=$(forge_api GET "/pulls?state=open&limit=10" 2>/dev/null || echo "[]")
echo "$_open_prs" | jq -r '.[] | "#\(.number) [\(.head.ref)] \(.title) — updated \(.updated_at)"' 2>/dev/null || echo "No open PRs or query failed" echo "$_open_prs" | jq -r '.[] | "#\(.number) [\(.head.ref)] \(.title) — updated \(.updated_at)"' 2>/dev/null || echo "No open PRs or query failed"
echo "" echo ""
# ── Backlog + In-Progress ───────────────────────────────────────────────── # ── Backlog + In-Progress ─────────────────────────────────────────────────
echo "## Issue Status (${PROJECT_NAME})" echo "## Issue Status (${PROJECT_NAME})"
_backlog_count=$(codeberg_api GET "/issues?state=open&labels=backlog&type=issues&limit=50" 2>/dev/null | jq 'length' 2>/dev/null || echo "?") _backlog_count=$(forge_api GET "/issues?state=open&labels=backlog&type=issues&limit=50" 2>/dev/null | jq 'length' 2>/dev/null || echo "?")
_in_progress_count=$(codeberg_api GET "/issues?state=open&labels=in-progress&type=issues&limit=50" 2>/dev/null | jq 'length' 2>/dev/null || echo "?") _in_progress_count=$(forge_api GET "/issues?state=open&labels=in-progress&type=issues&limit=50" 2>/dev/null | jq 'length' 2>/dev/null || echo "?")
_blocked_count=$(codeberg_api GET "/issues?state=open&labels=blocked&type=issues&limit=50" 2>/dev/null | jq 'length' 2>/dev/null || echo "?") _blocked_count=$(forge_api GET "/issues?state=open&labels=blocked&type=issues&limit=50" 2>/dev/null | jq 'length' 2>/dev/null || echo "?")
echo "Backlog: ${_backlog_count}, In-progress: ${_in_progress_count}, Blocked: ${_blocked_count}" echo "Backlog: ${_backlog_count}, In-progress: ${_in_progress_count}, Blocked: ${_blocked_count}"
echo "" echo ""
@ -161,7 +161,7 @@ echo ""
# ── Blocked Issues ──────────────────────────────────────────────────────── # ── Blocked Issues ────────────────────────────────────────────────────────
echo "## Blocked Issues" echo "## Blocked Issues"
_blocked_issues=$(codeberg_api GET "/issues?state=open&labels=blocked&type=issues&limit=50" 2>/dev/null || echo "[]") _blocked_issues=$(forge_api GET "/issues?state=open&labels=blocked&type=issues&limit=50" 2>/dev/null || echo "[]")
_blocked_n=$(echo "$_blocked_issues" | jq 'length' 2>/dev/null || echo 0) _blocked_n=$(echo "$_blocked_issues" | jq 'length' 2>/dev/null || echo 0)
if [ "${_blocked_n:-0}" -gt 0 ]; then if [ "${_blocked_n:-0}" -gt 0 ]; then
echo "$_blocked_issues" | jq -r '.[] | " #\(.number): \(.title)"' 2>/dev/null || echo " (query failed)" echo "$_blocked_issues" | jq -r '.[] | " #\(.number): \(.title)"' 2>/dev/null || echo " (query failed)"

View file

@ -28,13 +28,13 @@ emit_metric() {
printf '%s\n' "$1" >> "$METRICS_FILE" printf '%s\n' "$1" >> "$METRICS_FILE"
} }
# Count all matching items from a paginated Codeberg API endpoint. # Count all matching items from a paginated forge API endpoint.
# Usage: codeberg_count_paginated "/issues?state=open&labels=backlog&type=issues" # Usage: codeberg_count_paginated "/issues?state=open&labels=backlog&type=issues"
# Returns total count across all pages (max 20 pages = 1000 items). # Returns total count across all pages (max 20 pages = 1000 items).
codeberg_count_paginated() { codeberg_count_paginated() {
local endpoint="$1" total=0 page=1 count local endpoint="$1" total=0 page=1 count
while true; do while true; do
count=$(codeberg_api GET "${endpoint}&limit=50&page=${page}" 2>/dev/null | jq 'length' 2>/dev/null || echo 0) count=$(forge_api GET "${endpoint}&limit=50&page=${page}" 2>/dev/null | jq 'length' 2>/dev/null || echo 0)
total=$((total + ${count:-0})) total=$((total + ${count:-0}))
[ "${count:-0}" -lt 50 ] && break [ "${count:-0}" -lt 50 ] && break
page=$((page + 1)) page=$((page + 1))
@ -244,7 +244,7 @@ mkdir -p "$_RETRY_DIR"
# Function: run all per-project checks for the currently loaded project config # Function: run all per-project checks for the currently loaded project config
check_project() { check_project() {
local proj_name="${PROJECT_NAME:-unknown}" local proj_name="${PROJECT_NAME:-unknown}"
flog "── checking project: ${proj_name} (${CODEBERG_REPO}) ──" flog "── checking project: ${proj_name} (${FORGE_REPO}) ──"
# =========================================================================== # ===========================================================================
# P2: FACTORY STOPPED — CI, dev-agent, git # P2: FACTORY STOPPED — CI, dev-agent, git
@ -366,8 +366,8 @@ check_project() {
if [ "${CHECK_PIPELINE_STALL:-true}" = "true" ]; then if [ "${CHECK_PIPELINE_STALL:-true}" = "true" ]; then
status "P2: ${proj_name}: checking pipeline stall" status "P2: ${proj_name}: checking pipeline stall"
BACKLOG_COUNT=$(codeberg_api GET "/issues?state=open&labels=backlog&type=issues&limit=1" 2>/dev/null | jq -r 'length' 2>/dev/null || echo "0") BACKLOG_COUNT=$(forge_api GET "/issues?state=open&labels=backlog&type=issues&limit=1" 2>/dev/null | jq -r 'length' 2>/dev/null || echo "0")
IN_PROGRESS=$(codeberg_api GET "/issues?state=open&labels=in-progress&type=issues&limit=1" 2>/dev/null | jq -r 'length' 2>/dev/null || echo "0") IN_PROGRESS=$(forge_api GET "/issues?state=open&labels=in-progress&type=issues&limit=1" 2>/dev/null | jq -r 'length' 2>/dev/null || echo "0")
if [ "${BACKLOG_COUNT:-0}" -gt 0 ] && [ "${IN_PROGRESS:-0}" -eq 0 ]; then if [ "${BACKLOG_COUNT:-0}" -gt 0 ] && [ "${IN_PROGRESS:-0}" -eq 0 ]; then
DEV_LOG="${FACTORY_ROOT}/dev/dev-agent.log" DEV_LOG="${FACTORY_ROOT}/dev/dev-agent.log"
@ -408,14 +408,14 @@ check_project() {
if [ "${CHECK_PRS:-true}" = "true" ]; then if [ "${CHECK_PRS:-true}" = "true" ]; then
status "P3: ${proj_name}: checking PRs" status "P3: ${proj_name}: checking PRs"
OPEN_PRS=$(codeberg_api GET "/pulls?state=open&limit=10" 2>/dev/null | jq -r '.[].number' 2>/dev/null || true) OPEN_PRS=$(forge_api GET "/pulls?state=open&limit=10" 2>/dev/null | jq -r '.[].number' 2>/dev/null || true)
for pr in $OPEN_PRS; do for pr in $OPEN_PRS; do
PR_JSON=$(codeberg_api GET "/pulls/${pr}" 2>/dev/null || true) PR_JSON=$(forge_api GET "/pulls/${pr}" 2>/dev/null || true)
[ -z "$PR_JSON" ] && continue [ -z "$PR_JSON" ] && continue
PR_SHA=$(echo "$PR_JSON" | jq -r '.head.sha // ""') PR_SHA=$(echo "$PR_JSON" | jq -r '.head.sha // ""')
[ -z "$PR_SHA" ] && continue [ -z "$PR_SHA" ] && continue
CI_STATE=$(codeberg_api GET "/commits/${PR_SHA}/status" 2>/dev/null | jq -r '.state // "unknown"' 2>/dev/null || true) CI_STATE=$(forge_api GET "/commits/${PR_SHA}/status" 2>/dev/null | jq -r '.state // "unknown"' 2>/dev/null || true)
MERGEABLE=$(echo "$PR_JSON" | jq -r '.mergeable // true') MERGEABLE=$(echo "$PR_JSON" | jq -r '.mergeable // true')
if [ "$MERGEABLE" = "false" ] && ci_passed "$CI_STATE"; then if [ "$MERGEABLE" = "false" ] && ci_passed "$CI_STATE"; then
@ -429,7 +429,7 @@ check_project() {
[ "$AGE_MIN" -gt 30 ] && p3 "${proj_name}: PR #${pr}: CI=${CI_STATE}, stale ${AGE_MIN}min" [ "$AGE_MIN" -gt 30 ] && p3 "${proj_name}: PR #${pr}: CI=${CI_STATE}, stale ${AGE_MIN}min"
fi fi
elif ci_passed "$CI_STATE"; then elif ci_passed "$CI_STATE"; then
HAS_REVIEW=$(codeberg_api GET "/issues/${pr}/comments?limit=50" 2>/dev/null | \ HAS_REVIEW=$(forge_api GET "/issues/${pr}/comments?limit=50" 2>/dev/null | \
jq -r --arg sha "$PR_SHA" '[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | length' 2>/dev/null || echo "0") jq -r --arg sha "$PR_SHA" '[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | length' 2>/dev/null || echo "0")
if [ "${HAS_REVIEW:-0}" -eq 0 ]; then if [ "${HAS_REVIEW:-0}" -eq 0 ]; then
@ -454,7 +454,7 @@ check_project() {
# =========================================================================== # ===========================================================================
status "P3: ${proj_name}: checking for circular dependencies" status "P3: ${proj_name}: checking for circular dependencies"
BACKLOG_FOR_DEPS=$(codeberg_api GET "/issues?state=open&labels=backlog&type=issues&limit=50" 2>/dev/null || true) BACKLOG_FOR_DEPS=$(forge_api GET "/issues?state=open&labels=backlog&type=issues&limit=50" 2>/dev/null || true)
if [ -n "$BACKLOG_FOR_DEPS" ] && [ "$BACKLOG_FOR_DEPS" != "null" ] && [ "$(echo "$BACKLOG_FOR_DEPS" | jq 'length' 2>/dev/null || echo 0)" -gt 0 ]; then if [ -n "$BACKLOG_FOR_DEPS" ] && [ "$BACKLOG_FOR_DEPS" != "null" ] && [ "$(echo "$BACKLOG_FOR_DEPS" | jq 'length' 2>/dev/null || echo 0)" -gt 0 ]; then
PARSE_DEPS="${FACTORY_ROOT}/lib/parse-deps.sh" PARSE_DEPS="${FACTORY_ROOT}/lib/parse-deps.sh"
@ -524,7 +524,7 @@ check_project() {
if [ -n "${DEP_CACHE[$dep]+x}" ]; then if [ -n "${DEP_CACHE[$dep]+x}" ]; then
DEP_INFO="${DEP_CACHE[$dep]}" DEP_INFO="${DEP_CACHE[$dep]}"
else else
DEP_JSON=$(codeberg_api GET "/issues/${dep}" 2>/dev/null || true) DEP_JSON=$(forge_api GET "/issues/${dep}" 2>/dev/null || true)
[ -z "$DEP_JSON" ] && continue [ -z "$DEP_JSON" ] && continue
DEP_STATE=$(echo "$DEP_JSON" | jq -r '.state // "unknown"') DEP_STATE=$(echo "$DEP_JSON" | jq -r '.state // "unknown"')
DEP_CREATED=$(echo "$DEP_JSON" | jq -r '.created_at // ""') DEP_CREATED=$(echo "$DEP_JSON" | jq -r '.created_at // ""')
@ -646,8 +646,8 @@ Instructions:
_sess_issue="${_sess#dev-"${proj_name}"-}" _sess_issue="${_sess#dev-"${proj_name}"-}"
[[ "$_sess_issue" =~ ^[0-9]+$ ]] || continue [[ "$_sess_issue" =~ ^[0-9]+$ ]] || continue
# Check Codeberg: is the issue still open? # Check forge: is the issue still open?
_issue_state=$(codeberg_api GET "/issues/${_sess_issue}" 2>/dev/null \ _issue_state=$(forge_api GET "/issues/${_sess_issue}" 2>/dev/null \
| jq -r '.state // "open"' 2>/dev/null || echo "open") | jq -r '.state // "open"' 2>/dev/null || echo "open")
_should_cleanup=false _should_cleanup=false
@ -671,7 +671,7 @@ Instructions:
_has_open_pr=0 _has_open_pr=0
_pr_page=1 _pr_page=1
while true; do while true; do
_pr_page_json=$(codeberg_api GET "/pulls?state=open&limit=50&page=${_pr_page}" \ _pr_page_json=$(forge_api GET "/pulls?state=open&limit=50&page=${_pr_page}" \
2>/dev/null || echo "[]") 2>/dev/null || echo "[]")
_pr_page_len=$(printf '%s' "$_pr_page_json" | jq 'length' 2>/dev/null || echo 0) _pr_page_len=$(printf '%s' "$_pr_page_json" | jq 'length' 2>/dev/null || echo 0)
_pr_match=$(printf '%s' "$_pr_page_json" | \ _pr_match=$(printf '%s' "$_pr_page_json" | \
@ -689,7 +689,7 @@ Instructions:
_has_closed_pr=0 _has_closed_pr=0
_pr_page=1 _pr_page=1
while true; do while true; do
_pr_page_json=$(codeberg_api GET "/pulls?state=closed&limit=50&page=${_pr_page}" \ _pr_page_json=$(forge_api GET "/pulls?state=closed&limit=50&page=${_pr_page}" \
2>/dev/null || echo "[]") 2>/dev/null || echo "[]")
_pr_page_len=$(printf '%s' "$_pr_page_json" | jq 'length' 2>/dev/null || echo 0) _pr_page_len=$(printf '%s' "$_pr_page_json" | jq 'length' 2>/dev/null || echo 0)
_pr_match=$(printf '%s' "$_pr_page_json" | \ _pr_match=$(printf '%s' "$_pr_page_json" | \
@ -771,7 +771,7 @@ if [ -d "$PROJECTS_DIR" ]; then
[ -f "$project_toml" ] || continue [ -f "$project_toml" ] || continue
PROJECT_COUNT=$((PROJECT_COUNT + 1)) PROJECT_COUNT=$((PROJECT_COUNT + 1))
# Load project config (overrides CODEBERG_REPO, PROJECT_REPO_ROOT, etc.) # Load project config (overrides FORGE_REPO, PROJECT_REPO_ROOT, etc.)
source "${FACTORY_ROOT}/lib/load-project.sh" "$project_toml" source "${FACTORY_ROOT}/lib/load-project.sh" "$project_toml"
check_project || flog "check_project failed for ${project_toml} (per-project checks incomplete)" check_project || flog "check_project failed for ${project_toml} (per-project checks incomplete)"

View file

@ -71,7 +71,7 @@ SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
build_prompt_footer build_prompt_footer
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor # shellcheck disable=SC2034 # consumed by run_formula_and_monitor
PROMPT="You are the supervisor agent for ${CODEBERG_REPO}. Work through the formula below. You MUST write PHASE:done to '${PHASE_FILE}' when finished — the orchestrator will time you out if you return to the prompt without signalling. PROMPT="You are the supervisor agent for ${FORGE_REPO}. Work through the formula below. You MUST write PHASE:done to '${PHASE_FILE}' when finished — the orchestrator will time you out if you return to the prompt without signalling.
You have full shell access and --dangerously-skip-permissions. You have full shell access and --dangerously-skip-permissions.
Fix what you can. Escalate what you cannot. Do NOT ask permission — act first, report after. Fix what you can. Escalate what you cannot. Do NOT ask permission — act first, report after.

View file

@ -1,6 +1,6 @@
# Vault Agent # Vault Agent
You are the vault agent for `$CODEBERG_REPO`. You were called by You are the vault agent for `$FORGE_REPO`. You were called by
`vault-poll.sh` because one or more actions in `vault/pending/` need `vault-poll.sh` because one or more actions in `vault/pending/` need
classification and routing. classification and routing.