From bcbdf7fdec1cd0f5899eb53658f657e5db4577fd Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 25 Jun 2026 06:45:58 +0000 Subject: [PATCH] docs(prd): draft PRD for separate agent/bottle selection Closes #269. --- ...prd-new-separate-agent-bottle-selection.md | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/prds/prd-new-separate-agent-bottle-selection.md diff --git a/docs/prds/prd-new-separate-agent-bottle-selection.md b/docs/prds/prd-new-separate-agent-bottle-selection.md new file mode 100644 index 0000000..3e27d39 --- /dev/null +++ b/docs/prds/prd-new-separate-agent-bottle-selection.md @@ -0,0 +1,216 @@ +# PRD prd-new: Separate agent and bottle selection + +- **Status:** Draft +- **Author:** claude +- **Created:** 2026-06-25 +- **Issue:** #269 + +## Summary + +Agents and bottles are two separate concerns: agents carry a system prompt and +skills; bottles carry infrastructure configuration (egress, git-gate, env, +agent provider). Today an agent's manifest file hard-codes a single `bottle:` +reference, which prevents the same agent prompt from being reused across +projects that need different bottle configurations. This PRD decouples them: at +launch time, after choosing the agent, the operator picks an ordered list of +bottles via a multi-select picker. The selected bottles are merged in order +(later entries override earlier ones) to produce the effective bottle for the +session. + +## Problem + +The current `bottle: ` field on an agent manifest file binds the agent +permanently to one bottle. To use the same system prompt with a different bottle +(e.g. `claude-implementer` at home vs. at a client site that needs a different +egress policy), the operator must duplicate the agent file and change the +`bottle:` field. Duplicate agent files drift out of sync. + +## Goals / Success Criteria + +1. `bottle:` in an agent's frontmatter becomes optional. Existing manifests with + `bottle:` continue to work unchanged (backward compat). +2. After selecting an agent (via the existing single-select picker), a new + multi-select bottle picker appears showing all available bottles. +3. The multi-select picker pre-populates with the agent's `bottle:` value when + present. +4. Confirming with one or more bottles selected uses those bottles, merged in + selection order, as the effective bottle for the session. +5. Confirming with an empty selection falls back to the agent's `bottle:` field. + If neither is set, a ManifestError is raised pointing the operator at the fix. +6. The ordered bottle list is stored in launch metadata so `./cli.py resume` + uses the same bottles. +7. The preflight summary (`y/N` screen) shows the effective bottle name(s). +8. The multi-select picker supports incremental filtering, Space/Enter to toggle + selection, an ordered "Selected: ..." summary line, Ctrl-D to confirm, and + Esc/q to cancel the whole start operation. +9. Unit tests cover: multi-select widget (filter, toggle, confirm, cancel), + the `cmd_start` bottle-picker step, and the manifest `load_for_agent` + runtime-bottle-merge path. + +## Non-goals + +- Reordering the selection list from within the picker (order = insertion order; + drag-and-drop is out of scope). +- Storing bottle selection history / MRU. +- Changes to `./cli.py edit`, `./cli.py list`, or `./cli.py info`. +- Removing the `bottle:` key from the agent schema (it stays, now optional). + +## Design + +### `bot_bottle/cli/tui.py` — `filter_multiselect` + +```python +def filter_multiselect( + items: list[str], + *, + title: str = "", + initial: list[str] | None = None, + tty_path: str = "/dev/tty", +) -> list[str] | None: + """Multi-select variant of filter_select. + + Returns the ordered list of selected items, or None on cancel. + Press Space/Enter to toggle the item under the cursor. + Press Ctrl-D to confirm. Press Esc/q to cancel. + """ +``` + +Layout: + +``` +Select bottles +Filter: _ +───────────────────────────────────────── +> [*] claude + [ ] dev + [ ] codex +───────────────────────────────────────── +Selected (in order): claude +───────────────────────────────────────── +[↑↓/jk] move [Space] toggle [Ctrl-D] done [Esc] cancel +``` + +`initial` pre-populates the ordered selection. `None` means no pre-selection. +Items added are appended in insertion order; items removed leave the remaining +order unchanged. + +### `bot_bottle/manifest_schema.py` — optional `bottle:` + +`bottle` moves from `AGENT_KEYS_REQUIRED` to `AGENT_KEYS_OPTIONAL`. + +### `bot_bottle/manifest_agent.py` — optional `bottle:` + +`ManifestAgent.bottle` changes from `str` (required) to `str = ""`. +`from_dict` no longer requires the key to be present; the bottle-exists +validation is skipped when the key is absent. + +### `bot_bottle/manifest_loader.py` — `scan_bottle_names` + +```python +def scan_bottle_names(bottles_dir: Path) -> list[str]: + """Scan /*.md and return sorted bottle names.""" +``` + +### `bot_bottle/manifest.py` — `ManifestIndex` changes + +**`all_bottle_names` property** — analogous to `all_agent_names`; scans +`home_md / "bottles"` in lazy mode, returns `sorted(self.bottles.keys())` in +eager mode. + +**`load_for_agent(agent_name, bottle_names: tuple[str, ...] = ())`** — new +`bottle_names` parameter. When non-empty, the listed bottles are resolved and +merged in order (index 0 is the base; each subsequent bottle is applied on top +using the same field-merge rules as `extends:`). The result replaces the bottle +that `agent.bottle` would have provided. When empty, falls back to `agent.bottle`. +Raises ManifestError if neither `bottle_names` nor `agent.bottle` is set. + +### `bot_bottle/manifest_extends.py` — `merge_bottles_runtime` + +```python +def merge_bottles_runtime(bottles: list[ManifestBottle]) -> ManifestBottle: + """Merge an ordered list of pre-resolved ManifestBottle objects. + + Index 0 is the base; each subsequent entry overrides the previous using + the same rules as the file-based extends machinery: + - env: dict merge, later wins + - git_user: per-field overlay, later wins on non-empty + - git (repos): union by name, later wins per-name + - egress.routes: concatenate + - agent_provider, supervise: later bottle's value replaces earlier + """ +``` + +This function operates on already-parsed `ManifestBottle` objects, so it does +not need to touch the raw-dict path. + +### `bot_bottle/backend/__init__.py` — `BottleSpec` + `_validate` + +`BottleSpec` gains `bottle_names: tuple[str, ...] = ()`. + +`BottleBackend._validate` passes `spec.bottle_names` to `load_for_agent`: + +```python +manifest = spec.manifest.load_for_agent(spec.agent_name, spec.bottle_names) +``` + +The preflight print updates `info(f"bottle: {agent.bottle}")` to display the +effective bottle name(s). When `spec.bottle_names` is non-empty those are +shown; when empty and `agent.bottle` is set, the agent's `bottle:` is shown. + +### `bot_bottle/bottle_state.py` — persist bottle names + +`BottleMetadata` gains `bottle_names: tuple[str, ...] = ()`. `read_metadata` +reads this from JSON (default `()`). `write_launch_metadata` passes +`spec.bottle_names` through. + +### `bot_bottle/cli/start.py` — bottle multiselect step + +After agent selection, before the name/color modal: + +```python +available_bottle_names = manifest.all_bottle_names +# Peek at agent's bottle default for pre-population +initial_bottle = _peek_agent_bottle(manifest, agent_name) +initial = [initial_bottle] if initial_bottle else [] + +bottle_names_list = tui.filter_multiselect( + available_bottle_names, + title="Select bottles", + initial=initial, +) +if bottle_names_list is None: + return 0 # user cancelled +bottle_names = tuple(bottle_names_list) +``` + +`_peek_agent_bottle` reads the agent file's frontmatter without full parsing, +returning the `bottle:` value or `""` when absent. + +`BottleSpec` is built with `bottle_names=bottle_names`. + +### `bot_bottle/cli/resume.py` — bottle names from metadata + +```python +spec = BottleSpec( + ... + bottle_names=tuple(metadata.bottle_names), +) +``` + +## Implementation chunks + +1. **Schema + model** — `manifest_schema.py`, `manifest_agent.py` (optional + `bottle:`), `manifest_loader.py` (`scan_bottle_names`), `manifest.py` + (`all_bottle_names`, `load_for_agent` signature), `manifest_extends.py` + (`merge_bottles_runtime`), `bottle_state.py` (`bottle_names` field), + `resolve_common.py` (thread through). +2. **Backend** — `BottleSpec.bottle_names`, `_validate`, preflight print. +3. **TUI** — `filter_multiselect` in `tui.py` + unit tests. +4. **CLI wiring** — `start.py` bottle picker step, `resume.py` metadata load. +5. **Tests** — `test_cli_start_selector.py` bottle-picker cases, + `test_manifest_agent.py` optional-bottle cases, new + `test_manifest_bottle_merge.py` for `merge_bottles_runtime`. + +## Open questions + +None.