diff --git a/.woodpecker/agent-smoke.sh b/.woodpecker/agent-smoke.sh
index 89cb74e..c280006 100644
--- a/.woodpecker/agent-smoke.sh
+++ b/.woodpecker/agent-smoke.sh
@@ -103,6 +103,7 @@ echo "=== 2/2 Function resolution ==="
# lib/guard.sh — sourced by all cron entry points (check_active)
#
# Excluded — not sourced inline by agents:
+# lib/tea-helpers.sh — sourced conditionally by env.sh (tea_file_issue, etc.); checked standalone below
# lib/ci-debug.sh — standalone CLI tool, run directly (not sourced)
# lib/matrix_listener.sh — standalone systemd daemon (not sourced)
# lib/parse-deps.sh — executed via `bash lib/parse-deps.sh` (not sourced)
@@ -124,7 +125,7 @@ KNOWN_CMDS=(
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
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
)
@@ -179,6 +180,7 @@ check_script lib/agent-session.sh
check_script lib/ci-helpers.sh
check_script 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/load-project.sh
check_script lib/mirrors.sh
diff --git a/docker/agents/Dockerfile b/docker/agents/Dockerfile
index 3ad7ef3..b7641c1 100644
--- a/docker/agents/Dockerfile
+++ b/docker/agents/Dockerfile
@@ -4,6 +4,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
bash curl git jq tmux cron python3 openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/*
+# tea CLI — official Gitea/Forgejo CLI for issue/label/comment operations
+# Checksum from https://dl.gitea.com/tea/0.9.2/tea-0.9.2-linux-amd64.sha256
+RUN curl -sL https://dl.gitea.com/tea/0.9.2/tea-0.9.2-linux-amd64 -o /usr/local/bin/tea \
+ && echo "be10cdf9a619e3c0f121df874960ed19b53e62d1c7036cf60313a28b5227d54d /usr/local/bin/tea" | sha256sum -c - \
+ && chmod +x /usr/local/bin/tea
+
# Claude CLI is mounted from the host via docker-compose volume.
# No internet access to cli.anthropic.com required at build time.
diff --git a/docker/agents/entrypoint.sh b/docker/agents/entrypoint.sh
index 993f721..9b83d32 100644
--- a/docker/agents/entrypoint.sh
+++ b/docker/agents/entrypoint.sh
@@ -57,6 +57,24 @@ log "Claude CLI: $(claude --version 2>&1 || true)"
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)
if [ -n "${MATRIX_TOKEN:-}" ] && [ -n "${MATRIX_ROOM_ID:-}" ]; then
log "Starting matrix listener in background"
diff --git a/formulas/run-predictor.toml b/formulas/run-predictor.toml
index dd00403..fcbe849 100644
--- a/formulas/run-predictor.toml
+++ b/formulas/run-predictor.toml
@@ -148,27 +148,27 @@ For each weakness you identify, choose one:
Valid outcome. Not every run needs to produce a prediction.
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:
- 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'
+tea is pre-configured with login "$TEA_LOGIN" and repo "$FORGE_REPO".
-2. File predictions:
- curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
- -H "Content-Type: application/json" \
- "$FORGE_API/issues" \
- -d '{"title":"
","body":"","labels":[]}'
+1. File predictions (labels by name, no ID lookup):
+ tea issues create --login "$TEA_LOGIN" --repo "$FORGE_REPO" \
+ --title "" --body "" --labels "prediction/unreviewed"
-3. File action dispatches (if exploiting):
- curl -sf -X POST -H "Authorization: token $FORGE_TOKEN" \
- -H "Content-Type: application/json" \
- "$FORGE_API/issues" \
- -d '{"title":"action: test prediction #NNN — ","body":"","labels":[]}'
+2. File action dispatches (if exploiting):
+ tea issues create --login "$TEA_LOGIN" --repo "$FORGE_REPO" \
+ --title "action: test prediction #NNN — " \
+ --body "" --labels "action"
-4. Do NOT duplicate existing open predictions. If your theory matches
+3. Close superseded predictions:
+ tea issues close --login "$TEA_LOGIN" --repo "$FORGE_REPO"
+
+4. Add a comment when closing (optional):
+ tea comment create --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.
## Rules
diff --git a/lib/AGENTS.md b/lib/AGENTS.md
index 5d6ad86..be56a4c 100644
--- a/lib/AGENTS.md
+++ b/lib/AGENTS.md
@@ -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/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/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 |
diff --git a/lib/env.sh b/lib/env.sh
index d94c146..221438e 100755
--- a/lib/env.sh
+++ b/lib/env.sh
@@ -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 CODEBERG_API="${FORGE_API}" # 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_REPO_ROOT="${PROJECT_REPO_ROOT:-/home/${USER}/${PROJECT_NAME}}"
export PRIMARY_BRANCH="${PRIMARY_BRANCH:-master}"
@@ -218,3 +227,9 @@ matrix_send_ctx() {
printf '%s' "$event_id"
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
diff --git a/lib/tea-helpers.sh b/lib/tea-helpers.sh
new file mode 100644
index 0000000..0292742
--- /dev/null
+++ b/lib/tea-helpers.sh
@@ -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
+# Sets FILED_ISSUE_NUM on success.
+# Returns: 0=created, 3=API/tea error, 4=secrets detected
+#
+# tea_relabel
+# tea_comment
+# tea_close
+
+# 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 edit "$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"
+}