feat: integrate tea CLI for forge issue/label/comment operations (#666)

- Add lib/tea-helpers.sh with tea_file_issue, tea_relabel, tea_comment,
  tea_close — thin wrappers preserving secret scanning on write ops
- Add tea 0.9.2 binary to docker/agents/Dockerfile
- Configure tea login in docker/agents/entrypoint.sh from FORGE_TOKEN/FORGE_URL
- Derive TEA_LOGIN in lib/env.sh (codeberg vs local forgejo)
- Source tea-helpers.sh conditionally when tea binary is available
- Migrate predictor formula from inline curl to tea CLI commands
- Register tea-helpers.sh in smoke test function resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-25 12:20:15 +00:00
parent 5e66ba7d12
commit 44cbbbde62
7 changed files with 137 additions and 19 deletions

View file

@ -98,6 +98,7 @@ echo "=== 2/2 Function resolution ==="
# 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
# lib/file-action-issue.sh — sourced by gardener-run.sh (file_action_issue) # lib/file-action-issue.sh — sourced by gardener-run.sh (file_action_issue)
# lib/secret-scan.sh — sourced by file-action-issue.sh, phase-handler.sh (scan_for_secrets, redact_secrets) # lib/secret-scan.sh — sourced by file-action-issue.sh, phase-handler.sh (scan_for_secrets, redact_secrets)
# lib/tea-helpers.sh — sourced by env.sh when tea is available (tea_file_issue, tea_relabel, etc.)
# lib/formula-session.sh — sourced by formula-driven agents (acquire_cron_lock, run_formula_and_monitor, etc.) # lib/formula-session.sh — sourced by formula-driven agents (acquire_cron_lock, run_formula_and_monitor, etc.)
# lib/mirrors.sh — sourced by merge sites (mirror_push) # lib/mirrors.sh — sourced by merge sites (mirror_push)
# lib/guard.sh — sourced by all cron entry points (check_active) # lib/guard.sh — sourced by all cron entry points (check_active)
@ -111,7 +112,7 @@ echo "=== 2/2 Function resolution ==="
# If a new lib file is added and sourced by agents, add it to LIB_FUNS below # If a new lib file is added and sourced by agents, add it to LIB_FUNS below
# and add a check_script call for it in the lib files section further down. # and add a check_script call for it in the lib files section further down.
LIB_FUNS=$( LIB_FUNS=$(
for f in lib/agent-session.sh lib/env.sh lib/ci-helpers.sh lib/load-project.sh lib/secret-scan.sh lib/file-action-issue.sh lib/formula-session.sh lib/mirrors.sh lib/guard.sh; do for f in lib/agent-session.sh lib/env.sh lib/ci-helpers.sh lib/load-project.sh lib/secret-scan.sh lib/file-action-issue.sh lib/tea-helpers.sh lib/formula-session.sh lib/mirrors.sh lib/guard.sh; do
if [ -f "$f" ]; then get_fns "$f"; fi if [ -f "$f" ]; then get_fns "$f"; fi
done | sort -u done | sort -u
) )
@ -124,7 +125,7 @@ KNOWN_CMDS=(
false 'fi' find flock for getopts git grep gzip gunzip head hash false 'fi' find flock for getopts git grep gzip gunzip head hash
'if' jq kill local ln ls mapfile mkdir mktemp mv nc pgrep printf 'if' jq kill local ln ls mapfile mkdir mktemp mv nc pgrep printf
python3 python read readarray return rm sed set sh shift sleep python3 python read readarray return rm sed set sh shift sleep
sort source stat tail tar test 'then' tmux touch tr trap true type sort source stat tail tar tea test 'then' tmux touch tr trap true type
unset until wait wc while which xargs unset until wait wc while which xargs
) )
@ -179,6 +180,7 @@ check_script lib/agent-session.sh
check_script lib/ci-helpers.sh check_script lib/ci-helpers.sh
check_script lib/secret-scan.sh check_script lib/secret-scan.sh
check_script lib/file-action-issue.sh lib/secret-scan.sh check_script lib/file-action-issue.sh lib/secret-scan.sh
check_script lib/tea-helpers.sh lib/secret-scan.sh
check_script lib/formula-session.sh lib/agent-session.sh check_script lib/formula-session.sh lib/agent-session.sh
check_script lib/load-project.sh check_script lib/load-project.sh
check_script lib/mirrors.sh check_script lib/mirrors.sh

View file

@ -4,6 +4,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
bash curl git jq tmux cron python3 openssh-client ca-certificates \ bash curl git jq tmux cron python3 openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# tea CLI — official Gitea/Forgejo CLI for issue/label/comment operations
RUN curl -sL https://dl.gitea.com/tea/0.9.2/tea-0.9.2-linux-amd64 -o /usr/local/bin/tea \
&& chmod +x /usr/local/bin/tea
# Claude CLI is mounted from the host via docker-compose volume. # Claude CLI is mounted from the host via docker-compose volume.
# No internet access to cli.anthropic.com required at build time. # No internet access to cli.anthropic.com required at build time.

View file

@ -57,6 +57,24 @@ log "Claude CLI: $(claude --version 2>&1 || true)"
install_project_crons install_project_crons
# Configure tea CLI login for forge operations (runs as agent user).
# tea stores config in ~/.config/tea/ — persistent across container restarts
# only if that directory is on a mounted volume.
if command -v tea &>/dev/null && [ -n "${FORGE_TOKEN:-}" ] && [ -n "${FORGE_URL:-}" ]; then
local_tea_login="forgejo"
case "$FORGE_URL" in
*codeberg.org*) local_tea_login="codeberg" ;;
esac
su -s /bin/bash agent -c "tea login add \
--name '${local_tea_login}' \
--url '${FORGE_URL}' \
--token '${FORGE_TOKEN}' \
--no-version-check 2>/dev/null || true"
log "tea login configured: ${local_tea_login}${FORGE_URL}"
else
log "tea login: skipped (tea not found or FORGE_TOKEN/FORGE_URL not set)"
fi
# Start matrix listener in background (if configured) # Start matrix listener in background (if configured)
if [ -n "${MATRIX_TOKEN:-}" ] && [ -n "${MATRIX_ROOM_ID:-}" ]; then if [ -n "${MATRIX_TOKEN:-}" ] && [ -n "${MATRIX_ROOM_ID:-}" ]; then
log "Starting matrix listener in background" log "Starting matrix listener in background"

View file

@ -148,27 +148,27 @@ For each weakness you identify, choose one:
Valid outcome. Not every run needs to produce a prediction. Valid outcome. Not every run needs to produce a prediction.
But if you skip, write a brief note to your scratch file about why. But if you skip, write a brief note to your scratch file about why.
## Filing ## Filing (use tea CLI — labels by name, no ID lookup needed)
1. Look up label IDs: tea is pre-configured with login "$TEA_LOGIN" and repo "$FORGE_REPO".
curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$FORGE_API/labels" | jq '[.[] | select(.name | startswith("prediction")) | {name, id}]'
curl -sf -H "Authorization: token $FORGE_TOKEN" \
"$FORGE_API/labels" | jq '.[] | select(.name == "action") | .id'
2. File predictions: 1. File predictions (labels by name, no ID lookup):
curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \ tea issues create --login "$TEA_LOGIN" --repo "$FORGE_REPO" \
-H "Content-Type: application/json" \ --title "<title>" --body "<body>" --labels "prediction/unreviewed"
"$FORGE_API/issues" \
-d '{"title":"<title>","body":"<body>","labels":[<prediction_unreviewed_id>]}'
3. File action dispatches (if exploiting): 2. File action dispatches (if exploiting):
curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \ tea issues create --login "$TEA_LOGIN" --repo "$FORGE_REPO" \
-H "Content-Type: application/json" \ --title "action: test prediction #NNN — <formula> <focus>" \
"$FORGE_API/issues" \ --body "<body>" --labels "action"
-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 3. Close superseded predictions:
tea issues close <number> --login "$TEA_LOGIN" --repo "$FORGE_REPO"
4. Add a comment when closing (optional):
tea comment create <number> --login "$TEA_LOGIN" --repo "$FORGE_REPO" \
--body "Superseded by #NNN"
5. Do NOT duplicate existing open predictions. If your theory matches
an open prediction/unreviewed or prediction/backlog issue, skip it. an open prediction/unreviewed or prediction/backlog issue, skip it.
## Rules ## Rules

View file

@ -18,4 +18,5 @@ sourced as needed.
| `lib/build-graph.py` | Python tool: parses VISION.md, prerequisite-tree.md, AGENTS.md, formulas/*.toml, evidence/, and forge issues/labels into a NetworkX DiGraph. Runs structural analyses (orphaned objectives, stale prerequisites, thin evidence, circular deps) and outputs a JSON report. Used by `review-pr.sh` (per-PR changed-file analysis) and `predictor-run.sh` (full-project analysis) to provide structural context to Claude. | review-pr.sh, predictor-run.sh | | `lib/build-graph.py` | Python tool: parses VISION.md, prerequisite-tree.md, AGENTS.md, formulas/*.toml, evidence/, and forge issues/labels into a NetworkX DiGraph. Runs structural analyses (orphaned objectives, stale prerequisites, thin evidence, circular deps) and outputs a JSON report. Used by `review-pr.sh` (per-PR changed-file analysis) and `predictor-run.sh` (full-project analysis) to provide structural context to Claude. | review-pr.sh, predictor-run.sh |
| `lib/secret-scan.sh` | `scan_for_secrets()` — detects potential secrets (API keys, bearer tokens, private keys, URLs with embedded credentials) in text; returns 1 if secrets found. `redact_secrets()` — replaces detected secret patterns with `[REDACTED]`. | file-action-issue.sh, phase-handler.sh | | `lib/secret-scan.sh` | `scan_for_secrets()` — detects potential secrets (API keys, bearer tokens, private keys, URLs with embedded credentials) in text; returns 1 if secrets found. `redact_secrets()` — replaces detected secret patterns with `[REDACTED]`. | file-action-issue.sh, phase-handler.sh |
| `lib/file-action-issue.sh` | `file_action_issue()` — dedup check, secret scan, label lookup, and issue creation for formula-driven cron wrappers. Sets `FILED_ISSUE_NUM` on success. Returns 4 if secrets detected in body. | (available for future use) | | `lib/file-action-issue.sh` | `file_action_issue()` — dedup check, secret scan, label lookup, and issue creation for formula-driven cron wrappers. Sets `FILED_ISSUE_NUM` on success. Returns 4 if secrets detected in body. | (available for future use) |
| `lib/tea-helpers.sh` | `tea_file_issue(title, body, labels...)` — create issue via tea CLI with secret scanning; sets `FILED_ISSUE_NUM`. `tea_relabel(issue_num, labels...)` — replace labels. `tea_comment(issue_num, body)` — add comment with secret scanning. `tea_close(issue_num)` — close issue. All use `TEA_LOGIN` and `FORGE_REPO` from env.sh. Labels by name (no ID lookup). Sourced by env.sh when `tea` binary is available. | env.sh (conditional) |
| `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`, `write_compact_context()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. Also installs a SessionStart hook (matcher `compact`) that re-injects phase protocol instructions after context compaction — callers write the context file via `write_compact_context(phase_file, content)`, and the hook (`on-compact-reinject.sh`) outputs the file content to stdout so Claude retains critical instructions. When `MATRIX_THREAD_ID` is exported, also installs a Stop hook (`on-stop-matrix.sh`) that streams each Claude response to the Matrix thread. When `phase_file` is set, passes it to the idle stop hook (`on-idle-stop.sh`) so the hook can **nudge Claude** (up to 2 times) if Claude returns to the prompt without writing to the phase file — the hook injects a tmux reminder asking Claude to signal PHASE:done or PHASE:awaiting_ci. The PreToolUse guard hook (`on-pretooluse-guard.sh`) receives the session name as a third argument — formula agents (`gardener-*`, `planner-*`, `predictor-*`, `supervisor-*`) are identified this way and allowed to access `FACTORY_ROOT` from worktrees (they need env.sh, AGENTS.md, formulas/, lib/). `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `>` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or `PHASE:escalate` / other `PHASE:*` string. **Unified escalation**: `PHASE:escalate` is the signal that a session needs human input (renamed from `PHASE:needs_human`). **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh, action-agent.sh | | `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`, `write_compact_context()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. Also installs a SessionStart hook (matcher `compact`) that re-injects phase protocol instructions after context compaction — callers write the context file via `write_compact_context(phase_file, content)`, and the hook (`on-compact-reinject.sh`) outputs the file content to stdout so Claude retains critical instructions. When `MATRIX_THREAD_ID` is exported, also installs a Stop hook (`on-stop-matrix.sh`) that streams each Claude response to the Matrix thread. When `phase_file` is set, passes it to the idle stop hook (`on-idle-stop.sh`) so the hook can **nudge Claude** (up to 2 times) if Claude returns to the prompt without writing to the phase file — the hook injects a tmux reminder asking Claude to signal PHASE:done or PHASE:awaiting_ci. The PreToolUse guard hook (`on-pretooluse-guard.sh`) receives the session name as a third argument — formula agents (`gardener-*`, `planner-*`, `predictor-*`, `supervisor-*`) are identified this way and allowed to access `FACTORY_ROOT` from worktrees (they need env.sh, AGENTS.md, formulas/, lib/). `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `>` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or `PHASE:escalate` / other `PHASE:*` string. **Unified escalation**: `PHASE:escalate` is the signal that a session needs human input (renamed from `PHASE:needs_human`). **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh, action-agent.sh |

View file

@ -65,6 +65,15 @@ export FORGE_API="${FORGE_API:-${FORGE_URL}/api/v1/repos/${FORGE_REPO}}"
export FORGE_WEB="${FORGE_WEB:-${FORGE_URL}/${FORGE_REPO}}" export FORGE_WEB="${FORGE_WEB:-${FORGE_URL}/${FORGE_REPO}}"
export CODEBERG_API="${FORGE_API}" # backwards compat export CODEBERG_API="${FORGE_API}" # backwards compat
export CODEBERG_WEB="${FORGE_WEB}" # backwards compat export CODEBERG_WEB="${FORGE_WEB}" # backwards compat
# tea CLI login name: derived from FORGE_URL (codeberg vs local forgejo)
if [ -z "${TEA_LOGIN:-}" ]; then
case "${FORGE_URL}" in
*codeberg.org*) TEA_LOGIN="codeberg" ;;
*) TEA_LOGIN="forgejo" ;;
esac
fi
export TEA_LOGIN
export PROJECT_NAME="${PROJECT_NAME:-${FORGE_REPO##*/}}" 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}"
@ -218,3 +227,9 @@ matrix_send_ctx() {
printf '%s' "$event_id" printf '%s' "$event_id"
fi fi
} }
# Source tea helpers (available when tea binary is installed)
if command -v tea &>/dev/null; then
# shellcheck source=tea-helpers.sh
source "$(dirname "${BASH_SOURCE[0]}")/tea-helpers.sh"
fi

78
lib/tea-helpers.sh Normal file
View file

@ -0,0 +1,78 @@
#!/usr/bin/env bash
# tea-helpers.sh — Thin wrappers around tea CLI for forge issue operations
#
# Usage: source this file (after env.sh), then call tea_* functions.
# Requires: tea binary in PATH, TEA_LOGIN and FORGE_REPO from env.sh,
# scan_for_secrets from lib/secret-scan.sh
#
# tea_file_issue <title> <body> <labels...>
# Sets FILED_ISSUE_NUM on success.
# Returns: 0=created, 3=API/tea error, 4=secrets detected
#
# tea_relabel <issue_number> <labels...>
# tea_comment <issue_number> <body>
# tea_close <issue_number>
# Load secret scanner
# shellcheck source=secret-scan.sh
source "$(dirname "${BASH_SOURCE[0]}")/secret-scan.sh"
tea_file_issue() {
local title="$1" body="$2"
shift 2
FILED_ISSUE_NUM=""
# Secret scan: reject issue bodies containing embedded secrets
if ! scan_for_secrets "$body"; then
echo "tea-helpers: BLOCKED — issue body contains potential secrets. Use env var references instead." >&2
return 4
fi
# Join remaining args as comma-separated label names
local IFS=','
local labels="$*"
local result
result=$(tea issues create --login "$TEA_LOGIN" --repo "$FORGE_REPO" \
--title "$title" --body "$body" --labels "$labels" \
--output simple 2>&1) || {
echo "tea-helpers: tea issues create failed: ${result}" >&2
return 3
}
# Parse issue number from tea output (e.g. "#42 Title")
FILED_ISSUE_NUM=$(printf '%s' "$result" | grep -oE '#[0-9]+' | head -1 | tr -d '#')
if [ -z "$FILED_ISSUE_NUM" ]; then
# Fallback: extract any number
FILED_ISSUE_NUM=$(printf '%s' "$result" | grep -oE '[0-9]+' | head -1)
fi
}
tea_relabel() {
local issue_num="$1"
shift
local IFS=','
local labels="$*"
tea issues labels "$issue_num" --login "$TEA_LOGIN" --repo "$FORGE_REPO" \
--labels "$labels"
}
tea_comment() {
local issue_num="$1" body="$2"
# Secret scan: reject comment bodies containing embedded secrets
if ! scan_for_secrets "$body"; then
echo "tea-helpers: BLOCKED — comment body contains potential secrets. Use env var references instead." >&2
return 4
fi
tea comment create "$issue_num" --login "$TEA_LOGIN" --repo "$FORGE_REPO" \
--body "$body"
}
tea_close() {
local issue_num="$1"
tea issues close "$issue_num" --login "$TEA_LOGIN" --repo "$FORGE_REPO"
}