diff --git a/docs/prds/prd-new-forge-native-integration.md b/docs/prds/prd-new-forge-native-integration.md index ee12845..b28ab73 100644 --- a/docs/prds/prd-new-forge-native-integration.md +++ b/docs/prds/prd-new-forge-native-integration.md @@ -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 --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:` 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 --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 --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