Files
gitea-infrastructure/ARTICLE-agent-task-consolidation.md
Alex Mikhalev 2b5ab840f7 docs: Add articles comparing beads/br/agent-mail with Gitea replacement
Two complementary articles:
- ARTICLE-agent-task-management.md: CTO voice, ~2800 words, narrative style
- ARTICLE-agent-task-consolidation.md: Technical reference, ~6000 words, full configs

Both cover: beads (Go) vs br (Rust) vs Agent Mail vs Gitea with 12 labels,
1Password integration, SeaweedFS S3 storage, Docker Compose deployment,
and the three API gotchas (label swap, assignees, dependencies).
All commands verified against live Gitea 1.22.6.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:11:21 +01:00

45 KiB

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:

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):

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:

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:

# 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):

# 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:

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):

#!/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:

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)

# 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:

# 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.

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)

{
  "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:

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:

sudo systemctl reload caddy

5.5 Deployment Steps

# 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

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.

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:

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):

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:

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):

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:

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):

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:

# 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:

gitea_api POST "/repos/$owner/$repo/issues/$index/assignees" \
  "{\"assignees\": [\"$user\"]}"

What actually happens:

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:

# 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:

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):

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:

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:

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:

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

# 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

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

# 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

# Verify token (returns user info)
GET /user

B.2 Milestones (Projects)

# 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)

# 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

# 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

# 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.