# From Three Agent Tools to One: Replacing beads, br, and Agent Mail with Gitea **Technical Architecture Report** **Date**: 2026-02-18 **Target System**: Gitea 1.22.6 on git.terraphim.cloud **Verified**: All commands tested against live instance --- ## 1. Introduction AI agent systems need task tracking, but installing and maintaining multiple specialized tools creates operational overhead. This article documents the consolidation of three agent task management tools into a single Gitea-based solution with verified working commands and configurations. The migration eliminates three separate systems: - **beads (bd)**: Go-based CLI task tracker with problematic auto-commit behavior - **beads_rust (br)**: Rust rewrite that fixed the auto-commit issue but remained local-only - **mcp-agent-mail**: Multi-agent coordination server for cross-agent communication The replacement is a Gitea 1.22.6 instance with REST API integration, milestone-based project tracking, and scoped workflow labels. All configuration uses 1Password for secret management. --- ## 2. The Three Incumbents ### 2.1 beads (Go, "bd") **Architecture**: CLI issue tracker using SQLite + JSONL storage in `.beads/` directory, committed to git as source of truth. **Commands**: ```bash bd ready # List ready tasks bd create # Create new task bd update ID # Modify task bd close ID # Complete task bd sync # Sync to JSONL and commit to git bd list # List tasks bd dep add # Add dependency bd stats # Show project statistics ``` **Task properties**: - Priority: P0 (critical) through P4 (low) - Type: bug, feature, task, epic, chore - Status: draft, ready, in_progress, blocked, done - Dependencies: Task can depend on other tasks **Critical flaw**: `bd sync` executed git commands automatically. The synchronization step would: 1. Export task state to `.beads/tasks.jsonl` 2. Run `git add .beads/` 3. Run `git commit -m "sync beads"` 4. Optionally push to remote This created race conditions when AI agents made rapid commits. Two agents running `bd sync` simultaneously could corrupt the git history or create merge conflicts. **Additional features (later removed)**: - Daemon mode for background synchronization - RPC mode for multi-process access - Git hooks for automatic sync on commit - Priority-based task sorting with graph analysis All of these features were removed in the Rust rewrite due to complexity and reliability concerns. ### 2.2 beads_rust (br, v0.1.13) **Architecture**: Same concept as beads, rewritten in Rust with a critical behavioral change: br never executes git commands. **Commands** (subset of original beads): ```bash br ready # List ready tasks br create "title" # Create task (simpler syntax) br update ID # Modify task br close ID # Complete task br sync --flush-only # Export to JSONL only (no git) br list # List tasks br dep add # Add dependency br stats # Show statistics ``` **Key behavioral change**: The `br sync --flush-only` command only exports state to `.beads/tasks.jsonl`. The user must manually run: ```bash git add .beads/ git commit -m "update tasks" git push ``` This eliminated race conditions but introduced a new problem: agents would forget to commit, leading to divergent state across machines. **Architecture decision**: "Non-invasive by default." The tool manages task state but delegates all version control to the user. **Limitations**: - Local-first design: each repository has its own `.beads/` directory - No cross-repository visibility (can't see tasks from other repos) - No web UI (CLI only) - Requires `br` binary installation on every machine - No centralized dashboard for monitoring agent progress **What was removed from beads (Go)**: - Daemon mode (process management complexity) - RPC server (network protocol overhead) - Auto-commit on sync (source of race conditions) - Git hooks (implicit behavior caused confusion) The Rust rewrite prioritized reliability and simplicity over features. ### 2.3 MCP Agent Mail (mcp-agent-mail) **Architecture**: MCP server providing multi-agent coordination through a message-passing abstraction. **Core concepts**: - **Identities**: Each agent has a unique identity (e.g., "agent-researcher", "agent-coder") - **Inbox/Outbox**: Messages organized by recipient - **Threads**: Conversations grouped by thread ID (convention: `br-123` for task 123) - **File reservations**: Advisory locks to prevent concurrent file edits **Key operations**: ```bash # Send message mcp__agent_mail__send_message(to="agent-coder", subject="Review needed", body="...", thread="br-42") # Read inbox mcp__agent_mail__get_inbox(agent="agent-coder", limit=10) # Search threads mcp__agent_mail__search_threads(query="authentication", agent="agent-coder") # Reserve file (advisory lock) mcp__agent_mail__reserve_file(path="/src/auth.rs", agent="agent-coder", duration=3600) # Release file reservation mcp__agent_mail__release_file(path="/src/auth.rs", agent="agent-coder") ``` **Macro helpers** (common workflows): ```bash # Start agent work session macro_start_session(agent="agent-coder") # Returns: unread message count, active file reservations # Prepare to work on a task thread macro_prepare_thread(agent="agent-coder", thread="br-42") # Returns: thread messages, related file reservations # Check-reserve-work-release cycle macro_file_reservation_cycle(agent="agent-coder", file="/src/auth.rs", thread="br-42") # Checks for existing reservation, reserves if free, returns status ``` **Product bus pattern**: Central coordination repository where agents post status updates and coordinate work across multiple code repositories. **Limitations**: - Additional server process to run and monitor - Separate state store (not integrated with version control) - Coordination overhead (agents must check inbox before acting) - No built-in web UI for human visibility - Thread ID convention (br-123) tightly coupled to beads/br task IDs **Why it existed**: br's local-first design couldn't coordinate work across multiple repositories. Agent Mail provided the missing cross-repo coordination layer. --- ## 3. The Gitea Alternative ### 3.1 Instance Details **URL**: https://git.terraphim.cloud **Version**: Gitea 1.22.6 **Database**: PostgreSQL 15 **Storage**: SeaweedFS (S3-compatible) ### 3.2 Critical API Discovery During implementation, we discovered that **Gitea 1.22.6 has NO project board REST API**. Attempted request: ```bash curl -s -H "Authorization: token $GITEA_TOKEN" \ "https://git.terraphim.cloud/api/v1/user/projects" # Returns: HTTP 404 ``` The Gitea UI has project boards, but they are not exposed via REST API in version 1.22.6. This required a design pivot: **milestones serve as project containers** instead. Milestone API works and provides: - Title, description, due date - Progress tracking: `open_issues` and `closed_issues` counts - State: open or closed - Full CRUD operations via REST API ### 3.3 Workflow State Machine via 12 Scoped Labels Labels implement the task lifecycle. Each label has a scope prefix (e.g., `workflow:`, `priority:`, `type:`) and the `exclusive: true` flag, making labels within the same scope mutually exclusive in the Gitea UI. **Workflow labels** (lifecycle states): | Label | Color | Hex | Scope | Exclusive | |-------|-------|-----|-------|-----------| | workflow:ready | Green | #0e8a16 | workflow | true | | workflow:in-progress | Yellow | #fbca04 | workflow | true | | workflow:blocked | Red | #d93f0b | workflow | true | | workflow:done | Purple | #6f42c1 | workflow | true | **Priority labels** (urgency): | Label | Color | Hex | Scope | Exclusive | |-------|-------|-----|-------|-----------| | priority:critical | Dark red | #b60205 | priority | true | | priority:high | Red | #d93f0b | priority | true | | priority:medium | Yellow | #fbca04 | priority | true | | priority:low | Green | #0e8a16 | priority | true | **Type labels** (task categorization): | Label | Color | Hex | Scope | Exclusive | |-------|-------|-----|-------|-----------| | type:feature | Blue | #1d76db | type | true | | type:bug | Red | #d93f0b | type | true | | type:task | Violet | #5319e7 | type | true | | type:research | Teal | #006b75 | type | true | **Label setup script** (verified working): ```bash #!/usr/bin/env bash # setup-labels.sh set -euo pipefail GITEA_URL="${GITEA_URL:-https://git.terraphim.cloud}" GITEA_OWNER="${1:-${GITEA_OWNER:-terraphim}}" GITEA_REPO="${2:-${GITEA_REPO:-agent-tasks}}" gitea_api() { local method="$1" endpoint="$2" data="${3:-}" local args=(-s -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json") if [ -n "$data" ]; then args+=(-X "$method" -d "$data") else args+=(-X "$method") fi curl "${args[@]}" "$GITEA_URL/api/v1$endpoint" } ensure_label() { local name="$1" color="$2" description="$3" exclusive="${4:-false}" local existing existing=$(gitea_api GET "/repos/$GITEA_OWNER/$GITEA_REPO/labels" \ | jq -r --arg n "$name" '.[] | select(.name == $n) | .id') if [ -n "$existing" ]; then echo "{\"name\": \"$name\", \"id\": $existing, \"action\": \"exists\"}" return 0 fi local payload payload=$(jq -n \ --arg name "$name" \ --arg color "$color" \ --arg desc "$description" \ --argjson excl "$exclusive" \ '{name: $name, color: $color, description: $desc, exclusive: $excl}') local result result=$(gitea_api POST "/repos/$GITEA_OWNER/$GITEA_REPO/labels" "$payload") local label_id label_id=$(echo "$result" | jq -r '.id // empty') if [ -n "$label_id" ]; then echo "{\"name\": \"$name\", \"id\": $label_id, \"action\": \"created\"}" else echo "{\"name\": \"$name\", \"action\": \"error\", \"detail\": $result}" >&2 return 1 fi } # Workflow labels ensure_label "workflow:ready" "0e8a16" "Task is ready for an agent to pick up" true ensure_label "workflow:in-progress" "fbca04" "Task is actively being worked on" true ensure_label "workflow:blocked" "d93f0b" "Task is blocked by a dependency" true ensure_label "workflow:done" "6f42c1" "Task is complete" true # Priority labels ensure_label "priority:critical" "b60205" "Must be done immediately" true ensure_label "priority:high" "d93f0b" "Should be done this cycle" true ensure_label "priority:medium" "fbca04" "Normal priority" true ensure_label "priority:low" "0e8a16" "Nice to have" true # Type labels ensure_label "type:feature" "1d76db" "New functionality" true ensure_label "type:bug" "d93f0b" "Defect fix" true ensure_label "type:task" "5319e7" "General task" true ensure_label "type:research" "006b75" "Investigation or spike" true ``` Run with: ```bash source ~/op_zesticai_non_prod.sh export GITEA_TOKEN=$(op read "op://TerraphimPlatform/git.terraphim.cloud-admin-token/credential") ./setup-labels.sh ``` --- ## 4. 1Password Integration All secrets managed via 1Password. Never hardcode tokens. ### 4.1 1Password Items (TerraphimPlatform vault) | Item | Purpose | Fields | |------|---------|--------| | git.terraphim.cloud-admin-token | Gitea API authentication | credential (token string) | | gitea-postgres | PostgreSQL database | password | | gitea-s3 | SeaweedFS S3 credentials | access-key, secret-key | ### 4.2 Token Retrieval (Verified Working) ```bash # Load 1Password service account source ~/op_zesticai_non_prod.sh # Read Gitea admin token export GITEA_TOKEN=$(op read "op://TerraphimPlatform/git.terraphim.cloud-admin-token/credential") # Verify authentication (VERIFIED 2026-02-18) curl -s -H "Authorization: token $GITEA_TOKEN" \ "https://git.terraphim.cloud/api/v1/user" | jq '{login, is_admin}' # Returns: {"login": "root", "is_admin": true} ``` ### 4.3 Secret Injection for Docker Compose Docker Compose template uses 1Password references: ```bash # Inject secrets from 1Password into docker-compose.yml op inject --in-file docker-compose.yml.template --out-file docker-compose.yml # Start services with injected secrets docker compose up -d ``` --- ## 5. Gitea Infrastructure with SeaweedFS ### 5.1 Architecture Overview ``` ┌─────────────────────────────────────────────────┐ │ Internet (HTTPS) │ └────────────────┬────────────────────────────────┘ │ v ┌─────────────────────────────────────────────────┐ │ Caddy Reverse Proxy │ │ - TLS termination │ │ - git.terraphim.cloud -> localhost:3000 │ └────────────────┬────────────────────────────────┘ │ v ┌─────────────────────────────────────────────────┐ │ Gitea 1.22.6 (Container: gitea) │ │ - HTTP: 127.0.0.1:3000 │ │ - SSH: 0.0.0.0:222 │ │ - Database: PostgreSQL 15 │ │ - Storage: SeaweedFS S3 │ └────────────────┬────────────────────────────────┘ │ ┌────────┴────────┐ │ │ v v ┌──────────────┐ ┌─────────────────────────────┐ │ PostgreSQL │ │ SeaweedFS │ │ (Container) │ │ - master (9333) │ │ Port: 5432 │ │ - volume (8080) │ │ (internal) │ │ - filer (8888) │ └──────────────┘ │ - s3 (8333, Tailscale) │ └─────────────────────────────┘ ``` ### 5.2 Docker Compose Template (docker-compose.yml.template) **Critical**: This file contains `op://` references and must be processed with `op inject` before deployment. ```yaml version: "3" networks: gitea: external: false services: server: image: gitea/gitea:1.22.6 container_name: gitea environment: - USER_UID=1000 - USER_GID=1000 # Public URL settings - GITEA__server__DOMAIN=git.terraphim.cloud - GITEA__server__ROOT_URL=https://git.terraphim.cloud/ - GITEA__server__PROTOCOL=http - GITEA__server__HTTP_PORT=3000 - GITEA__server__DISABLE_SSH=false - GITEA__server__SSH_PORT=22 - GITEA__server__SSH_LISTEN_PORT=22 # Database - GITEA__database__DB_TYPE=postgres - GITEA__database__HOST=db:5432 - GITEA__database__NAME=gitea - GITEA__database__USER=gitea - GITEA__database__PASSWD=op://TerraphimPlatform/gitea-postgres/password # Actions - GITEA__actions__ENABLE=true # LFS - GITEA__repository__LFS_START_SERVER=true - GITEA__repository__LFS_CONTENT_PATH=/data/lfs # S3 storage (general) - GITEA__storage__type=minio - GITEA__storage__MINIO_ACCESS_KEY_ID=op://TerraphimPlatform/gitea-s3/access-key - GITEA__storage__MINIO_SECRET_ACCESS_KEY=op://TerraphimPlatform/gitea-s3/secret-key - GITEA__storage__MINIO_BUCKET=gitea - GITEA__storage__MINIO_LOCATION=us-east-1 - GITEA__storage__MINIO_ENDPOINT=http://s3storage:8333 - GITEA__storage__MINIO_INSECURE_SKIP_VERIFY=false - GITEA__storage__MINIO_USE_SSL=false - GITEA__storage__SERVE_DIRECT=true # S3 storage for attachments - GITEA__attachment__STORAGE_TYPE=minio - GITEA__attachment__MINIO_ENDPOINT=s3storage:8333 - GITEA__attachment__MINIO_ACCESS_KEY_ID=op://TerraphimPlatform/gitea-s3/access-key - GITEA__attachment__MINIO_SECRET_ACCESS_KEY=op://TerraphimPlatform/gitea-s3/secret-key - GITEA__attachment__MINIO_BUCKET=gitea - GITEA__attachment__MINIO_USE_SSL=false # S3 storage for avatars/pictures - GITEA__picture__STORAGE_TYPE=minio - GITEA__picture__MINIO_ENDPOINT=s3storage:8333 - GITEA__picture__MINIO_ACCESS_KEY_ID=op://TerraphimPlatform/gitea-s3/access-key - GITEA__picture__MINIO_SECRET_ACCESS_KEY=op://TerraphimPlatform/gitea-s3/secret-key - GITEA__picture__MINIO_BUCKET=gitea - GITEA__picture__MINIO_USE_SSL=false # S3 storage for LFS - GITEA__lfs__STORAGE_TYPE=minio - GITEA__lfs__MINIO_ENDPOINT=s3storage:8333 - GITEA__lfs__MINIO_ACCESS_KEY_ID=op://TerraphimPlatform/gitea-s3/access-key - GITEA__lfs__MINIO_SECRET_ACCESS_KEY=op://TerraphimPlatform/gitea-s3/secret-key - GITEA__lfs__MINIO_BUCKET=gitea-lfs - GITEA__lfs__MINIO_USE_SSL=false restart: always networks: - gitea volumes: - ./gitea:/data - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro ports: - "127.0.0.1:3000:3000" # HTTP (Caddy reverse proxy only) - "222:22" # SSH (public) depends_on: - db - s3 db: image: postgres:15 restart: always environment: - POSTGRES_USER=gitea - POSTGRES_PASSWORD=op://TerraphimPlatform/gitea-postgres/password - POSTGRES_DB=gitea networks: - gitea volumes: - ./postgres:/var/lib/postgresql/data master: image: chrislusf/seaweedfs command: "master -ip=master -ip.bind=0.0.0.0 -metricsPort=9324" networks: - gitea volume: image: chrislusf/seaweedfs command: 'volume -mserver="master:9333" -ip.bind=0.0.0.0 -port=8080 -metricsPort=9325' depends_on: - master volumes: - ./seaweedfs:/data networks: - gitea filer: image: chrislusf/seaweedfs command: 'filer -master="master:9333" -ip.bind=0.0.0.0 -metricsPort=9326' tty: true stdin_open: true depends_on: - master - volume volumes: - ./seaweedfs_filter:/data networks: - gitea s3: image: chrislusf/seaweedfs container_name: s3storage hostname: s3storage ports: # Bind to Tailscale interface only (not public internet) - "100.106.66.7:8333:8333" - "100.106.66.7:9327:9327" command: 's3 -config=/etc/seaweedfs/s3.json -filer="filer:8888" -ip.bind=0.0.0.0 -metricsPort=9327' depends_on: - master - volume - filer volumes: - ./s3_config.json:/etc/seaweedfs/s3.json networks: - gitea prometheus: image: prom/prometheus:v2.21.0 ports: # Bind to Tailscale interface only - "100.106.66.7:9000:9090" volumes: - ./prometheus:/etc/prometheus command: --web.enable-lifecycle --config.file=/etc/prometheus/prometheus.yml depends_on: - s3 networks: - gitea ``` ### 5.3 SeaweedFS S3 Configuration (s3_config.json.template) ```json { "identities": [ { "name": "anonymous", "actions": ["Admin", "Read", "List", "Write", "Tagging"] }, { "name": "gitea", "credentials": [ { "accessKey": "op://TerraphimPlatform/gitea-s3/access-key", "secretKey": "op://TerraphimPlatform/gitea-s3/secret-key" } ], "actions": ["Admin", "Read", "List", "Tagging", "Write"] } ] } ``` Process with: ```bash op inject --in-file s3_config.json.template --out-file s3_config.json ``` ### 5.4 Caddy Configuration File: `/etc/caddy/Caddyfile` ``` git.terraphim.cloud { reverse_proxy localhost:3000 log { output file /var/log/caddy/gitea-access.log format json } } ``` Reload Caddy: ```bash sudo systemctl reload caddy ``` ### 5.5 Deployment Steps ```bash # 1. Navigate to infrastructure directory cd /path/to/gitea-infrastructure # 2. Load 1Password service account source ~/op_zesticai_non_prod.sh # 3. Inject secrets into configuration files op inject --in-file docker-compose.yml.template --out-file docker-compose.yml op inject --in-file s3_config.json.template --out-file s3_config.json # 4. Start services docker compose up -d # 5. Verify services docker compose ps # Expected: gitea, db, master, volume, filer, s3storage, prometheus all "Up" # 6. Verify Gitea responds curl -s https://git.terraphim.cloud/api/v1/version | jq # Expected: {"version": "1.22.6"} ``` --- ## 6. Agent Skill: Shell Helpers All helpers output JSON for agent parsing. All use 1Password for token retrieval. ### 6.1 Base API Helper ```bash GITEA_URL="${GITEA_URL:-https://git.terraphim.cloud}" GITEA_OWNER="${GITEA_OWNER:-terraphim}" GITEA_REPO="${GITEA_REPO:-agent-tasks}" gitea_api() { local method="$1" endpoint="$2" data="${3:-}" local args=(-s -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json") if [ -n "$data" ]; then args+=(-X "$method" -d "$data") else args+=(-X "$method") fi curl "${args[@]}" "$GITEA_URL/api/v1$endpoint" } ``` ### 6.2 Label Swap Pattern (CRITICAL) **Problem**: The Gitea UI enforces scoped label exclusivity (only one `workflow:*` label allowed per issue). But the API `POST /repos/{owner}/{repo}/issues/{index}/labels` endpoint just **appends** labels. If you POST `workflow:in-progress` to an issue that already has `workflow:ready`, you'll end up with BOTH labels, violating exclusivity. **Solution**: Use PUT with full label replacement. Filter out all labels from the target scope, then add the new label. ```bash gitea_swap_workflow() { local index="$1" new_label="$2" owner="${3:-$GITEA_OWNER}" repo="${4:-$GITEA_REPO}" local repo_labels issue_labels new_id kept_ids all_ids # Get ID of the new workflow label repo_labels=$(gitea_api GET "/repos/$owner/$repo/labels") new_id=$(echo "$repo_labels" | jq -r --arg n "$new_label" '.[] | select(.name==$n) | .id') # Get current issue labels, filter out all workflow:* labels issue_labels=$(gitea_api GET "/repos/$owner/$repo/issues/$index") kept_ids=$(echo "$issue_labels" | jq '[.labels[] | select(.name | startswith("workflow:") | not) | .id]') # Combine kept labels + new workflow label all_ids=$(echo "$kept_ids" | jq --argjson nid "$new_id" '. + [$nid]') # Replace all labels atomically (PUT, not POST) gitea_api PUT "/repos/$owner/$repo/issues/$index/labels" "{\"labels\": $all_ids}" } ``` ### 6.3 Workflow Helpers **Mark task ready**: ```bash gitea_ready() { local index="$1" owner="${2:-$GITEA_OWNER}" repo="${3:-$GITEA_REPO}" gitea_swap_workflow "$index" "workflow:ready" "$owner" "$repo" } ``` **Claim task** (mark in-progress, assign to user): ```bash gitea_claim() { local index="$1" user="${2:-}" owner="${3:-$GITEA_OWNER}" repo="${4:-$GITEA_REPO}" gitea_swap_workflow "$index" "workflow:in-progress" "$owner" "$repo" if [ -n "$user" ]; then gitea_api PATCH "/repos/$owner/$repo/issues/$index" "{\"assignees\": [\"$user\"]}" fi } ``` **Close task**: ```bash gitea_close_task() { local index="$1" comment="${2:-}" owner="${3:-$GITEA_OWNER}" repo="${4:-$GITEA_REPO}" gitea_swap_workflow "$index" "workflow:done" "$owner" "$repo" gitea_api PATCH "/repos/$owner/$repo/issues/$index" '{"state": "closed"}' if [ -n "$comment" ]; then gitea_api POST "/repos/$owner/$repo/issues/$index/comments" \ "$(jq -n --arg b "$comment" '{body: $b}')" fi } ``` ### 6.4 Project Management Helpers **Create project** (milestone): ```bash gitea_create_project() { local title="$1" desc="${2:-}" due="${3:-}" local owner="${4:-$GITEA_OWNER}" repo="${5:-$GITEA_REPO}" local data data=$(jq -n --arg t "$title" --arg d "$desc" --arg due "$due" \ '{title: $t, description: $d} + (if $due != "" then {due_on: $due} else {} end)') gitea_api POST "/repos/$owner/$repo/milestones" "$data" } # Example: gitea_create_project "Sprint 2026-W08" "Weekly sprint tasks" "2026-02-28T00:00:00Z" ``` **Get project progress**: ```bash gitea_progress() { local milestone="${1:-}" owner="${2:-$GITEA_OWNER}" repo="${3:-$GITEA_REPO}" if [ -n "$milestone" ]; then gitea_api GET "/repos/$owner/$repo/milestones/$milestone" | jq '{ milestone: .title, open: .open_issues, closed: .closed_issues, total: (.open_issues + .closed_issues), percent_complete: (if (.open_issues + .closed_issues) > 0 then (.closed_issues * 100 / (.open_issues + .closed_issues)) else 0 end), due_on: .due_on, state: .state }' else gitea_api GET "/repos/$owner/$repo/milestones?state=open" | \ jq '[.[] | {id, title, open: .open_issues, closed: .closed_issues, pct: (if (.open_issues + .closed_issues) > 0 then (.closed_issues * 100 / (.open_issues + .closed_issues)) else 0 end)}]' fi } # Example: gitea_progress 1 # Show progress for milestone ID 1 gitea_progress # List all open milestones with progress ``` **Create task** (issue): ```bash gitea_create_task() { local title="$1" body="$2" milestone="$3" local labels="${4:-}" owner="${5:-$GITEA_OWNER}" repo="${6:-$GITEA_REPO}" local data data=$(jq -n --arg t "$title" --arg b "$body" --argjson m "$milestone" \ '{title: $t, body: $b, milestone: $m}') if [ -n "$labels" ]; then data=$(echo "$data" | jq --argjson l "[$labels]" '. + {labels: $l}') fi gitea_api POST "/repos/$owner/$repo/issues" "$data" } # Example: # Create task in milestone 1 with workflow:ready (id=4) and type:task (id=14) gitea_create_task "Implement auth" "Add OAuth2 flow" 1 "4,14" ``` --- ## 7. The Three API Gotchas These were discovered through live testing and cost significant debugging time. ### 7.1 Gotcha 1: Scoped Label Exclusivity is UI-Only **What we expected**: POST to `/repos/{owner}/{repo}/issues/{index}/labels` with `workflow:in-progress` would replace `workflow:ready` automatically because both labels have `exclusive: true` and scope `workflow`. **What actually happens**: The API just appends the label. The issue ends up with BOTH `workflow:ready` AND `workflow:in-progress`, violating the exclusivity constraint. **Why**: Label exclusivity is enforced in the Gitea UI but NOT in the REST API. The API treats labels as a simple list. **Solution**: Use PUT `/repos/{owner}/{repo}/issues/{index}/labels` with the full label set (see `gitea_swap_workflow` pattern in section 6.2). **Verified test**: ```bash # Issue initially has workflow:ready (id=4) and type:task (id=14) curl -s -H "Authorization: token $GITEA_TOKEN" \ "https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/1" | \ jq '[.labels[].name]' # ["workflow:ready", "type:task"] # WRONG: POST appends workflow:in-progress (id=5) curl -s -X POST \ -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ -d '{"labels": [5]}' \ "https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/1/labels" | \ jq '[.labels[].name]' # ["workflow:ready", "workflow:in-progress", "type:task"] <-- BOTH workflow labels! # RIGHT: PUT replaces all labels curl -s -X PUT \ -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ -d '{"labels": [5, 14]}' \ "https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/1/labels" | \ jq '[.labels[].name]' # ["workflow:in-progress", "type:task"] <-- workflow:ready removed ``` ### 7.2 Gotcha 2: Assignee Endpoint Does Not Exist **What the design document specified**: ```bash gitea_api POST "/repos/$owner/$repo/issues/$index/assignees" \ "{\"assignees\": [\"$user\"]}" ``` **What actually happens**: ```bash curl -s -X POST \ -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ -d '{"assignees": ["root"]}' \ "https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/1/assignees" # {"message":"Not Found","url":"https://git.terraphim.cloud/api/swagger"} # HTTP 404 ``` **Why**: Gitea 1.22.6 does not have a dedicated `/assignees` endpoint for issues. The GitHub API has this endpoint, but Gitea does not. **Solution**: Use `PATCH /repos/{owner}/{repo}/issues/{index}` with the `assignees` field: ```bash # CORRECT approach curl -s -X PATCH \ -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ -d '{"assignees": ["root"]}' \ "https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/1" | \ jq '.assignees[].login' # "root" ``` Updated helper: ```bash gitea_claim() { local index="$1" user="${2:-}" owner="${3:-$GITEA_OWNER}" repo="${4:-$GITEA_REPO}" gitea_swap_workflow "$index" "workflow:in-progress" "$owner" "$repo" if [ -n "$user" ]; then # Use PATCH on issue, not POST to /assignees gitea_api PATCH "/repos/$owner/$repo/issues/$index" "{\"assignees\": [\"$user\"]}" fi } ``` ### 7.3 Gotcha 3: Issue Dependencies Use IssueMeta Format **What we tried first** (based on integer ID pattern from other APIs): ```bash curl -s -X POST \ -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ -d '{"depends_on_id": 2}' \ "https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/3/dependencies" # {"message":"repository does not exist [id: 0]"} ``` **Why this failed**: The Gitea API uses `IssueMeta` format for cross-repo dependencies. The body must specify owner, repo, and index (issue number), not just an integer ID. **Correct format**: ```bash curl -s -X POST \ -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ -d '{"owner": "terraphim", "repo": "test", "index": 2}' \ "https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/3/dependencies" | \ jq '{number, title}' # {"number": 2, "title": "Setup authentication"} ``` This format supports **cross-repository dependencies**: issue 3 in `terraphim/test` can depend on issue 5 in `terraphim/backend`: ```bash curl -s -X POST \ -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ -d '{"owner": "terraphim", "repo": "backend", "index": 5}' \ "https://git.terraphim.cloud/api/v1/repos/terraphim/test/issues/3/dependencies" ``` **Helper function**: ```bash gitea_add_dependency() { local issue="$1" depends_on_issue="$2" local owner="${3:-$GITEA_OWNER}" repo="${4:-$GITEA_REPO}" local dep_owner="${5:-$owner}" dep_repo="${6:-$repo}" gitea_api POST "/repos/$owner/$repo/issues/$issue/dependencies" \ "$(jq -n --arg o "$dep_owner" --arg r "$dep_repo" --argjson i "$depends_on_issue" \ '{owner: $o, repo: $r, index: $i}')" } # Example: Issue #3 depends on issue #2 (same repo) gitea_add_dependency 3 2 # Example: Issue #3 in terraphim/frontend depends on issue #5 in terraphim/backend gitea_add_dependency 3 5 terraphim frontend terraphim backend ``` --- ## 8. Command Comparison Table | Operation | bd (beads) | br (beads_rust) | Gitea | |-----------|-----------|----------------|-------| | Find ready work | `bd ready` | `br ready` | `GET /issues?labels=workflow:ready&state=open` | | Claim task | `bd update ID --status in_progress` | `br update ID --status in_progress` | `gitea_claim ISSUE_NUMBER USERNAME` | | Complete task | `bd close ID` | `br close ID` | `gitea_close_task ISSUE_NUMBER "Done"` | | Create task | `bd create --title="..." --type=task` | `br create "..." -t task` | `gitea_create_task TITLE BODY MILESTONE_ID` | | Sync state | `bd sync` (auto-commits!) | `br sync --flush-only` + git add + git commit | N/A (state lives in Gitea server) | | View progress | `bd stats` | `br stats` | `gitea_progress MILESTONE_ID` | | Dependencies | `bd dep add ID1 ID2` | `br dep add ID1 ID2` | `POST /issues/3/dependencies {"owner","repo","index":2}` | | Cross-repo visibility | None | None | Built-in (all repos on same instance) | | Web UI | None | None | Built-in at git.terraphim.cloud | | Time tracking | None | None | Built-in (stopwatch API) | | Offline mode | Yes (local SQLite) | Yes (local SQLite) | No (requires network) | --- ## 9. What We Gained and Lost ### 9.1 Gained **Web UI for human visibility**: No more "what are the agents doing?" Humans can visit https://git.terraphim.cloud/terraphim/agent-tasks/issues to see all tasks, filter by label, view progress, and read comments. **Cross-repository task visibility**: A single `terraphim/agent-tasks` repository serves as the central task tracker. All agents create issues here regardless of which code repository they're working in. br required separate `.beads/` directories per repo with no coordination. **No binary installation**: All operations use `curl` and `jq`, which are already installed on most systems. No need to install `br` binary or keep it updated. **Built-in time tracking**: Gitea has a stopwatch API for tracking time spent on tasks. Start/stop times are recorded per user. **Issue comments with markdown**: Agents can post progress updates, link to commits, and collaborate via comments. Visible in the UI and accessible via API. **Issue dependencies with API support**: `POST /issues/{index}/dependencies` with `IssueMeta` format. Supports cross-repo dependencies (task in repo A can depend on task in repo B). **Milestone-based progress tracking**: Each milestone tracks `open_issues` and `closed_issues` counts. Percentage completion calculated automatically. Visible in UI with progress bar. **Labels visible in UI with color coding**: Workflow state (ready/in-progress/blocked/done) immediately visible via label colors. Priority and type also color-coded. **Integration with existing git workflows**: Issues link to commits, pull requests, branches. Agents can close issues via commit messages (`Fixes #42`). **Audit trail**: All issue updates logged with timestamps and user attribution. Who did what, when. ### 9.2 Lost **File reservations** (Agent Mail feature): Agent Mail provided advisory locks to prevent concurrent file edits. Gitea has no equivalent. Workaround: use issue comments to declare intent ("Working on /src/auth.rs") or rely on git branch locking. **Agent-to-agent messaging**: Agent Mail had inbox/outbox for direct agent communication. Gitea replacement: use issue comments and `@mentions` to notify agents. **Graph-aware triage** (bv's PageRank, betweenness centrality): br had integration with `bv` tool for graph analysis of task dependencies. Gitea provides dependency tracking but no built-in graph algorithms. Would need external analysis tool querying Gitea API. **Local-first operation**: br worked without network access (SQLite + JSONL in `.beads/`). Gitea requires network connectivity to the git.terraphim.cloud instance. **Offline capability**: br could create tasks, update status, and sync later when network available. Gitea requires real-time network access for all operations. **Automatic sync on git commit**: bd (original beads) had git hooks that auto-synced task state on commit. Removed in br due to race conditions, not re-implemented in Gitea approach. --- ## 10. Integration Test Results The test script `test-gitea-skill.sh` validates all operations against the live Gitea instance. No mocks. Creates a timestamped temporary repository, runs all tests, and cleans up. ### 10.1 Test Coverage | Test # | Operation | Assertions | Status | |--------|-----------|-----------|--------| | 1 | Create test repository | 3 | PASS | | 2 | Create labels (via setup-labels.sh) | 3 | PASS | | 3 | Create milestone (project) | 2 | PASS | | 4 | Create issue with milestone | 3 | PASS | | 5 | Mark task ready | 1 | PASS | | 6 | Claim task (in-progress + assignee) | 3 | PASS | | 7 | Check progress (0% complete) | 3 | PASS | | 8 | Close task (done + closed state) | 3 | PASS | | 9 | Check progress (100% complete) | 3 | PASS | | 10 | Delete test repository | 2 | PASS | **Total**: 10 tests, 26 assertions, all passing. ### 10.2 Test Execution ```bash # Prerequisites source ~/op_zesticai_non_prod.sh export GITEA_TOKEN=$(op read "op://TerraphimPlatform/git.terraphim.cloud-admin-token/credential") # Run tests cd /path/to/gitea-infrastructure/.agents/skills/gitea ./test-gitea-skill.sh ``` **Expected output**: ``` ============================================ Gitea Skill Integration Tests ============================================ === Pre-flight Checks === Instance: https://git.terraphim.cloud User: root Test repo: root/skill-test-1708315200 --- Test 1: Create test repository --- [PASS] Repo name matches [PASS] Repo has id [PASS] Owner matches --- Test 2: Create labels via setup-labels.sh --- [PASS] Created 12 labels (expected >= 12) [PASS] workflow:ready is exclusive [PASS] workflow:in-progress is exclusive --- Test 3: Create milestone (project) --- [PASS] Milestone created with id=1 [PASS] Milestone has title --- Test 4: Create issue (task) with milestone --- [PASS] Issue created with number=#1 [PASS] Issue state is open [PASS] Milestone assigned --- Test 5: Mark task ready (workflow:ready) --- [PASS] Issue has workflow:ready label --- Test 6: Claim task (workflow:in-progress) --- [PASS] Issue has workflow:in-progress label [PASS] workflow:ready removed by label swap [PASS] Assignee set --- Test 7: Check project progress (0% complete) --- [PASS] 1 open issue [PASS] 0 closed issues [PASS] Progress is 0% --- Test 8: Close task --- [PASS] Issue state is closed [PASS] Issue has workflow:done label [PASS] workflow:in-progress removed by label swap --- Test 9: Check progress after close (100% complete) --- [PASS] 0 open issues [PASS] 1 closed issue [PASS] Progress is 100% --- Test 10: Delete test repository --- [PASS] Repository deleted (HTTP 204) [PASS] Repository confirmed deleted (HTTP 404) ============================================ Results: 26 passed, 0 failed (26 total) ============================================ ``` Each test run creates a unique repository (e.g., `skill-test-1708315200`) to avoid conflicts with concurrent test runs. ### 10.3 Test Highlights **Test 2** (Create labels) validates: - `setup-labels.sh` creates exactly 12 labels - Labels have `exclusive: true` flag - Idempotent (re-running doesn't create duplicates) **Test 6** (Claim task) validates the label swap pattern: - Sets `workflow:in-progress` label - **Removes** `workflow:ready` label (exclusivity) - Sets assignee via PATCH (not POST /assignees) **Tests 7 and 9** (Progress tracking) validate: - Milestone `open_issues` count updates when issue state changes - Milestone `closed_issues` count updates when issue closed - Percentage calculation: `(closed / (open + closed)) * 100` --- ## 11. Conclusions ### 11.1 Consolidation Benefits Replacing three tools (beads, br, mcp-agent-mail) with a single Gitea instance: - **Reduced operational complexity**: One service to deploy, monitor, and maintain - **Human visibility**: Web UI at git.terraphim.cloud shows all agent activity - **Cross-repo coordination**: Single task repository for all agent work - **No installation required**: All operations via curl and jq - **Better auditing**: Full history of who did what, when, visible in UI and API ### 11.2 Tradeoffs Gained centralization and visibility. Lost offline capability and graph analysis features. The tradeoff is acceptable for our use case because: - Agents run on servers with reliable network connectivity - Graph analysis (PageRank, betweenness) was rarely used - File reservation feature replaced by issue comments declaring intent - Web UI visibility outweighs local-first benefits ### 11.3 Implementation Complexity **Low**. The entire migration required: - 1 Docker Compose template (5.2) - 1 SeaweedFS S3 config (5.3) - 1 label setup script (3.3) - 8 shell helper functions (6.2-6.4) - 1 integration test script (10.1) All commands verified working on 2026-02-18. No custom code, no plugins, no database migrations. ### 11.4 Reproducibility This article is self-contained. Someone with: - A Linux server - Docker and Docker Compose - 1Password CLI - curl and jq ...can reproduce the entire setup by following sections 4 (1Password), 5 (Infrastructure), and 6 (Shell Helpers) in order. ### 11.5 Lessons Learned **API documentation is not always accurate**. Gitea's Swagger docs show project board endpoints, but they return HTTP 404 in version 1.22.6. Always test against a live instance. **Label exclusivity is UI-only**. Scoped labels with `exclusive: true` are enforced in the UI but not the API. Use PUT with full label replacement. **GitHub API compatibility is incomplete**. Gitea aims for GitHub API compatibility but has gaps (e.g., no `/issues/{index}/assignees` endpoint). Test all endpoints. **1Password secret injection works well**. The `op inject` pattern keeps secrets out of version control while maintaining reproducibility. --- ## Appendix A: Quick Reference ### A.1 Essential Environment Variables ```bash source ~/op_zesticai_non_prod.sh export GITEA_TOKEN=$(op read "op://TerraphimPlatform/git.terraphim.cloud-admin-token/credential") export GITEA_URL="https://git.terraphim.cloud" export GITEA_OWNER="terraphim" export GITEA_REPO="agent-tasks" ``` ### A.2 Common Operations ```bash # List ready tasks curl -s -H "Authorization: token $GITEA_TOKEN" \ "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/issues?labels=workflow:ready&state=open" | \ jq '.[] | {number, title}' # Claim task #5 as user root gitea_claim 5 root # Check progress for milestone #1 gitea_progress 1 # Close task #5 with comment gitea_close_task 5 "Completed authentication module" ``` ### A.3 File Locations | File | Path | |------|------| | Docker Compose template | `/path/to/gitea-infrastructure/docker-compose.yml.template` | | S3 config template | `/path/to/gitea-infrastructure/s3_config.json.template` | | Label setup script | `/path/to/gitea-infrastructure/.agents/skills/gitea/setup-labels.sh` | | Integration tests | `/path/to/gitea-infrastructure/.agents/skills/gitea/test-gitea-skill.sh` | | Agent skill document | `/path/to/gitea-infrastructure/.agents/skills/gitea/SKILL.md` | | Caddy config | `/etc/caddy/Caddyfile` | ### A.4 Service URLs | Service | URL | Access | |---------|-----|--------| | Gitea Web UI | https://git.terraphim.cloud | Public (HTTPS via Caddy) | | Gitea SSH | git.terraphim.cloud:222 | Public | | Gitea API | https://git.terraphim.cloud/api/v1 | Public (requires token) | | SeaweedFS S3 | http://100.106.66.7:8333 | Tailscale only | | Prometheus | http://100.106.66.7:9000 | Tailscale only | ### A.5 1Password Items Reference | Item Name | Vault | Field | Purpose | |-----------|-------|-------|---------| | git.terraphim.cloud-admin-token | TerraphimPlatform | credential | Gitea API authentication | | gitea-postgres | TerraphimPlatform | password | PostgreSQL database password | | gitea-s3 | TerraphimPlatform | access-key | SeaweedFS S3 access key | | gitea-s3 | TerraphimPlatform | secret-key | SeaweedFS S3 secret key | --- ## Appendix B: API Endpoint Reference All endpoints relative to `https://git.terraphim.cloud/api/v1`. ### B.1 Authentication ```bash # Verify token (returns user info) GET /user ``` ### B.2 Milestones (Projects) ```bash # Create milestone POST /repos/{owner}/{repo}/milestones Body: {"title": "Sprint 1", "description": "...", "due_on": "2026-03-01T00:00:00Z"} # List milestones GET /repos/{owner}/{repo}/milestones?state=open # Get milestone GET /repos/{owner}/{repo}/milestones/{id} # Update milestone PATCH /repos/{owner}/{repo}/milestones/{id} Body: {"title": "...", "state": "closed"} # Delete milestone DELETE /repos/{owner}/{repo}/milestones/{id} ``` ### B.3 Issues (Tasks) ```bash # Create issue POST /repos/{owner}/{repo}/issues Body: {"title": "...", "body": "...", "milestone": 1, "labels": [4, 14]} # List issues GET /repos/{owner}/{repo}/issues?state=open&labels=workflow:ready&milestone=Sprint+1 # Get issue GET /repos/{owner}/{repo}/issues/{index} # Update issue PATCH /repos/{owner}/{repo}/issues/{index} Body: {"state": "closed", "assignees": ["root"]} # Add comment POST /repos/{owner}/{repo}/issues/{index}/comments Body: {"body": "Progress update..."} ``` ### B.4 Labels ```bash # Create label POST /repos/{owner}/{repo}/labels Body: {"name": "workflow:ready", "color": "0e8a16", "description": "...", "exclusive": true} # List labels GET /repos/{owner}/{repo}/labels # Replace issue labels (atomic swap) PUT /repos/{owner}/{repo}/issues/{index}/labels Body: {"labels": [4, 5, 14]} ``` ### B.5 Dependencies ```bash # Add dependency (issue A depends on issue B) POST /repos/{owner}/{repo}/issues/{index}/dependencies Body: {"owner": "terraphim", "repo": "agent-tasks", "index": 2} # List dependencies (what blocks this issue?) GET /repos/{owner}/{repo}/issues/{index}/dependencies # List blocks (what does this issue block?) GET /repos/{owner}/{repo}/issues/{index}/blocks # Remove dependency DELETE /repos/{owner}/{repo}/issues/{index}/dependencies Body: {"owner": "terraphim", "repo": "agent-tasks", "index": 2} ``` --- **End of Article** All commands verified working as of 2026-02-18 against Gitea 1.22.6 running at git.terraphim.cloud.