docs: update forge PRD — orchestrator split, done signal, org targeting, forge env vars

This commit is contained in:
2026-06-29 12:39:02 -04:00
parent b607d68a0e
commit 1789deaf73
+196 -222
View File
@@ -8,84 +8,112 @@
## Summary ## Summary
Add a webhook-driven orchestration layer that lets Gitea issues and PR comments Add a webhook-driven orchestration layer that lets Gitea issues and PR comments
drive bot-bottle sessions end-to-end no operator in the loop for the happy drive bot-bottle sessions end-to-end with no operator in the loop for the happy
path. An issue assigned to the agent user and labelled with a bottle name path. An issue assigned to a member of the configured agent org and labelled
triggers a headless bottle launch; the bottle processes the issue, opens a PR, with an agent name triggers a headless bottle launch; the bottle processes the
and is frozen. Subsequent PR comments rehydrate the bottle with the comment as issue, opens a PR, and posts a done-comment via the Gitea API (through
input. The PR is destroyed when it is closed. Every run emits a provenance cred-proxy) before exiting. The orchestrator detects the done-comment, freezes
footer recording the agent identity, bottle, model, egress activity, and the bottle, and attaches a provenance footer. Subsequent PR comments rehydrate
gitleaks outcome so each PR carries a verifiable audit trail of how it was the frozen bottle. The bottle is destroyed when the PR closes.
produced.
The separation of concerns across the two layers: bot-bottle owns the headless
launch primitives, forge state, Gitea client, and provenance builder.
`bot-bottle-orchestrator` (separate binary) owns the webhook listener, bottle
lifecycle loop, and monitoring dashboard; it calls into bot-bottle via
`./cli.py orchestrate`, a thin wrapper command. This PRD covers bot-bottle's
side of that contract.
## Problem ## Problem
Today an operator must open the TUI, select an agent and bottle, confirm the Today an operator must open the TUI, select an agent and bottle, confirm the
preflight, and type prompts interactively. This loop is fine for exploratory preflight, and type prompts interactively. This blocks "issue → PR" automation
work but blocks "issue → PR" automation: nothing triggers a bottle from a forge and produces no durable audit record of what the agent did. The security model
event, and nothing captures what the agent did in a durable, PR-visible record. already provides the right isolation and egress controls; the missing pieces are
The security model already produces the right isolation and egress controls; the the headless launch primitive that `bot-bottle-orchestrator` can call, the
missing piece is the orchestration layer that closes the loop between forge in-bottle Gitea API access the agent uses to signal completion, and the
events and running bottles, plus a provenance trail that makes the audit story provenance trail that makes the audit story legible to reviewers on every PR.
legible to reviewers.
## Goals / Success Criteria ## Goals / Success Criteria
1. `./cli.py forge listen` starts a webhook listener. Gitea delivers issue and 1. `./cli.py orchestrate start` and `./cli.py orchestrate resume` are the
PR events to it. non-interactive counterparts to `start` and `resume`. They accept agent,
2. An issue opened with assignee matching the configured agent Gitea username bottle, and prompt via flags rather than TUI pickers, and exit when the
and at least one `bot-bottle:<agent-name>` label launches a headless bottle. agent process exits.
The issue title + body is the initial prompt. 2. An issue assigned to a member of the configured org (`FORGE_ORG`, default
3. The bottle runs `claude --dangerously-skip-permissions --no-interactive -p `bot-bottle`) and labelled `bot-bottle:<agent-name>` is the trigger
"<prompt>"` (non-interactive print mode). When it exits, the orchestrator convention. Org membership is verified via the Gitea API at event time.
freezes the bottle and posts a comment with the provenance footer. 3. Forge-targeted bottles receive a set of env vars at launch
4. A new comment on the PR associated with the issue rehydrates the bottle with (`FORGE_GITEA_API`, `FORGE_OWNER`, `FORGE_REPO`, `FORGE_ISSUE_NUMBER`) so
`claude ... --continue -p "<comment body>"` and re-freezes on exit. the agent knows where to post its done-comment without hardcoding forge
5. Closing the PR destroys the bottle and cleans up forge state. context in the agent manifest.
6. Every comment the orchestrator posts includes a provenance footer: agent 4. The agent's egress policy for forge runs includes `gitea.<host>` with Bearer
name, bottle name(s), model, egress summary, gitleaks pass/fail, start time, auth injected by cred-proxy, enabling direct Gitea API calls from inside the
and duration. bottle.
7. Forge state (issue → slug mapping) survives orchestrator restarts: a new 5. The done-comment the agent posts is the done signal. A watchdog timeout
`listen` process picks up in-flight bottles from the forge state directory. (configurable, default 30 min) causes the orchestrator to post the
8. `./cli.py forge status` lists active forge-managed bottles and their done-comment on the agent's behalf if the agent exits without posting one.
associated issue/PR URLs. 6. Every orchestrator-posted comment ends with a provenance footer: agent name,
9. Unit tests cover: label parsing, forge state read/write, provenance footer bottle name(s), slug, start time, duration, exit code, gitleaks result, and
rendering, headless launch path (no TUI calls), orchestrator event dispatch. egress summary.
7. Forge state (issue → slug, status) is persisted to disk and survives
orchestrator restarts.
8. `./cli.py orchestrate status` lists active forge-managed bottles and their
issue/PR URLs.
9. Unit tests cover: label parsing, org-membership check path, forge state
read/write, provenance footer rendering, headless launch arg construction,
forge env var injection, echo-loop guard.
## Non-goals ## Non-goals
- Webhook signature verification (HMAC-SHA256 of the `X-Gitea-Signature` header). - Webhook signature verification (HMAC-SHA256). Added as a follow-up.
Can be added as a follow-up; the listener accepts all POSTs for now. - The `bot-bottle-orchestrator` binary itself — this PRD covers bot-bottle's
- GitHub or GitLab event support. side of the interface only. The orchestrator is a separate project.
- GitHub or GitLab support.
- Multiple simultaneous forge bottles per issue. - Multiple simultaneous forge bottles per issue.
- Automatic retry on agent error exit. - Automatic retry on agent error exit.
- Bottle destruction on issue close (only PR close is in scope; issue close is - Bottle destruction on issue close (PR close only; issue close is ambiguous).
ambiguous — the issue may close before the PR does). - Concurrent multi-issue handling (one blocking run per orchestrator process).
- Auto-discovery of repos to watch; the operator configures the Gitea webhook - A monitoring dashboard (orchestrator-side concern).
URL manually.
- Parallelism between the orchestrator and the running bottle (one active
bottle per issue at a time; a new comment while the bottle is running is
queued by re-freezing after each exit).
## Design ## Design
### Label convention ### Targeting convention
An issue is forge-targeted when **both** of the following are true: An issue is forge-targeted when **both** hold:
- Assignee login matches `FORGE_AGENT_USER` env var (default: `didericis-claude`). - At least one assignee is a member of the Gitea org named by `FORGE_ORG`
(default `bot-bottle`). Checked via `GET /api/v1/orgs/{org}/members/{user}`.
- At least one label has the prefix `bot-bottle:`. The suffix names the agent - At least one label has the prefix `bot-bottle:`. The suffix names the agent
manifest, e.g. `bot-bottle:implementer` → agent `implementer`. manifest, e.g. `bot-bottle:implementer` → agent `implementer`.
If the label suffix matches no known agent, the orchestrator posts an error `FORGE_ORG` is read at orchestrate-command startup. It is not embedded in
comment and does nothing. manifests or state files; the orchestrator stamps its value into log output for
auditability.
Optionally, a second label `bot-bottle-bottle:<bottle-name>` overrides the An optional label `bot-bottle-bottle:<name>` overrides bottle selection. When
bottle selection (analagous to multi-bottle selection in PRD 0066). When absent, absent the agent's default bottle is used.
the agent's default bottle is used.
### Headless launch — `attach_agent_headless` ### `./cli.py orchestrate` — the thin wrapper
A new function in `bot_bottle/cli/start.py`: ```
./cli.py orchestrate start --agent AGENT [--bottle BOTTLE ...] --prompt PROMPT
[--label LABEL] [--backend BACKEND]
./cli.py orchestrate resume --slug SLUG --prompt PROMPT [--backend BACKEND]
./cli.py orchestrate status
```
`orchestrate start` is `start_headless` exposed as a subcommand. It prepares
the bottle non-interactively, launches the agent in print mode, and exits
with the agent's exit code. The caller (`bot-bottle-orchestrator`) manages
freeze, state, and Gitea comments around it.
`orchestrate resume` is `resume_headless` exposed as a subcommand.
`orchestrate status` prints the forge state table.
### Headless primitives
**`attach_agent_headless`** — new function in `bot_bottle/cli/start.py`:
```python ```python
def attach_agent_headless( def attach_agent_headless(
@@ -96,11 +124,6 @@ def attach_agent_headless(
agent_provider_template: str = "claude", agent_provider_template: str = "claude",
startup_args: tuple[str, ...] = (), startup_args: tuple[str, ...] = (),
) -> int: ) -> int:
"""Run the provider CLI inside bottle in non-interactive print mode.
Blocks until the agent exits; returns the exit code. No tty.
resume=True adds --continue so the agent resumes its last session
before processing prompt."""
runtime = runtime_for(agent_provider_template) runtime = runtime_for(agent_provider_template)
agent_args = list(runtime.bypass_args) # --dangerously-skip-permissions agent_args = list(runtime.bypass_args) # --dangerously-skip-permissions
agent_args.extend(startup_args) agent_args.extend(startup_args)
@@ -111,15 +134,8 @@ def attach_agent_headless(
return bottle.exec_agent(agent_args, tty=False) return bottle.exec_agent(agent_args, tty=False)
``` ```
The system prompt from the agent's manifest `.md` file is still applied via **`start_headless`** — new function in `bot_bottle/cli/start.py` that mirrors
`--append-system-prompt-file` in `startup_args` (provisioned by `_launch_bottle` without any TUI steps:
`ClaudeAgentProvider.provision_prompt`). The `-p` arg is the user-visible
prompt the issue or comment supplies.
### Headless start — `start_headless`
A new function in `bot_bottle/cli/start.py` that mirrors `_launch_bottle` but
skips all TUI steps:
```python ```python
def start_headless( def start_headless(
@@ -129,42 +145,80 @@ def start_headless(
bottle_names: tuple[str, ...], bottle_names: tuple[str, ...],
label: str, label: str,
prompt: str, prompt: str,
forge_env: dict[str, str] | None = None,
backend_name: str | None = None, backend_name: str | None = None,
) -> tuple[str, int]: ) -> tuple[str, int]:
"""Non-interactive bottle launch for forge-driven runs. """Non-interactive bottle launch. Returns (slug, exit_code)."""
Prepares the bottle, runs attach_agent_headless, and freezes on exit.
Returns (slug, exit_code). Does not prompt the operator or open a tty.
Raises on backend errors."""
``` ```
`start_headless`: `forge_env` is merged into the bottle's `guest_env` so the agent receives the
1. Builds a `BottleSpec` with `copy_cwd=False`, `color=""`. forge context as env vars (see below). The caller freezes the bottle after
2. Calls `backend.prepare` directly (no preflight render, no y/N prompt). `start_headless` returns.
3. Enters `backend.launch(plan)` and calls `attach_agent_headless(bottle, prompt=prompt)`.
4. Captures session state and returns `(slug, exit_code)`.
The caller (orchestrator) is responsible for calling **`resume_headless`** — new function in `bot_bottle/cli/resume.py`:
`get_freezer(backend_name).commit_slug(slug)` after the bottle exits.
### Headless resume — `resume_headless`
A new function in `bot_bottle/cli/resume.py` that mirrors `cmd_resume` but
non-interactively:
```python ```python
def resume_headless( def resume_headless(slug: str, *, prompt: str, backend_name: str | None = None) -> int:
slug: str,
*,
prompt: str,
backend_name: str | None = None,
) -> int:
"""Rehydrate a frozen bottle and run one headless prompt. Returns exit_code.""" """Rehydrate a frozen bottle and run one headless prompt. Returns exit_code."""
``` ```
### Forge state — `bot_bottle/contrib/gitea/forge_state.py` ### Forge env vars
Per-issue tracking persisted to disk: The orchestrator builds this dict and passes it to `start_headless` as
`forge_env`:
| Var | Example | Purpose |
|---|---|---|
| `FORGE_GITEA_API` | `https://gitea.dideric.is/api/v1` | Base URL for Gitea API calls |
| `FORGE_OWNER` | `didericis` | Repo owner |
| `FORGE_REPO` | `bot-bottle` | Repo name |
| `FORGE_ISSUE_NUMBER` | `317` | Issue that triggered the run |
| `FORGE_PR_NUMBER` | `318` | PR to comment on (empty until PR exists) |
The agent's system prompt (from the manifest) instructs it to post a comment to
`$FORGE_GITEA_API/repos/$FORGE_OWNER/$FORGE_REPO/issues/$FORGE_ISSUE_NUMBER/comments`
when it finishes a work unit. The instruction is part of the forge-specific
agent prompt, not the base agent manifest, so non-forge runs are unaffected.
### Gitea egress for forge-targeted bottles
Forge-targeted bottles get an additional egress route injected by the
orchestrator at launch time. This is passed as an extra `EgressRoute` in the
`BottleSpec` (or via the forge env and bottle manifest) rather than requiring
operators to add it to every agent manifest:
```yaml
host: gitea.dideric.is
auth:
scheme: Bearer
token_env: GITEA_TOKEN
```
The cred-proxy injects the token; the agent never sees the raw credential.
### Done signal and watchdog
The agent posts a Gitea comment when it finishes a work unit. The orchestrator
webhook listener receives the `issue_comment` event and:
1. Verifies the commenter is a member of `FORGE_ORG`.
2. Reads the forge state for `(owner, repo, issue_number)`.
3. If `status == "running"`, treats the comment as the done signal: freezes the
bottle, appends the provenance footer to the same comment thread, sets
`status = "frozen"`.
**Watchdog**: the orchestrator tracks `last_checkin_at` in forge state. A
background thread wakes every minute. If `now - last_checkin_at > FORGE_WATCHDOG_TIMEOUT`
(default 30 min, configurable via env) and `status == "running"`, the
orchestrator posts the provenance footer comment on behalf of the agent and
freezes the bottle.
Echo-loop guard: comments from members of `FORGE_ORG` that are not the
currently-running slug's agent user are still dispatched as resume triggers, not
as done signals. The comment-is-done-signal path checks that
`comment.user.login == agent_git_user` (read from forge state).
### Forge state — `bot_bottle/contrib/gitea/forge_state.py`
``` ```
~/.bot-bottle/forge/ ~/.bot-bottle/forge/
@@ -182,14 +236,16 @@ Schema:
"agent_name": "implementer", "agent_name": "implementer",
"bottle_names": ["claude"], "bottle_names": ["claude"],
"backend_name": "docker", "backend_name": "docker",
"agent_git_user": "didericis-claude",
"issue_number": 17, "issue_number": 17,
"owner": "didericis", "owner": "didericis",
"repo": "bot-bottle", "repo": "bot-bottle",
"status": "frozen" "status": "frozen",
"last_checkin_at": "2026-06-29T12:04:12-04:00"
} }
``` ```
`status` is one of `"running"` | `"frozen"` | `"destroyed"`. `status`: `"running"` | `"frozen"` | `"destroyed"`.
Public API: Public API:
@@ -200,23 +256,26 @@ def delete_forge_state(owner: str, repo: str, issue_number: int) -> None: ...
def all_forge_states() -> list[ForgeState]: ... def all_forge_states() -> list[ForgeState]: ...
``` ```
### Provenance — `bot_bottle/contrib/gitea/provenance.py` Writes use atomic rename (`os.replace`) for crash safety.
Reads bottle metadata and egress log summary, produces a markdown section: ### Provenance — `bot_bottle/contrib/gitea/provenance.py`
```python ```python
def build_provenance_footer( def build_provenance_footer(
slug: str, slug: str,
*, *,
agent_name: str,
bottle_names: tuple[str, ...],
started_at: str, started_at: str,
finished_at: str, finished_at: str,
exit_code: int, exit_code: int,
watchdog_fired: bool = False,
egress_log_path: Path | None = None, egress_log_path: Path | None = None,
) -> str: ) -> str:
"""Return a markdown string suitable for appending to a PR/comment body.""" """Return a markdown string for appending to a Gitea comment body."""
``` ```
Output format (collapsed by default via `<details>`): Output (collapsed by default):
```markdown ```markdown
<details><summary>🔬 Run provenance</summary> <details><summary>🔬 Run provenance</summary>
@@ -230,149 +289,64 @@ Output format (collapsed by default via `<details>`):
| duration | 4m 12s | | duration | 4m 12s |
| exit | 0 ✓ | | exit | 0 ✓ |
| gitleaks | ✓ no secrets detected | | gitleaks | ✓ no secrets detected |
| done signal | agent comment *(or: watchdog — agent did not check in)* |
**Egress summary** (deny-by-default; routes allowed: 2) **Egress** (deny-by-default; 3 routes allowed)
- `api.anthropic.com` — Bearer auth - `api.anthropic.com` — Bearer auth
- `gitea.dideric.is` — unauthenticated - `gitea.dideric.is`Bearer auth
- `pypi.org` — unauthenticated
</details> </details>
``` ```
The egress summary is read from the egress log written by the egress proxy The egress summary is read from `~/.bot-bottle/state/<slug>/egress/`. When
sidecar in `~/.bot-bottle/state/<slug>/egress/`. When unavailable (backend unavailable the section is omitted. `watchdog_fired=True` changes the
has no egress log), the section is omitted rather than erroring. "done signal" row to warn reviewers.
### Gitea API client — `bot_bottle/contrib/gitea/client.py` ### Gitea client — `bot_bottle/contrib/gitea/client.py`
Thin stdlib-only HTTP wrapper used by the orchestrator:
```python ```python
class GiteaClient: class GiteaClient:
def __init__(self, *, api_url: str) -> None: ... def __init__(self, *, api_url: str) -> None: ...
def is_org_member(self, org: str, username: str) -> bool: ...
def post_comment(self, owner: str, repo: str, issue_number: int, body: str) -> None: ... def post_comment(self, owner: str, repo: str, issue_number: int, body: str) -> None: ...
def get_pr_for_issue(self, owner: str, repo: str, issue_number: int) -> int | None: def get_pr_for_issue(self, owner: str, repo: str, issue_number: int) -> int | None: ...
"""Return the PR number whose body references issue_number, or None.""" def is_pr_open(self, owner: str, repo: str, pr_number: int) -> bool: ...
def close_bottle_is_pr_open(self, owner: str, repo: str, pr_number: int) -> bool: ...
``` ```
Authentication is not configured in the client — the egress layer injects the Auth is not configured in the client — the egress layer injects the token on
Gitea token on the way out (same pattern as `GiteaDeployKeyProvisioner`). the way out, matching the existing `GiteaDeployKeyProvisioner` pattern.
### Orchestrator — `bot_bottle/contrib/gitea/orchestrator.py` ### Implementation chunks
```python 1. **Headless primitives**`attach_agent_headless` + `start_headless` (with
class ForgeOrchestrator: `forge_env` param) in `cli/start.py`; `resume_headless` in `cli/resume.py`.
def __init__( Tests: no tty, correct arg order, `forge_env` appears in `guest_env`.
self,
*,
manifest: ManifestIndex,
gitea_client: GiteaClient,
agent_user: str,
backend_name: str | None = None,
) -> None: ...
def on_issue_opened(self, event: dict) -> None: ... 2. **Forge state**`contrib/gitea/forge_state.py`: `ForgeState` dataclass,
def on_issue_comment_created(self, event: dict) -> None: ... read/write/delete/all helpers, atomic rename. Tests: round-trip JSON, missing
def on_pull_request_closed(self, event: dict) -> None: ... file → None, atomic write.
```
`on_issue_opened`: 3. **Gitea client**`contrib/gitea/client.py`: `is_org_member`,
1. Extract `owner`, `repo`, `issue_number`, `assignees`, `labels`, `title`, `body`. `post_comment`, `get_pr_for_issue`, `is_pr_open`. Tests: mock
2. Verify assignee contains `agent_user`. Bail silently if not. `urllib.request.urlopen`, assert payloads and 404-as-false for membership.
3. Parse `bot-bottle:<agent-name>` label. Post error comment + return if absent or unknown.
4. Parse optional `bot-bottle-bottle:<bottle-name>` label; else `bottle_names = ()`.
5. Build prompt: `f"Issue #{issue_number}: {title}\n\n{body}"`.
6. Call `start_headless(manifest, agent_name=..., bottle_names=..., label=..., prompt=...)`.
7. Write forge state (status=`"running"`).
8. On bottle exit: `get_freezer(backend).commit_slug(slug)`.
9. Update forge state `status="frozen"`, set `pr_number` by querying Gitea for a PR
referencing the issue.
10. Post provenance comment on the PR (or the issue if no PR found).
`on_issue_comment_created`: 4. **Provenance**`contrib/gitea/provenance.py`: `build_provenance_footer`.
1. Look up forge state by `(owner, repo, issue_number)`. Skip if not found or destroyed. Tests: required fields present, watchdog row text, egress omitted when log
2. Skip if comment author is `agent_user` (prevents echo loops). absent.
3. Skip if forge state `status == "running"` (already active; queue is out of scope).
4. Update forge state `status="running"`.
5. Call `resume_headless(slug, prompt=comment_body)`.
6. Re-freeze: `get_freezer(backend).commit_slug(slug)`.
7. Update forge state `status="frozen"`.
8. Post provenance comment.
`on_pull_request_closed`: 5. **`./cli.py orchestrate`** — `cli/orchestrate.py` with `start`, `resume`,
1. Match `pr_number` against all forge states for `(owner, repo)`. `status` subcommands wired into `cli.py`. Tests: arg parsing, `start`
2. Destroy the bottle: call the backend's teardown for `slug` and delete the image. delegates to `start_headless`, `resume` delegates to `resume_headless`.
3. Set `status="destroyed"`.
### Webhook listener — `bot_bottle/contrib/gitea/webhook_server.py`
Small `http.server.BaseHTTPRequestHandler` that:
- Accepts `POST /webhook`.
- Reads the `X-Gitea-Event` header to select the handler.
- Deserializes the JSON body and calls the orchestrator's matching `on_*` method.
- Returns HTTP 200 for known events, 204 for unknown (no-op).
Runs in the same thread as the CLI (blocking `serve_forever`). The orchestrator
handlers are synchronous; long-running launches block the listener thread for
the duration. (Concurrent multi-issue handling is out of scope for the MVP.)
### CLI — `bot_bottle/cli/forge.py`
```
./cli.py forge listen [--host HOST] [--port PORT] [--agent-user USER]
./cli.py forge status
```
`listen` defaults: `--host 0.0.0.0 --port 8765 --agent-user $FORGE_AGENT_USER`.
`status` prints a table of active forge bottles (slug, issue URL, PR URL, status).
`forge` is registered in `cli.py` alongside `start`, `resume`, `commit`, etc.
## Provenance as the product ## Provenance as the product
Every comment the orchestrator posts ends with the provenance footer. The footer Every orchestrator-posted comment ends with the provenance footer — non-optional
is not optional and not configurable off. This is load-bearing: it is the audit and not configurable off. PRs that land without a footer were not produced by
trail that lets human reviewers verify what the agent did, what credentials it this integration. The `watchdog_fired` flag in the footer flags runs where the
had access to, what it called out to, and whether gitleaks caught anything. agent did not self-report completion, so reviewers know the audit trail may be
PRs that land without a provenance footer were not opened by the forge incomplete.
integration.
The footer also links back to the bot-bottle repo (anchored to the commit SHA The footer links to the bot-bottle repo pinned to the commit SHA active during
used for the run, not `main`) so the policy that governed the run is pinned in the run (not `main`), so the policy that governed the run is permanently
the PR history. anchored in the PR history.
## Implementation chunks
1. **Headless primitives** — `attach_agent_headless` + `start_headless` in
`cli/start.py`; `resume_headless` in `cli/resume.py`. Tests: assert no tty,
correct arg construction with and without `resume=True`.
2. **Forge state** — `contrib/gitea/forge_state.py`: `ForgeState` dataclass,
`write_forge_state`, `read_forge_state`, `delete_forge_state`,
`all_forge_states`. Tests: round-trip JSON, missing file returns None,
concurrent-write safety via atomic rename.
3. **Gitea client** — `contrib/gitea/client.py`: `post_comment`,
`get_pr_for_issue`. Tests: mock `urllib.request.urlopen` and assert payloads.
4. **Provenance** — `contrib/gitea/provenance.py`: `build_provenance_footer`.
Tests: verify footer contains all required fields; verify graceful omission
when egress log is absent.
5. **Orchestrator** — `contrib/gitea/orchestrator.py`: `ForgeOrchestrator`
with the three `on_*` handlers. Tests: mock `start_headless`,
`resume_headless`, `get_freezer`, `GiteaClient`, `forge_state.*`; assert
correct calls for each event path (happy path, unknown label, echo-loop
prevention, status=running guard).
6. **Webhook listener** — `contrib/gitea/webhook_server.py`. Tests: mock
orchestrator methods; assert correct dispatch per `X-Gitea-Event` value and
correct HTTP status codes.
7. **CLI wiring** — `cli/forge.py` + registration in `cli.py`. Tests:
`cmd_forge_status` tabular output, `cmd_forge_listen` argument parsing.
## Open questions
None.