docs(prd): reconcile headless primitives with shipped start --headless

#315 already merged `start --headless` (assume_yes on _launch_bottle +
AgentProvider.headless_prompt). The PRD's proposed start_headless /
attach_agent_headless helpers were redundant with it, and the latter
diverged by hand-rolling --no-interactive/-p instead of using the
headless_prompt provider abstraction. Drop them.

Scope the remaining headless work to what's actually new: a forge_env
hook threaded into the existing _launch_bottle core, and a `resume
--headless` path (resume has no non-interactive entry point today).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WL77TgFxKbs3cidGMG9dz7
This commit is contained in:
2026-06-30 17:46:59 -04:00
parent ebad90bfa9
commit 4cb106b48d
+54 -64
View File
@@ -40,11 +40,12 @@ bot-bottle's side of that contract.
Today an operator must open the TUI, select an agent and bottle, confirm the
preflight, and type prompts interactively. This blocks "issue → PR" automation
and produces no durable audit record of what the agent did. The security model
already provides the right isolation and egress controls; the missing pieces are
the headless launch primitive that `bot-bottle-orchestrator` can call, a
forge-interaction surface the agent uses to read context, post comments, and
signal completion, and the provenance trail that makes the audit story legible
to reviewers on every PR.
already provides the right isolation and egress controls, and `start --headless`
(#315) already gives `bot-bottle-orchestrator` a non-interactive launch path.
The missing pieces are a headless `resume` counterpart for rehydrating frozen
bottles, a forge-interaction surface the agent uses to read context, post
comments, and signal completion, and the provenance trail that makes the audit
story legible to reviewers on every PR.
That forge-interaction surface could be built two ways: (2) give the agent the
Gitea API directly with cred-proxy injecting the token, or (3) put a forge
@@ -60,10 +61,12 @@ accepted as the price of those properties.
## Goals / Success Criteria
1. `./cli.py orchestrate start` and `./cli.py orchestrate resume` are the
non-interactive counterparts to `start` and `resume`. They accept agent,
bottle, and prompt via flags rather than TUI pickers, and exit when the
agent process exits.
1. Headless launch already exists: `./cli.py start <agent> --headless --prompt`
(#315) runs non-interactively with no TUI selectors or y/N preflight. This
PRD builds on it rather than re-introducing it. The remaining gap is a
matching headless `resume` path (`./cli.py resume --headless`), since
rehydrating a frozen bottle for a new prompt is required by the freeze /
rehydrate loop and `resume` has no non-interactive entry point today.
2. An issue assigned to a member of the configured org (`FORGE_ORG`, default
`bot-bottle`) and labelled `bot-bottle:<agent-name>` is the trigger
convention. Org membership is verified via the Gitea API at event time.
@@ -138,65 +141,49 @@ absent the agent's default bottle is used.
./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 start` is a thin shim over the already-shipped `start --headless`
(#315): it forwards agent / bottle / label / prompt and adds the forge-specific
wiring (`forge_env`, sidecar launch). It does not re-implement headless launch.
The caller (`bot-bottle-orchestrator`) manages freeze, state, and the forge
sidecar's done signal around it.
`orchestrate resume` is `resume_headless` exposed as a subcommand.
`orchestrate resume` is the shim over the new `resume --headless` (below).
`orchestrate status` prints the forge state table.
### Headless primitives
### Headless primitives — what exists vs. what's new
**`attach_agent_headless`** — new function in `bot_bottle/cli/start.py`:
Headless **start** already shipped in #315 and this PRD reuses it as-is:
```python
def attach_agent_headless(
bottle: Bottle,
*,
prompt: str,
resume: bool = False,
agent_provider_template: str = "claude",
startup_args: tuple[str, ...] = (),
) -> int:
runtime = runtime_for(agent_provider_template)
agent_args = list(runtime.bypass_args) # --dangerously-skip-permissions
agent_args.extend(startup_args)
agent_args.append("--no-interactive")
if resume:
agent_args.extend(runtime.resume_args) # --continue
agent_args.extend(["-p", prompt])
return bottle.exec_agent(agent_args, tty=False)
- `./cli.py start <agent> --headless --prompt TEXT` — no TUI selectors, no y/N
preflight. Internally `_start_headless()` calls the shared `_launch_bottle()`
with `assume_yes=True` and `headless_prompt_text=prompt`.
- The prompt is delivered through `AgentProvider.headless_prompt(prompt)`
claude `-p`, codex positional, pi `-p`. The orchestrator does **not** hand-roll
agent args; it relies on this provider abstraction. (An earlier draft proposed
`start_headless` / `attach_agent_headless` helpers that constructed
`--no-interactive`/`-p` directly — those are dropped as redundant with, and
divergent from, what #315 merged.)
Two additions are needed on top of #315:
**1. A `forge_env` hook on the headless launch path.** The orchestrator needs to
pass forge context + token through to the forge sidecar launched alongside the
agent. This is a parameter threaded into `_launch_bottle` (the same core
`start --headless` already uses), not a parallel launch function. The agent
process itself does not receive the token.
**2. `resume --headless`** — new in `bot_bottle/cli/resume.py`, mirroring the
`--headless` flag on `start`:
```
./cli.py resume <slug> --headless --prompt TEXT
```
**`start_headless`** — new function in `bot_bottle/cli/start.py` that mirrors
`_launch_bottle` without any TUI steps:
```python
def start_headless(
manifest: ManifestIndex,
*,
agent_name: str,
bottle_names: tuple[str, ...],
label: str,
prompt: str,
forge_env: dict[str, str] | None = None,
backend_name: str | None = None,
) -> tuple[str, int]:
"""Non-interactive bottle launch. Returns (slug, exit_code)."""
```
`forge_env` carries the forge context and token to the forge sidecar launched
alongside the agent (see below); the agent process itself does not receive the
token. The caller freezes the bottle after `start_headless` returns.
**`resume_headless`** — new function in `bot_bottle/cli/resume.py`:
```python
def resume_headless(slug: str, *, prompt: str, backend_name: str | None = None) -> int:
"""Rehydrate a frozen bottle and run one headless prompt. Returns exit_code."""
```
It rehydrates a frozen bottle and runs one headless prompt via the same
`assume_yes` + `headless_prompt` path, returning the agent's exit code. `resume`
has no non-interactive entry point today, so this is genuinely new work rather
than a rename of an existing helper.
### Forge sidecar
@@ -428,9 +415,12 @@ the `Forge` abstraction.
### Implementation chunks
1. **Headless primitives**`attach_agent_headless` + `start_headless` (with
`forge_env` param) in `cli/start.py`; `resume_headless` in `cli/resume.py`.
Tests: no tty, correct arg order, `forge_env` appears in `guest_env`.
1. **Headless additions on top of #315** — thread a `forge_env` parameter into
the existing `_launch_bottle` core (the one `start --headless` already uses);
add a `--headless` path to `cli/resume.py` reusing `assume_yes` +
`headless_prompt`. No new `start_headless`/`attach_agent_headless` helpers.
Tests: `forge_env` reaches the sidecar/`guest_env`; `resume --headless` skips
the TUI and y/N preflight and returns the agent exit code.
2. **Forge state**`contrib/gitea/forge_state.py`: `ForgeState` dataclass,
read/write/delete/all helpers, atomic rename. Tests: round-trip JSON, missing
@@ -455,7 +445,7 @@ the `Forge` abstraction.
6. **`./cli.py orchestrate`** — `cli/orchestrate.py` with `start`, `resume`,
`status` subcommands wired into `cli.py`; `start` launches the forge sidecar
alongside the agent for forge-targeted runs. Tests: arg parsing, `start`
delegates to `start_headless`, `resume` delegates to `resume_headless`.
delegates to `start --headless`, `resume` delegates to `resume --headless`.
## Provenance as the product