From d4b7e1e7b8064e990850a67c7d72c2fc815a9680 Mon Sep 17 00:00:00 2001
From: Renzo F <45401804+rennf93@users.noreply.github.com>
Date: Thu, 23 Jul 2026 09:41:27 +0200
Subject: [PATCH] =?UTF-8?q?fix:=20post-finale=20completeness=20sweep=20?=
=?UTF-8?q?=E2=80=94=20routing=20surface,=20provider=20config,=20budgets,?=
=?UTF-8?q?=20compose=20env,=20interactive=20exemption=20(#661)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 49 ++
CLAUDE.md | 26 +-
.../versions/086_enable_gemini_provider.py | 49 ++
docker-compose.registry.yml | 8 +
docker-compose.yaml | 9 +
docker-compose.yml | 9 +
docs/map/runtime-providers.md | 10 +-
docs/rag/architecture/http-security-guard.md | 2 +
docs/rag/tools/messaging-tools.md | 4 +
docs/rag/workflows/task-claiming.md | 1 +
.../__tests__/edit-project-dialog.test.tsx | 30 ++
.../projects/edit-project-dialog.tsx | 9 +
.../__tests__/ai-routing-card.test.tsx | 108 +++++
.../components/settings/ai-routing-card.tsx | 446 +++++++++++-------
.../tasks/__tests__/edit-task-dialog.test.tsx | 68 ++-
.../src/components/tasks/edit-task-dialog.tsx | 16 +-
panel/src/lib/api/providers.ts | 10 +-
panel/src/types/index.ts | 9 +
roboco/api/routes/project.py | 16 +-
roboco/api/routes/provider.py | 11 +
roboco/api/routes/tasks.py | 7 +
roboco/api/schemas/project.py | 8 +-
roboco/api/schemas/provider.py | 45 +-
roboco/api/schemas/tasks.py | 8 +-
roboco/config.py | 11 +
roboco/llm/providers/codex_cli_config.py | 33 +-
roboco/llm/providers/gemini.py | 6 +-
roboco/llm/providers/gemini_cli_config.py | 26 +-
roboco/models/project.py | 9 +-
roboco/models/task.py | 6 +-
roboco/runtime/orchestrator.py | 42 ++
roboco/services/llm.py | 184 ++++++--
roboco/services/task.py | 18 +
tests/integration/test_llm_routing.py | 255 +++++++++-
tests/integration/test_project_routes.py | 74 +++
tests/integration/test_provider_routes.py | 119 +++++
tests/integration/test_tasks_routes.py | 76 +++
tests/unit/api/test_schemas_tasks.py | 21 +
.../llm/providers/test_codex_cli_config.py | 27 +-
.../llm/providers/test_gemini_cli_config.py | 55 ++-
.../llm/providers/test_gemini_provider.py | 9 +
tests/unit/llm/test_routing_downgrade.py | 6 +-
tests/unit/models/test_budget_fields.py | 165 +++++++
.../test_interactive_provider_guard.py | 195 ++++++++
.../services/test_task_base_inheritance.py | 25 +
45 files changed, 2064 insertions(+), 256 deletions(-)
create mode 100644 alembic/versions/086_enable_gemini_provider.py
create mode 100644 tests/unit/models/test_budget_fields.py
create mode 100644 tests/unit/runtime/test_interactive_provider_guard.py
diff --git a/.env.example b/.env.example
index 99488cbf..c983eb41 100644
--- a/.env.example
+++ b/.env.example
@@ -151,6 +151,55 @@ ROBOCO_DEFAULT_EMBEDDING_MODEL=qwen3-embedding:0.6b
# 0 disables. Backstops runaway-loop token burn.
# ROBOCO_GROK_MAX_COST_USD=0.0
+# =============================================================================
+# Codex (OpenAI) Provider — optional
+# =============================================================================
+# RoboCo can run agents on OpenAI's Codex CLI (ChatGPT subscription auth) via a
+# mounted ~/.codex/auth.json — run `codex login` once on the host. Enable it by
+# picking the "Codex" routing mode or a gpt-* model per agent in the panel's AI
+# routing card. Host dir mounted RO into each Codex agent; the orchestrator
+# refreshes the token before expiry (codex_auth.py). All vars optional.
+# ROBOCO_HOST_CODEX_DIR=/home/youruser/.codex
+# ROBOCO_CODEX_CLI_MODEL=gpt-5.3-codex
+# If the default OIDC client id is wrong for your account, override it (a bad
+# refresh never mutates auth.json — worst case is a parked provider):
+# ROBOCO_CODEX_OAUTH_CLIENT_ID=
+
+# =============================================================================
+# Gemini (Google) Provider — optional
+# =============================================================================
+# RoboCo can run agents on Google's Gemini CLI (Google-account OAuth) via a
+# mounted ~/.gemini — run the interactive `gemini` login once on the host.
+# Enable via the "Gemini" routing mode or a gemini-* model per agent. Each
+# container copies the RO-mounted creds to a writable local dir and refreshes
+# in-process (reusable refresh tokens, no orchestrator daemon). All optional.
+# ROBOCO_HOST_GEMINI_DIR=/home/youruser/.gemini
+# ROBOCO_GEMINI_CLI_MODEL=gemini-2.5-pro
+# Hard ceiling on agentic turns per run (loop guard, grok parity):
+# ROBOCO_GEMINI_MAX_TURNS=200
+# Park-and-retry delays after a rate-limit / auth failure (seconds):
+# ROBOCO_GEMINI_RATE_LIMIT_RETRY_AFTER_SECONDS=300
+# ROBOCO_GEMINI_AUTH_RETRY_AFTER_SECONDS=300
+
+# =============================================================================
+# Cost budgets — optional
+# =============================================================================
+# Per-task (tasks.budget_usd) and per-project (projects.monthly_budget_usd)
+# cost caps. Default-off subsystem; also toggleable on the panel's Feature
+# Flags card. A breached task is BLOCKED (not silently killed) and the CEO is
+# notified. Build compose arms it true; registry compose leaves it false.
+# ROBOCO_TASK_BUDGETS_ENABLED=false
+
+# =============================================================================
+# Notification re-escalation backoff — optional tuning
+# =============================================================================
+# Expired unacked ack-required notifications re-escalate on exponential backoff
+# (first at expiry, then doubling from the base, capped at 24h) up to a max
+# count, instead of re-firing every sweep tick. No panel UI — env/compose is
+# the only tuning path.
+# ROBOCO_NOTIFICATION_REESCALATION_BASE_SECONDS=3600
+# ROBOCO_NOTIFICATION_MAX_REESCALATIONS=5
+
# =============================================================================
# Security
# =============================================================================
diff --git a/CLAUDE.md b/CLAUDE.md
index be4f1a83..e3f39298 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -79,7 +79,7 @@ pnpm test
| RAG Engine | in-house (asyncpg + pgvector, hybrid retrieval) |
| Cache/Queue | Redis |
| Container Runtime | Docker + Docker Compose |
-| Cloud LLM | Claude API (claude-opus-4-6) + xAI Grok (official `grok` CLI, SuperGrok subscription) |
+| Cloud LLM | Claude API (claude-opus-4-6) + xAI Grok (official `grok` CLI, SuperGrok subscription) + OpenAI (official `codex` CLI, ChatGPT subscription) + Google Gemini (official `gemini` CLI, OAuth login) |
| Local LLM | Ollama (glm-5.2:cloud for RAG/hybrid retrieval) |
| Embeddings | qwen3-embedding:0.6b (1024 dim) |
| Frontend | Next.js 16 + TypeScript + Tailwind + Radix UI (in `panel/`) |
@@ -190,6 +190,10 @@ Git authentication is managed **per-project** through encrypted GitHub PATs:
The REST surface (PRs, CI status, reviews, labels, releases) is provider-routed (`roboco/services/forge/`): `GitProvider` is the ~20-method transport contract, `GitHubProvider`, `GiteaProvider`, and `GitLabProvider` implement it, and `GitService._forge` returns a `ForgeRouter` that picks the transport per call from `RepoRef.host` — `None` (github.com/GHE) rides GitHub, a registered Gitea/GitLab host rides that instance's provider, so `GitService`'s call sites never know which forge they're on. A project opts in via `projects.git_provider` (gitlab.com auto-detects like github.com; self-hosted instances set it explicitly; `"github"` doubles as the GHE escape hatch with `ROBOCO_GITHUB_API_BASE_URL`) — panel: the Forge select in the edit-project dialog. The host→provider(+scheme — plain-http LAN instances are supported) map is in-memory per process, self-healing: `ProjectService.get`/`get_by_slug` re-register on every read. Both non-GitHub providers adapt their wire contracts back into the GitHub shapes `GitService` classifies (`forge/shaping.py` `ShapedResponse`): Gitea — `token` auth scheme, duplicate-PR 409→422, commit statuses reshaped into `check_runs`/`workflow_runs`, `Do`-keyed POST merge, slash-encoded refs; GitLab — MR iid→`number`, source/target_branch→`head`/`base`, per-file diffs reassembled into unified-diff text, approve-vs-note review routing (no request-changes verb exists), pipelines/statuses CI reshapes, reviewer-request skipped (needs numeric ids). Neither has GitHub's server-side merges API: their `merge_branch` returns a shaped 501 and `GitService.sync_env_branch` runs the shared local-git fallback (`_local_merge_branch`: throwaway clone → merge → push; conflict aborts with the remote untouched, same status vocabulary). Plain git (clone/fetch/push) is forge-agnostic — the Basic-auth `x-access-token:` extraheader works on Gitea/GitLab unchanged (verified live on Gitea). The env-gated `tests/e2e_smoke/test_gitea_live.py` is the live contract suite (self-seeding against a dockerized `gitea/gitea`; it caught the slash-encoding and http-scheme gaps).
+### Protected Branches
+
+`projects.protected_branches` (operator-declared, panel: a chips editor in the edit-project dialog) is unioned — never replacing, only tightening — into `GitService`'s hardcoded safety floor. Two scopes, deliberately different: `_protected_branches_for` (rebase refusal + `sync_task_branch`'s force-push refusal) is the field alone unioned with the hardcoded `{master, main}` floor and fails OPEN on a lookup error (a wrongly-blocked rebase over a transient DB blip is the worse tradeoff, and a skipped rebase gets no free retry); `_protected_branches_for_deletion` — consulted ONLY by the shared `_delete_remote_branch_best_effort` chokepoint every remote-delete path routes through (task-branch cleanup on cancel, the stale-branch sweep, and post-merge PR-source cleanup) — additionally unions in the project's environment-ladder rung branches (`effective_environments`, so a null ladder's synthesized single rung off `default_branch` is protected too, e.g. a renamed trunk like `trunk`) and fails CLOSED on a lookup error (skip the delete entirely; deletion is best-effort so a skipped one just retries at the next sweep, whereas silently proceeding on an unresolvable project could delete a real declared rung for good). Matching is exact and case-sensitive; an empty `protected_branches` list degrades to exactly the prior hardcoded-only behavior.
+
## Task Lifecycle
### Task States
@@ -325,6 +329,8 @@ Agents coordinate via **task state + task detail fields**, not a channel/session
Agent learnings (`note` scope='learning') broadcast as knowledge-share notifications only to other **agents** — the human / human-driven roles (CEO, prompter, secretary) are excluded, since agent knowledge-sharing is noise in a human's inbox.
+**Notification re-escalation backoff (always-on).** `sweep_expired_notifications` (`roboco/services/notification_delivery.py`) re-escalates a still-unacked ack-required notification past its `expires_at` to the recipient's up-role (the PM's PM, or the CEO) — but only when a per-notification backoff schedule says it's due, not on every ~60s sweep tick forever. Each row carries `reescalation_count` / `last_reescalated_at` / `reescalation_delivered_count` (migration 079): the first re-escalation fires at expiry, each one after that doubles the wait from `ROBOCO_NOTIFICATION_REESCALATION_BASE_SECONDS` (default 1h, capped at 24h), and past `ROBOCO_NOTIFICATION_MAX_REESCALATIONS` (default 5) the row is logged once as permanently-unacked and left alone for good — the due/wait/capped decision is a pure function (`reescalation_decision`, `roboco/foundation/policy/communications.py`). The attempt slot is claimed by a compare-and-set `UPDATE ... WHERE reescalation_count = :n` BEFORE any delivery is attempted (the 60s dedup guard elsewhere does NOT backstop this — `BLOCKER_ESCALATION`, the type every re-escalation fires as, is excluded from the loop-prone dedup set), so two sweep ticks racing the same row can never both deliver. Legacy rows read as `count=0` and keep the original first-fire semantics.
+
## Key Principles
1. **Everything is a task** - All work is tracked and documented
@@ -380,7 +386,9 @@ The `next` field tells the agent what to call next; the `remediate` field on err
## Agent Providers
-Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider` lifecycle ABC (`base.py`) and a `ProviderRegistry` keyed by `ModelProvider` (`registry.py`), with `ClaudeCodeProvider` (default), `GrokCliProvider`, and `GeminiCliProvider`. The orchestrator resolves a provider at spawn from the agent's `ModelProvider`; when no dedicated provider is registered it falls back to the built-in Claude Code spawn. `ModelProvider` (`roboco/models/base.py`) is `ANTHROPIC` (default), `GROK`, `GEMINI`, `LOCAL`, `OLLAMA_CLOUD`, `OPENAI` (reserved). The seam is additive: only `GROK`/`GEMINI` route through their dedicated providers; Anthropic / Ollama Cloud / self-hosted spawns are unchanged, and every provider gets the same MCP gateway + tool-manifest wiring by construction.
+Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider` lifecycle ABC (`base.py`) and a `ProviderRegistry` keyed by `ModelProvider` (`registry.py`), with `ClaudeCodeProvider` (default), `GrokCliProvider`, `GeminiCliProvider`, and `CodexCliProvider`. The orchestrator resolves a provider at spawn from the agent's `ModelProvider`; when no dedicated provider is registered it falls back to the built-in Claude Code spawn. `ModelProvider` (`roboco/models/base.py`) is `ANTHROPIC` (default), `GROK`, `GEMINI`, `OPENAI`, `LOCAL`, `OLLAMA_CLOUD` — `OPENAI` routes through the official Codex CLI on a ChatGPT subscription (`CodexCliProvider`), not a reserved/unimplemented value. The seam is additive: only `GROK`/`GEMINI`/`OPENAI` route through their dedicated providers; Anthropic / Ollama Cloud / self-hosted spawns are unchanged, and every provider gets the same MCP gateway + tool-manifest wiring by construction.
+
+**Cost-tiered routing + routing presets.** `ModelRoutingService` (`roboco/services/llm.py`) resolves `(provider, model)` per agent at spawn from `model_assignments` with precedence `AGENT_SLUG > ROLE(:complexity) > ROLE > GLOBAL`; the compound `ROLE:complexity` rung (e.g. `developer:low`) reuses the existing ROLE scope + `scope_value` column — no schema change — to pin a role to a cheaper model at a given task's `estimated_complexity` without touching the plain ROLE row everything else still uses. `apply_mode('cost_tiered')` additively seeds one day-1 override (`developer:low` → `haiku`), unlike every other mode's wipe-then-seed. On top of modes, **routing presets** (`RoutingPresetTable`) let an operator name-and-snapshot the FULL current routing state (mode + every assignment row, AGENT_SLUG pins included) via `save_routing_preset`, then restore it wholesale later with `apply_routing_preset` — a full swap, validate-every-entry-first so an invalid payload never triggers the wipe, unlike a mode's pin-preserving behavior. Panel: the AI routing settings card (`ai-routing-card.tsx`) exposes preset save/apply/delete alongside the existing mode buttons and the complexity-override editor.
**Grok runtime.** `GROK` agents run xAI's official `grok` CLI (model `grok-build`) authenticated by a **SuperGrok subscription**, not a metered API key — so a Grok workforce can't stall mid-task on out-of-credits. The host `~/.grok/auth.json` is mounted **read-only** into each agent (`GrokCliProvider._append_grok_auth_mount`; `ROBOCO_HOST_GROK_DIR` is the host mount source, set up once with `grok login`). It reaches parity with the Claude path by construction: same MCP gateway + manifest, per-role tool-removal and git-operation deny rules, a prompt-injection guard on the task prompt, headless tool auto-approval, and per-agent token/cost capture from the grok session store. It covers both one-shot delivery roles and the interactive Intake (Prompter) and Secretary chats (per-turn `grok -p` with session resume).
@@ -388,6 +396,8 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
**Gemini runtime (V1: one-shot delivery roles only, no interactive Intake/Secretary).** `GEMINI` agents run Google's official `gemini` CLI (GA ids `gemini-2.5-pro`/`-flash`/`-flash-lite`, pinned via `ROBOCO_GEMINI_CLI_MODEL`) authenticated by an **OAuth login**, not a metered key. The host `~/.gemini` (from a one-time interactive `gemini` login, `ROBOCO_HOST_GEMINI_DIR`) is mounted **read-only** at a staging path; the entrypoint COPIES it into a container-local, writable `~/.gemini` so the CLI's own in-process token refresh (google-auth-library) can write back locally without ever touching the host copy. Unlike grok's single-use refresh token (which needs one orchestrator-side writer serializing every refresh, `grok_auth.py`), Google's refresh token is reusable, so each container refreshing its own copy independently is safe with **no orchestrator refresh daemon** — `roboco/llm/providers/gemini.py`'s module docstring spells out the contrast. Tool scoping has no CLI-flag equivalent to grok's `--disallowed-tools`/`--deny`: it's expressed entirely through a rendered TOML Policy Engine (`~/.gemini/policies/roboco.toml`, deny-only rules keyed by `toolName`/`commandPrefix`) plus `settings.json` (`security.auth.selectedType` for headless OAuth, `experimental.enableAgents=false` for the fleet-wide subagent ban, `advanced.autoConfigureMemory=false`), all rendered by `roboco/llm/providers/gemini_cli_config.py`; `--approval-mode yolo` is universal (headless auto-approval). Usage/cost capture (`gemini_cli_usage.py`) reads the run's own `--output-format stream-json` terminal `result` event for per-model token stats — no session-file scraping — and prices each of the three GA models at its own rate before flattening to the grok-shaped `usage.json`; the same module also remaps a quota/rate-limit error (no dedicated CLI exit code — parsed from the run's JSON `error.type`) to exit 75, while exit 41 (the CLI's own auth-failure code) passes straight through, so the orchestrator parks the `GEMINI` provider on either exactly like it does for grok's exit-75/78.
+**Codex runtime (V1: one-shot delivery roles only, no interactive Intake/Secretary).** `OPENAI` agents run OpenAI's official `codex` CLI (model pinned via `ROBOCO_CODEX_CLI_MODEL`, default `gpt-5.3-codex` — codex has no reliable default) authenticated by a **ChatGPT subscription**, not a metered API key. The host `~/.codex` (from a one-time `codex login`, `ROBOCO_HOST_CODEX_DIR`) is mounted **read-only** as a DIRECTORY, not a single `auth.json` file — a single-file bind mount pins the inode, so the orchestrator's atomic tmp+rename refresh would never reach a running container, the same concern grok's mount documents; the entrypoint symlinks `~/.codex/auth.json` to the RO mount while codex's own writable state (`config.toml`, `rules/`, `sessions/`) lives in the image's own `~/.codex`. `roboco/llm/providers/codex_auth.py` runs the orchestrator-side refresh loop (`refresh_if_stale`, mirroring `grok_auth.py`): the access token is a JWT whose `exp` claim is the only expiry signal (unlike grok's bundle there's no sibling `expires_at` field), and the refresh-token grant against `auth.openai.com/oauth/token` is single-use, guarded by the same process-wide lock + re-check-inside-the-lock pattern that protects grok's rotation from a concurrent double-burn. Tool scoping has no CLI-flag equivalent to grok's `--disallowed-tools`: a per-role `--sandbox` level (`workspace-write` for `developer`, `read-only` for every other role) plus one shared `~/.codex/rules/default.rules` execpolicy file (Starlark `prefix_rule`s denying git-mutation/destructive/raw-package-manager commands, rendered by `roboco/llm/providers/codex_cli_config.py`) do the job instead; the CLI has no verified system-prompt-file mechanism, so the composed role blueprint is prepended to the task prompt itself rather than mounted separately. Codex has no exit-code taxonomy (every failure exits 1), so `codex_cli_sniff.py` classifies a run's terminal state (`rate_limit`/`auth`/none) from ONLY the structured `error.message` field of its JSONL events plus stderr — never the model's own transcript, which could false-positive on ordinary on-topic prose (this repo's own prompts use the phrase "quota-limited"). Usage capture (`codex_cli_usage.py`) sums `turn.completed` events' real input/output/cache-read/cache-write split — a genuine four-bucket split, unlike grok's output-only fallback — into the grok-shaped `usage.json`.
+
## Self-Healing & Feature Flags
**Self-healing CI loop (default-off).** RoboCo can watch its own repository's CI (a single named workflow) and, on a detected regression, open a fix task that is held out of dispatch until the CEO approves it (it terminates at `awaiting_ceo_approval`), then dispatch it through the normal delivery flow. It is dormant by default and armed by `ROBOCO_SELF_HEAL_ENABLED` plus a second opt-in `ROBOCO_SELF_HEAL_ORIGINATE_ENABLED`; origination is bounded by `ROBOCO_SELF_HEAL_MAX_OPEN_TASKS` / `_MAX_PER_CYCLE` so it can't flood the backlog. It never auto-merges or self-deploys (`roboco/services/self_heal_engine.py`).
@@ -414,10 +424,12 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
**Obsidian vault V1+V2 (default-off).** The org's human-readable memory palace as a rebuildable DB projection — tasks, journals, and A2A digests as wikilinked markdown — gated by `ROBOCO_OBSIDIAN_VAULT_ENABLED` + `ROBOCO_VAULT_PATH` (default off, but both compose files arm it `true`). `VaultWriter` (`roboco/services/vault_writer.py`) is a pure, DB-free materializer under `RoboCo/{Tasks/,Journals/,A2A,Agents,Archive//Tasks/,Reports}/`; every note carries a stable `aliases: []` so a title rename (or an archival move) never breaks a `[[id8|title]]` cross-link, and private journals are excluded. Four best-effort event seams (`TaskService.create`'s materialize-on-create, `TaskService`'s status-transition frontmatter touch, `JournalService`, `A2AService`) patch/append on the relevant transition — a vault write failure never blocks the real action; materialize-on-create means a task's note exists from the moment it's created, not just at curation/rebuild. `python -m roboco.vault rebuild` re-projects every entity from the DB (preserving an existing task's Auditor-authored `## Narrative`, archive-aware so an old terminal task lands directly in `Archive//`) and materializes the shipped `.obsidian/` config (Dataview, Kanban, graph groups) + `RoboCo/_meta/` dashboards + `.base` Bases views from `roboco/vault_assets/`; `relocate ` moves the tree, grafting `RoboCo/` into an existing personal vault without touching its own config. The Auditor gets a one-shot `curate_vault(task_id, narrative)` do-tool, spawned by the orchestrator on each completed root task, writing the `## Narrative` section a deterministic write otherwise leaves as a placeholder. A second, independently-gated `ROBOCO_VAULT_INTAKE_ENABLED` watcher (`VaultIntakeEngine`) turns `#roboco`-tagged notes under the vault's inbox folder into PENDING, Product-Owner-assigned board-review drafts (`source=vault_note`) — the identical board-review path a chat-confirmed draft takes, never straight into delivery. Extraction runs on the local model with a deterministic fallback; the note body is screened through `foundation/policy/injection_guard.screen_external_text` (the same untrusted-content envelope `XEngine` applies to X mentions — flags an injection-pattern line inline, never removes content) before it reaches the prompt or the fallback. Deduped per `(vault-relative path, content hash)` via `vault_seen_notes`, so an edit re-qualifies a previously-seen note — the same hashing convention (every RoboCo feedback callout stripped first, `foundation/policy/vault_notes.py`) is now shared with the KB engine below. V2 adds three things on top: a **drift janitor** (`services/vault_janitor.py`, `_vault_janitor_loop`) hourly-ticked but gated by a `RoboCo/_meta/.janitor_state.json` state file so real work (a daily changed-task re-projection + random-sample drift check + archival pass, each capped at 200/cycle and per-item isolated so one bad row never wedges the sweep) and a weekly org-report (`vault_report_enabled`, default true — `VaultWriter.write_org_report` from `MetricsService`/`UsageService`, best-effort CEO notification) each fire exactly once per elapsed period regardless of loop/restart cadence; **archival** (`vault_archive_days`, default 30, `0`=off) moving old terminal tasks' notes into `RoboCo/Archive//Tasks//` during the sweep, alias links making the move free and the shipped Dataview/graph assets `Archive/`-aware; and **KB ingest** (`vault_kb_enabled`, default false — NAS compose arms it `true`, registry compose leaves it `false`) embedding the CEO's own `RoboCo/Notes/` (config `vault_kb_dirs`, csv, load-time-validated against traversal/overlap with reserved projection dirs) into a new `IndexType.VAULT_NOTES` corpus via `_vault_kb_loop` (`services/vault_kb_engine.py`, default 900s), with every note re-checked for symlink/path-escape at read time and screened through the injection guard as a hard GATE (a flagged note is quarantined — skipped, logged, callout-marked, never embedded) rather than the intake watcher's screen-and-still-process posture — reaching `roboco_kb_search`, `MentorService`'s default domain, and `EvidenceRepo.similar_memory` (claim-time briefings, relevance-floored, labeled `vault_note`) so the CEO's own writing finally becomes fleet-retrievable institutional memory.
-**Fable-mode (default-off).** Full opus-fable-playbook adoption: makes the fleet behave more like Fable 5 on the existing model tiers (the tiers stay — Fable 5 the model is not an option). Two levers, both gated by `ROBOCO_FABLE_MODE_ENABLED`: ① **doctrine** — `fable_doctrine_layer()` (`roboco/agents/factories/_base.py`) composes the vendored behavioral doctrine (`agents/prompts/doctrine/fable.md`, from `github.com/rennf93/opus-fable-playbook` MIT `output-styles/fable.md`, YAML frontmatter stripped) into `compose_prompt`'s layer tuple immediately after `base.md` — universal cross-role doctrine, the same tier as the base rules, ahead of role/team/identity layers so those keep their specificity precedence. ② **hooks** — 5 vendored scripts under `docker/scripts/fable-*.sh` (stop-gate, bash-discipline, honesty-nudge, prompt-nudge, precompact; `session-start.sh` deliberately SKIPPED — its doctrine card is redundant with ① and its output-style check is inapplicable here) are installed alongside RoboCo's own hooks, never replacing them: `AgentOrchestrator._fable_hook_groups()` appends them AFTER RoboCo's own per-event entries in the Claude-path settings.json (isolated into its own helper to protect `_generate_agent_settings`'s xenon budget); the grok path installs only `honesty-nudge` (`write_grok_fable_hooks`, `roboco/llm/providers/grok_cli_config.py`) — a deliberately conservative V1 scope, since a grok `PreToolUse`/`Stop` hook deny cancels the entire run (verified live) while `PostToolUse` never denies. Off by default: the spawn path (composed prompt, settings.json, grok hooks) is byte-for-byte unchanged when the flag is off. No new eval harness — measurement rides the existing rework/spawn-waste/`revision_count` dashboard (see "Delivery observability" below). Armed on the NAS deploy like the rest; left OFF in `docker-compose.registry.yml`.
+**Fable-mode (default-off).** Full opus-fable-playbook adoption: makes the fleet behave more like Fable 5 on the existing model tiers (the tiers stay — Fable 5 the model is not an option). Two levers, both gated by `ROBOCO_FABLE_MODE_ENABLED`: ① **doctrine** — `fable_doctrine_layer()` (`roboco/agents/factories/_base.py`) composes the vendored behavioral doctrine (`agents/prompts/doctrine/fable.md`, from `github.com/rennf93/opus-fable-playbook` MIT `output-styles/fable.md`, YAML frontmatter stripped) into `compose_prompt`'s layer tuple immediately after `base.md` — universal cross-role doctrine, the same tier as the base rules, ahead of role/team/identity layers so those keep their specificity precedence. ② **hooks** — 5 vendored scripts under `docker/scripts/fable-*.sh` (stop-gate, bash-discipline, honesty-nudge, prompt-nudge, precompact; `session-start.sh` deliberately SKIPPED — its doctrine card is redundant with ① and its output-style check is inapplicable here) are installed alongside RoboCo's own hooks, never replacing them: `AgentOrchestrator._fable_hook_groups()` appends them AFTER RoboCo's own per-event entries in the Claude-path settings.json (isolated into its own helper to protect `_generate_agent_settings`'s xenon budget); the grok path installs only `honesty-nudge` (`write_grok_fable_hooks`, `roboco/llm/providers/grok_cli_config.py`) — a deliberately conservative V1 scope, since a grok `PreToolUse`/`Stop` hook deny cancels the entire run (verified live) while `PostToolUse` never denies. Off by default: the spawn path (composed prompt, settings.json, grok hooks) is byte-for-byte unchanged when the flag is off. No new eval harness for Fable-specific measurement — that rides the existing rework/spawn-waste/`revision_count` dashboard (see "Delivery observability" below); the separate golden-task eval harness (`roboco/eval/`, see below) is an offline CLI bench for a (role, model/provider) cohort, unrelated to Fable-mode's own on/off measurement. Armed on the NAS deploy like the rest; left OFF in `docker-compose.registry.yml`.
**Ponytail (bundled with Fable-mode).** Rides `ROBOCO_FABLE_MODE_ENABLED` — no separate flag. Vendors the ponytail "lazy senior dev" build-laziness doctrine (`agents/prompts/doctrine/ponytail.md` + ethos sibling, MIT, Copyright (c) 2026 DietrichGebert — trimmed, YAML frontmatter stripped) into every composed system prompt via `ponytail_doctrine_layer` (`roboco/agents/factories/_base.py`), slotted immediately after the Fable doctrine layer and gated on the same flag. Role-scoped: developers (`AgentRole.DEVELOPER`) get the full ladder (YAGNI → reuse-in-this-codebase → stdlib → native-platform → installed-dep → one-line → minimal); every other role gets the ethos-only cut (`ponytail-ethos.md`) — the code-mechanics rungs are dropped so they can't leak into prose artifacts (task plans, review notes, docs). Both files carry a 5-point RoboCo preamble (the ethos sibling adds a 6th: free-text field obligations) that makes the ladder yield to the Architectural Conventions Standard (placement), the 80% coverage gate + QA review + self-verification, the per-team design bar, task hygiene (everything-is-a-task / commits-linked / state-is-sacred), and reviewer feedback (`needs_revision` / `pr_fail` / `request_changes`) — the overlap mitigation is scoping, not deletion, and it rides ponytail's own "when NOT to be lazy" clause. Developer intensity is tunable via `ROBOCO_PONYTAIL_INTENSITY` (`lite` / `full` / `ultra`, default `full`; `roboco/config.py` `ponytail_intensity`, a string value — not a feature flag): `full` enforces the ladder, `lite` builds what's asked and names the lazier alternative, `ultra` is YAGNI-extremist (deletion before addition, challenge the requirement). Non-developers get no dial — `ultra` is wrong for prose artifacts, so the ethos runs a fixed restrained stance. Prompt-only: no hooks, no grok-path changes — ponytail adds no hook surface, so bundling it under the Fable flag changes only the composed prompt, not the spawn hooks. The Fable flag's description in `roboco/config.py` names both doctrines.
+**Golden-task eval harness (source-checkout-only offline CLI).** `roboco/eval/` replays a fixed set of `BenchTaskSpec` fixtures (`roboco/eval/fixtures.py`) through the REAL delivery lifecycle in a disposable environment reused from `tests/e2e_smoke/harness.py` (fake GitHub REST, a real local git origin, a throwaway DB) — real isolation, not a mock. `EvalRunner.run_cohort` (`roboco/eval/runner.py`) scores each fixture on deterministic metrics (final status, `revision_count`, cycle time, tokens/cost via the `agent_spawn_sessions` task_id join) plus a local-model judge comparing the final PR diff + notes against the fixture's checked-in expectations, nested under a `"non_deterministic": true`-marked `"judge"` object so a naive cohort diff never mistakes judge noise for a real regression. `agent_spawn_sessions.doctrine_version` (migration 081) is stamped at spawn-session finalize from the composed prompt layers, so a cohort's model + doctrine combination (e.g. Fable-mode on vs. off) is durably identifiable after the fact. **Real-spawn is cut for this release**: `OrchestratorStageSpawner` raises `NotImplementedError` at construction — a real spawn's MCP wiring would resolve to the REAL production orchestrator under real agent UUIDs, unsafe for a bench run — so `python -m roboco.eval run` is wired but not yet functional; the only working path today is driving `EvalRunner` with an injected scripted `StageSpawner` from Python (see `tests/e2e_smoke/test_eval_bench.py`). Scoped to developer-role fixtures only (`run_cohort` refuses any other role) and only runs from a source checkout (`tests/e2e_smoke` isn't shipped in containers or wheels); bench runs also patch every vault flag off so a bench task/note/journal write never lands in the operator's real Obsidian vault.
+
**Env-branches ladder + EnvSyncEngine (default-off `ROBOCO_ENV_SYNC_ENABLED`).** Replaces a project's single `default_branch` with an ordered environment ladder: nullable `projects.environments` JSONB (migration 073), an ordered `list[{name, branch}]` where index 0 is the **head** rung (where dev/cell/leaf PRs land) and index -1 is the **prod** rung (where the gated release executor commits + tags); middle rungs are intermediates (qa/stag). A null ladder degenerates to a single-branch ladder synthesized from `default_branch` at read time (`roboco/models/env_branches.py`: `head_branch` / `prod_branch` / `ladder_pairs` / `promotion_chain`) — no backfill, byte-for-byte legacy behavior until the CEO declares a real split. Every former `default_branch` consumer now routes through the shim: the PR target and per-agent clone (`WorkspaceService.ensure_workspace` / `ensure_read_clone`), the CI branch, the release executor's clone/commit/tag target (`_ReleaseContext.prod_branch`) plus its full-chain head→…→prod promotion before bumping (`promote_env_chain`, fail-closed `promotion_failed` on a merge conflict), and `release_readiness`'s diff baseline (`prod..head` instead of `last_tag..HEAD`) with a tag-drift cross-check (`_tag_drift_gaps` — the last tag's commit vs. prod tip disagreeing flags a hotfix that landed on prod after the tag). `EnvSyncEngine` (`roboco/services/env_sync_engine.py`) cascades the ladder prod→…→head via GitHub's merges API: a clean merge auto-pushes straight to the lower rung, a conflict opens ONE idempotent sync PR + a Main-PM coordination task and stops that project's cascade for the cycle — the cascade's target is never the prod rung by construction, so "only the CEO merges master" still holds. Bounded + deduped per repo (one open env_sync task at a time). Panel: an environment-ladder editor on the project edit dialog.
**Telegram notifications bridge V1+V2+V3 (default-off `ROBOCO_TELEGRAM_ENABLED`).** V1: best-effort, outbound-only Telegram DMs to the CEO on escalation and completion. Mirrors the `x_credentials` pattern: a singleton Fernet-encrypted `telegram_credentials` row (migration 074, bot token + chat id; the API returns `has_credentials` only) behind CEO-only `/telegram/credentials` routes and a panel credentials card. `_notify_telegram` (`roboco/services/notification_delivery.py`) fans out from `notify_ceo_of_escalation` / `notify_ceo_of_completion`, sending only the notification's subject plus an optional panel deep-link (`panel_base_url`) — never the body — via a deferred, best-effort send that never raises into the producer (`NullTelegramClient` when unconfigured or the flag is off, `LiveTelegramClient` posting to the Bot API otherwise). V2 (`ROBOCO_TELEGRAM_INBOUND_ENABLED`, sub-switch on top of V1's flag — both plus stored credentials are required, otherwise the bot only sends and never listens) makes the bridge two-way: `TelegramInboundEngine` (`roboco/services/telegram_inbound.py`) long-polls `getUpdates` from a dedicated orchestrator loop (`_telegram_poll_loop`), authorizing every update by BOTH chat id and sender id, and routes `/status` / `/queue` / `/task` commands plus `Approve`/`Reject`/`Open` inline-keyboard taps (a compact `apv|rej::` callback codec; a reject reason or a task-approve note is collected via a force_reply prompt held in a TTL'd in-memory pending-action map) through the SAME CEO-gated service calls the HTTP routes make (task/release/xpost/video/roadmap), stamping a `via=telegram` audit row on each. Escalation DMs (not completion DMs) carry the actionable keyboard when V2 is armed. All bot/bridge messages are HTML-styled (`parse_mode=HTML` with mandatory `_esc`/`_esc_attr` escaping at every dynamic interpolation and balance-aware 4096 truncation — the injection posture moved from no-parse_mode to escaping discipline), and every held-draft origination (release proposal, X post, video post, roadmap item via `propose_roadmap`) pushes a styled DM with its Approve/Reject keyboard the moment it materializes (`notify_ceo_of_queue_item`, best-effort, sharing `/queue`'s renderer). Closing the loop exposed a real hole: a stale Approve/Reject button targets its item by id regardless of current status, so `ReleaseProposalService.approve`/`.reject`, `XPostService.approve`, and `VideoPostService.approve` now all refuse an already-CANCELLED (rejected) or already-COMPLETED (published/posted) target instead of silently re-executing — a fix that also closes the identical hole via a replayed HTTP call, not just Telegram. V3 adds a Telegram **Mini App** sign-in: `POST /api/telegram/webapp-auth` (`roboco/api/routes/telegram.py`, mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` are both armed — `telegram_miniapp_enabled` is env-only like `cloud_auth_enabled`, deliberately off the panel feature-flags card, and fails loud at startup if armed without cloud auth on) validates Telegram's signed `initData` (`roboco/utils/telegram_initdata.py` — pure HMAC-SHA256 `WebAppData`-keyed validation, constant-time compare, a `telegram_initdata_max_age_seconds` freshness window with 60s clock-skew tolerance) against the stored bot token and the CEO's own `chat_id`, then mints the same cloud-auth session cookie `/api/auth/login` issues — turning the CEO's phone into a real panel client at the new `(tg)` route group (`/tg`: Approvals/Inbox/Board/Chat tabs, outside the normal dashboard shell; `proxy.ts`'s matcher excludes `tg(?:/|$)` so a phone session is never bounced to the password `/login` page it can't reach). Requires a public HTTPS origin (the cookie is secure-only) and BotFather's `/setmenubutton` pointed at `https:///tg`. **V4 (Mini App V4)** rebuilds the cockpit and the command tier on both sides. Panel: the `(tg)` surface opens on a "Today" brief (`GET /api/telegram/today`, CEO-gated, one DB-only round trip via `TgCockpitService` — needs-you items, held-draft counts, fleet with per-agent task titles, day-rollup spend, ship state), the Approvals tab is a native card stack over all four held-draft queues (MainButton/BackButton/haptics with visible fallbacks; X 280-counter editing, blob-fetched video player, per-AC release view; a failed queue source is surfaced, never rendered as "queue is clear"), Chat/Today ride the shared `/ws/system` socket (invalidate-on-frame, poll fallback), theme adopts the user's Telegram `themeParams` scoped to `#tg-shell`, a dev-only mock bridge + `/tg?demo=1` fixtures make the whole surface workable in a plain browser, and shared primitives (`panel/src/components/tg/ui.tsx`) carry the visual language. Bot: `BOT_COMMANDS` is the single registry driving `/help` AND a once-per-process Bot API `setMyCommands` sync; `/agents` `/usage` `/blocked` join the read tier, and `/secretary` + `/newtask` bridge the chat into the SAME in-process live runtimes the panel drives (`roboco/services/telegram_bridge.py`): a per-chat consumer task drains the `PrompterLiveRegistry` stream (sole consumer — no sync reply seam exists) and pushes one Telegram message per `turn_end`; free text routes into the live session; a `draft` event becomes a Send-to-Board/Discard keyboard whose confirm runs `PrompterService.confirm_live_draft(route="board")` and PARKS the session so board feedback streams back into the thread; `/end` reaps; the bridge sweeps its own idle TTL (the held stream arms the registry keepalive, so the registry's reap never fires), parked sessions exempt. Intake/secretary containers are process-wide singletons, so a bridged session preempts a live panel session of the same kind by construction; MegaTask batches still confirm in the panel only. **V6 (Mini App V6)** is the premium overhaul: a native-type design system on the `#tg-shell` tokens (borderless elevated cards, wallet-style tabular-numeral heroes, floating dock; Share Tech Mono demoted to the `ROBOCO_` wordmark only) with Telegram window-chrome painting riding the theme bridge (`setHeaderColor`/`setBackgroundColor`/`setBottomBarColor`); Inbox moves behind a header bell as a pushed sub-page with humanized notifications (UUIDs resolve to task names via the Board's shared task index, `tg-format.tsx`); a new Metrics tab (period-segmented spend hero + by-agent/team/model + delivery/efficiency; tapping an agent pushes a drilldown over the previously-untapped `/usage/time-series?agent_slug` plus the member scorecard); Chat is rebuilt with honest scopes — Mine rides the participant-scoped `/a2a/chat/conversations` (resolved peer, real unread counts, mark-read on open, plain CEO send) while Fleet rides the admin list (task-linked threads interject via `replyAsCeo` with a recipient chip, task-less threads are watch-only), both with markdown transcripts, live pulse flashes, and a pinned **Secretary** live chat on the same `secretary_live` SSE session runtime the panel drives; and the Board task sheet carries the CEO's own decide verbs (approve / request-changes / unblock) instead of being read-only. The `/api/dashboard/*` router is now `require_panel_token`-gated at router level (mirroring `/api/usage`), closing the unauthenticated metrics/scorecard exposure.
@@ -428,9 +440,11 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
**Delegation detail-fidelity (always-on, 2026-07-16).** Details no longer thin out at hand-off in either direction. DOWN: `delegate` refuses any child that doesn't declare `covers_parent_criteria` mapping onto the parent's real acceptance criteria (matched by id or exact text; an unresolvable ref is rejected naming the valid criteria, never silently dropped — previously the mapping was optional and coverage surfaced only at `submit_up`'s roll-up gate, after the whole wave had already run); the success envelope carries `parent_ac_coverage` `{covered, uncovered}` so a wave-planning PM sees remaining gaps in the same turn, while multi-wave planning stays legal. UP: `pass_review` requires `criteria_verified` — one `{criterion, evidence}` entry per task acceptance criterion (the findings ledger's id-or-exact-text matcher, soup-checked and length-capped evidence), rejecting with the unverified criteria named; entries render deterministically into `qa_notes` as `[AC] — verified: ` lines, so a gestalt "looks good" pass is structurally impossible. Video briefs stopped being prose-only: an enumerable feature list (release `highlights`, or `input_props.highlights` carried onto a reject re-author) becomes its own acceptance criterion ("Every brief-named feature appears as its own fully readable scene: …", bounded to the AC caps; a re-author without highlights carries "every point in the CEO rejection feedback is visibly addressed"), so the dropped-scene class — a four-feature brief shipping three scenes past every gate — is caught by the QA per-AC stamp instead of the CEO's eyeball.
+**Task/project cost budgets (default-off `ROBOCO_TASK_BUDGETS_ENABLED`).** `tasks.budget_usd` + `projects.monthly_budget_usd` (migration 080). Claim-time: a project-month-spend guard (`project_budget_exceeded_guard`, `roboco/services/gateway/claim_guards.py`) applies only to WORK-STARTING claims (`i_will_work_on` / `i_will_plan`) — review/doc/gate/inbound-PR claims are exempt so in-flight work can always finish reviewing and merging even at cap; spend counts closed sessions' `estimated_cost_usd` plus open sessions priced live from token snapshots. Sweep-side: the orchestrator's existing budget sweep also prices the active task's own spend against `budget_usd` (falling back to a `TaskType` default, `effective_task_budget_usd` in `roboco/foundation/policy/agent_loop.py`, when null); on breach the task is BLOCKED (HUMAN resolver, budget marker) BEFORE the graceful stop so the unclaim no-ops and the dispatcher never respawns onto it, and the CEO notification names both recovery steps — `unblock` on a budget-blocked task re-checks live spend and refuses while still over, so there's no silent re-breach loop. Off => neither cap is ever consulted regardless of field values. Panel: budget inputs on both the project and task dialogs (a `0` is rejected — it would silently block everything); spend math is consolidated in `TaskService.task_spend_usd`.
+
**PR labeler (always-on).** `derive_pr_labels` (`roboco/foundation/policy/pr_labels.py`, pure) derives the org-structure label vocabulary every fleet PR now carries: `to {base_branch}` — the PR's REAL resolved target branch, verbatim (never assumed from `is_root_pr`, so a project with a renamed/non-standard trunk or an env-ladder rung gets an accurate label instead of a hardcoded `master`/`slave`), `root` for an assembled root PR, `MegaTask` for a batch-carrying task, and a layer label (`main-pm` for a Main-PM coordination root, `cell/{team}` for a cell-assembled PR, else `subtask/{team}` for a leaf dev PR). Applied best-effort at all three PR-opening sites in `GitService` so a human triaging the PR queue sees which tree and which org layer a PR belongs to at a glance.
-**Feature flags / company-in-a-box.** Env-gated, default-off subsystems toggle from the panel's Settings → Feature Flags card (`panel/src/components/settings/feature-flags-card.tsx`) instead of hand-editing env: web research (`ROBOCO_RESEARCH_ENABLED`), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`), pitch provisioning (`ROBOCO_PROVISIONING_*`), external / internal PR review, the agent-runtime toolchain match (`ROBOCO_TOOLCHAIN_MATCH_ENABLED`), the architectural-conventions standard (`ROBOCO_CONVENTIONS_ENABLED`), gateway-health recovery (`ROBOCO_GATEWAY_HEALTH_ENABLED`), multi-repo CI-watch (`ROBOCO_CI_WATCH_ENABLED`), the dependency-update bot (`ROBOCO_DEP_UPDATE_ENABLED`), the gated release manager (`ROBOCO_RELEASE_MANAGER_ENABLED`), the organizational memory loop (`ROBOCO_ORG_MEMORY_ENABLED`), the sandboxed dev DB/Redis (`ROBOCO_SANDBOX_DB_ENABLED`), the RoboCo X account (`ROBOCO_X_ENGINE_ENABLED`), the RoboCo video engine (`ROBOCO_VIDEO_ENGINE_ENABLED`), the board roadmap engine (`ROBOCO_ROADMAP_ENGINE_ENABLED`), Fable-mode (`ROBOCO_FABLE_MODE_ENABLED`), the vault weekly report + KB ingest (`ROBOCO_VAULT_REPORT_ENABLED` / `ROBOCO_VAULT_KB_ENABLED`), the env-sync cascade (`ROBOCO_ENV_SYNC_ENABLED`), the Telegram notifications bridge (`ROBOCO_TELEGRAM_ENABLED`, + inbound commands/actionable buttons sub-switch `ROBOCO_TELEGRAM_INBOUND_ENABLED`), the possibilities matrix (`ROBOCO_POSSIBILITIES_MATRIX_ENABLED`), the docs-divergence sync (`ROBOCO_DOCS_SYNC_ENABLED`), and the self-heal flags above. Cloud auth (`ROBOCO_CLOUD_AUTH_ENABLED`) is deliberately NOT on this card — like `ROBOCO_DB_NETWORK_ISOLATED`, it's a compose/env-coupled flag a runtime toggle can't safely flip mid-session. A toggle persists in the settings store and takes effect on the next backend restart; an unset flag falls back to its environment / config default.
+**Feature flags / company-in-a-box.** Env-gated, default-off subsystems toggle from the panel's Settings → Feature Flags card (`panel/src/components/settings/feature-flags-card.tsx`) instead of hand-editing env: web research (`ROBOCO_RESEARCH_ENABLED`), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`), pitch provisioning (`ROBOCO_PROVISIONING_*`), external / internal PR review, the agent-runtime toolchain match (`ROBOCO_TOOLCHAIN_MATCH_ENABLED`), the architectural-conventions standard (`ROBOCO_CONVENTIONS_ENABLED`), gateway-health recovery (`ROBOCO_GATEWAY_HEALTH_ENABLED`), multi-repo CI-watch (`ROBOCO_CI_WATCH_ENABLED`), the dependency-update bot (`ROBOCO_DEP_UPDATE_ENABLED`), the gated release manager (`ROBOCO_RELEASE_MANAGER_ENABLED`), the organizational memory loop (`ROBOCO_ORG_MEMORY_ENABLED`), the sandboxed dev DB/Redis (`ROBOCO_SANDBOX_DB_ENABLED`), the RoboCo X account (`ROBOCO_X_ENGINE_ENABLED`), the RoboCo video engine (`ROBOCO_VIDEO_ENGINE_ENABLED`), the board roadmap engine (`ROBOCO_ROADMAP_ENGINE_ENABLED`), Fable-mode (`ROBOCO_FABLE_MODE_ENABLED`), the vault weekly report + KB ingest (`ROBOCO_VAULT_REPORT_ENABLED` / `ROBOCO_VAULT_KB_ENABLED`), the env-sync cascade (`ROBOCO_ENV_SYNC_ENABLED`), the Telegram notifications bridge (`ROBOCO_TELEGRAM_ENABLED`, + inbound commands/actionable buttons sub-switch `ROBOCO_TELEGRAM_INBOUND_ENABLED`), the possibilities matrix (`ROBOCO_POSSIBILITIES_MATRIX_ENABLED`), the docs-divergence sync (`ROBOCO_DOCS_SYNC_ENABLED`), task/project cost budgets (`ROBOCO_TASK_BUDGETS_ENABLED`), and the self-heal flags above. Cloud auth (`ROBOCO_CLOUD_AUTH_ENABLED`) is deliberately NOT on this card — like `ROBOCO_DB_NETWORK_ISOLATED`, it's a compose/env-coupled flag a runtime toggle can't safely flip mid-session. A toggle persists in the settings store and takes effect on the next backend restart; an unset flag falls back to its environment / config default.
## Architectural Conventions Standard
@@ -536,6 +550,10 @@ The system runs as Docker Compose services. All Dockerfiles live under `docker/`
| `panel` | Next.js control panel (internal, port 3000) | — |
| `nginx` | Reverse proxy fronting panel + orchestrator | — |
+### Quickstart (registry pull-and-run)
+
+`make quickstart` runs `scripts/bootstrap.sh`: idempotent one-command bring-up for the pull-and-run deploy (`docker-compose.registry.yml`). A fresh `.env` is copied from `.env.example` and the three required secrets are injected using the documented one-liners (the panel token via the exact HMAC formula `issue_panel_token` uses), with a standing-credential warning (louder if cloud auth is detected) — compose's `:?` guard refuses an empty token unconditionally. A reused `.env` is never touched, but the three required vars are pre-validated with pointed remedies instead of compose's opaque interpolation error. It then pulls + `up -d` + runs a doctor-style readiness sweep grounded in real surfaces (root `/health`, `/api/auth/status` through nginx, the verbatim "Alembic upgrade finished" log line, `ollama list`), each stage failing loud with the exact command to run next. `.github/workflows/release.yml`'s `pull-smoke` job (fresh runner, own GHCR login, needs `publish-images`) literally pulls the registry compose against the just-published tag on every release — guarding the missing-image regression class that already happened once.
+
### Single Entry Point
`nginx` is the only externally-exposed service. It listens on `localhost:3000` and routes:
diff --git a/alembic/versions/086_enable_gemini_provider.py b/alembic/versions/086_enable_gemini_provider.py
new file mode 100644
index 00000000..fe3e0757
--- /dev/null
+++ b/alembic/versions/086_enable_gemini_provider.py
@@ -0,0 +1,49 @@
+"""Flip the Gemini (Google) provider row to enabled=true.
+
+Migration 085 seeded the row `enabled=false`, pending an operator OAuth
+setup — but nothing ever flipped it. Unlike Grok (enabled=true only via the
+`apply_mode="grok"` write path, which force-enables the row at apply time),
+Gemini had no equivalent enable step at all: `apply_mode` grew no "gemini"
+case until this same change, so any Mix-mode assignment to a Gemini model
+resolved through `resolve_for_agent` against a permanently-disabled row and
+silently fell back to the legacy Anthropic path — the provider was wired
+end-to-end everywhere except reachable.
+
+Codex (migration 083, `083_seed_openai_provider`) is the closer parity
+target: both are subscription-CLI providers with no API key to withhold
+behind a disabled row (`~/.codex` / `~/.gemini`, mounted OAuth/subscription
+credentials, not a stored token), and Codex seeds `enabled=true` directly for
+exactly that reason. This migration brings Gemini to the same state via an
+in-place `UPDATE` (the row already exists — no enum touched, no INSERT).
+
+Revision ID: 086_enable_gemini_provider
+Revises: 085_seed_gemini_provider
+Create Date: 2026-07-23
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "086_enable_gemini_provider"
+down_revision = "085_seed_gemini_provider"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute(
+ sa.text(
+ "UPDATE provider_configs SET enabled = true WHERE name = 'Gemini (Google)'"
+ )
+ )
+
+
+def downgrade() -> None:
+ # Honest revert — back to the state migration 085 left it in, not a no-op.
+ op.execute(
+ sa.text(
+ "UPDATE provider_configs SET enabled = false WHERE name = 'Gemini (Google)'"
+ )
+ )
diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml
index 8979b1a3..06dec679 100644
--- a/docker-compose.registry.yml
+++ b/docker-compose.registry.yml
@@ -447,6 +447,14 @@ services:
# omitted — inert while guard stays off by default, but reaches the
# container the moment an operator arms guard by hand-editing this file.
ROBOCO_GUARD_EMERGENCY_WHITELIST: ${ROBOCO_GUARD_EMERGENCY_WHITELIST:-}
+ # Trusted local-proxy hop IPs (docker bridge gateway) for the XFF
+ # real-client resolver — carried here (inert until guard is armed) so a
+ # gateway-fronted Tailscale Serve deploy can set it via .env rather than
+ # hand-editing this file. Same reach-the-container rule as above.
+ ROBOCO_GUARD_TRUSTED_CHAIN_PEERS: ${ROBOCO_GUARD_TRUSTED_CHAIN_PEERS:-}
+ # Per-task/project cost budgets (default-off; conservative registry
+ # posture, unlike the build compose which arms it).
+ ROBOCO_TASK_BUDGETS_ENABLED: ${ROBOCO_TASK_BUDGETS_ENABLED:-false}
# Cloud auth (FastAPI Users): login-gates the panel/API when exposed
# beyond localhost. OFF by default (matches config default, unlike the
# build compose which arms it for the personal deploy). Set
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 8df9289a..1692ce90 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -692,6 +692,15 @@ services:
# setting it in .env silently does nothing — only vars listed in this
# stanza reach the container.
ROBOCO_GUARD_EMERGENCY_WHITELIST: ${ROBOCO_GUARD_EMERGENCY_WHITELIST:-}
+ # Trusted local-proxy hop IPs (docker bridge gateway) for the XFF
+ # real-client resolver — set to the bridge gateway when Tailscale Serve
+ # is gateway-fronted, else the guard sees a whitelisted hop and the WAF
+ # goes inert for /tg. Same "must be listed here to reach the container"
+ # rule as the emergency whitelist above.
+ ROBOCO_GUARD_TRUSTED_CHAIN_PEERS: ${ROBOCO_GUARD_TRUSTED_CHAIN_PEERS:-}
+ # Per-task/project cost budgets (default-off subsystem; also on the
+ # panel feature-flags card).
+ ROBOCO_TASK_BUDGETS_ENABLED: ${ROBOCO_TASK_BUDGETS_ENABLED:-true}
volumes:
# Docker socket - allows spawning agent containers
- /var/run/docker.sock:/var/run/docker.sock
diff --git a/docker-compose.yml b/docker-compose.yml
index 8df9289a..1692ce90 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -692,6 +692,15 @@ services:
# setting it in .env silently does nothing — only vars listed in this
# stanza reach the container.
ROBOCO_GUARD_EMERGENCY_WHITELIST: ${ROBOCO_GUARD_EMERGENCY_WHITELIST:-}
+ # Trusted local-proxy hop IPs (docker bridge gateway) for the XFF
+ # real-client resolver — set to the bridge gateway when Tailscale Serve
+ # is gateway-fronted, else the guard sees a whitelisted hop and the WAF
+ # goes inert for /tg. Same "must be listed here to reach the container"
+ # rule as the emergency whitelist above.
+ ROBOCO_GUARD_TRUSTED_CHAIN_PEERS: ${ROBOCO_GUARD_TRUSTED_CHAIN_PEERS:-}
+ # Per-task/project cost budgets (default-off subsystem; also on the
+ # panel feature-flags card).
+ ROBOCO_TASK_BUDGETS_ENABLED: ${ROBOCO_TASK_BUDGETS_ENABLED:-true}
volumes:
# Docker socket - allows spawning agent containers
- /var/run/docker.sock:/var/run/docker.sock
diff --git a/docs/map/runtime-providers.md b/docs/map/runtime-providers.md
index 6e32f33d..d40baf14 100644
--- a/docs/map/runtime-providers.md
+++ b/docs/map/runtime-providers.md
@@ -1,5 +1,5 @@
## Purpose
-This slice is the agent-runtime + LLM-provider seam plus the in-container agent SDK. The provider layer (roboco/llm/providers/) abstracts how agents are spawned/stopped/health-checked/removed across LLM backends (Claude Code default, Grok CLI) behind an AgentProvider ABC + ProviderRegistry, with a Grok auth-token refresh loop keeping the SuperGrok credential live. The agent SDK (roboco/agent_sdk/) is the FastAPI sidecar running inside every agent container handling A2A messaging, tool-budget/loop/verb-circuit breakers, token-usage capture, and the interactive intake/secretary chat drivers (Claude SDK + Grok CLI). The runtime helpers (spawn_manifest, streaming, transcript_retention) build the per-role tool manifest, wire reasoning-stream callbacks, and select old agent transcripts to prune.
+This slice is the agent-runtime + LLM-provider seam plus the in-container agent SDK. The provider layer (roboco/llm/providers/) abstracts how agents are spawned/stopped/health-checked/removed across LLM backends (Claude Code default, Grok CLI, Codex CLI, Gemini CLI) behind an AgentProvider ABC + ProviderRegistry, with a Grok auth-token refresh loop keeping the SuperGrok credential live and an orchestrator-side Codex refresh loop keeping the ChatGPT-subscription credential live (Gemini needs neither — its OAuth refresh token is reusable, so each container refreshes its own local copy in-process). The agent SDK (roboco/agent_sdk/) is the FastAPI sidecar running inside every agent container handling A2A messaging, tool-budget/loop/verb-circuit breakers, token-usage capture, and the interactive intake/secretary chat drivers (Claude SDK + Grok CLI — Codex and Gemini are one-shot delivery roles only, no interactive intake/secretary support). The runtime helpers (spawn_manifest, streaming, transcript_retention) build the per-role tool manifest, wire reasoning-stream callbacks, and select old agent transcripts to prune.
## Files
@@ -23,6 +23,14 @@ This slice is the agent-runtime + LLM-provider seam plus the in-container agent
| roboco/llm/providers/grok_auth.py | SuperGrok token refresh-token grant loop + --check backstop CLI; atomic auth.json rewrite | 317 |
| roboco/llm/providers/grok_cli_config.py | Entrypoint renderer: mcp-config -> ~/.grok/config.toml, per-role grok flags, AGENTS.md, bash-guard hook, + default-off fable-mode honesty-nudge hook | 317 |
| roboco/llm/providers/grok_cli_usage.py | Capture token usage from grok sessions/updates.jsonl -> usage.json (notional cost) | 201 |
+| roboco/llm/providers/codex.py | CodexCliProvider: spawns roboco-agent-codex container, mounts ~/.codex dir (RO) + usage dir + codex env | 239 |
+| roboco/llm/providers/codex_auth.py | ChatGPT-subscription refresh-token grant loop + --check backstop CLI; JWT-exp decode (only expiry signal), atomic auth.json rewrite | 298 |
+| roboco/llm/providers/codex_cli_config.py | Entrypoint renderer: mcp-config -> ~/.codex/config.toml, Starlark execpolicy deny rules, per-role --sandbox level, combined system+task prompt (no verified system-prompt-file mechanism) | 277 |
+| roboco/llm/providers/codex_cli_usage.py | Capture token usage from codex exec --json turn.completed events -> usage.json (real input/output/cache-read/cache-write split, priced per-bucket) | 184 |
+| roboco/llm/providers/codex_cli_sniff.py | Classify a codex run's terminal state (rate_limit/auth/none) from ONLY structured error.message JSONL fields + stderr, never the model's own transcript | 124 |
+| roboco/llm/providers/gemini.py | GeminiCliProvider: spawns roboco-agent-gemini container, copies host ~/.gemini OAuth creds into a container-local writable copy + usage dir + gemini env | 264 |
+| roboco/llm/providers/gemini_cli_config.py | Entrypoint renderer: mcp-config -> ~/.gemini/settings.json + per-role TOML Policy Engine deny rules (no native tool-removal flag), GEMINI.md blueprint | 293 |
+| roboco/llm/providers/gemini_cli_usage.py | Capture token usage from gemini --output-format stream-json terminal result event -> usage.json (per-GA-model pricing); remaps quota/rate-limit errors to exit 75 | 290 |
| roboco/agent_sdk/__init__.py | Package docstring only | 10 |
| roboco/agent_sdk/models.py | Pydantic models: A2A messages, budget/terminal/verb-circuit/token-usage request+status | 258 |
| roboco/agent_sdk/prompt_guard.py | Prompt-injection detector (5 patterns) + CLI for grok entrypoint turn scan | 93 |
diff --git a/docs/rag/architecture/http-security-guard.md b/docs/rag/architecture/http-security-guard.md
index bdbdd1eb..b781db43 100644
--- a/docs/rag/architecture/http-security-guard.md
+++ b/docs/rag/architecture/http-security-guard.md
@@ -10,6 +10,8 @@ RoboCo's HTTP request layer is protected by `fastapi-guard` (v7.2.1), implemente
|----------|---------|--------|
| `ROBOCO_GUARD_ENABLED` | `false` | Master switch. Off = completely inert — no middleware is mounted, the request path is entirely unchanged, and nothing is logged or blocked. |
| `ROBOCO_GUARD_PASSIVE_MODE` | see below | When the guard is enabled, controls whether it blocks matching requests or only logs them. |
+| `ROBOCO_GUARD_EMERGENCY_WHITELIST` | `` (empty) | Comma-separated IPs/CIDRs always allowed through in an active `ROBOCO_GUARD_EMERGENCY` lockdown, in addition to loopback. Empty = loopback only. |
+| `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` | `` (empty) | Comma-separated exact IP address(es) — never a CIDR range — trusted to appear as a recorded proxy hop inside `X-Forwarded-For` beyond loopback, e.g. the docker bridge gateway a host-proxied Tailscale Serve chain terminates behind, so the resolved client is the real tailnet/LAN peer instead of that hop's own address. Empty = only a loopback rightmost hop ever peels. |
As of 2026-07-19 the guard is gated off by default in config, but the NAS build compose arms it ON in ACTIVE enforcement (`ROBOCO_GUARD_PASSIVE_MODE=false`) — passive/log-only calibration came back clean, and the CEO approved the flip now that cloud auth + Tailscale are armed. A matching request on that deploy is actually blocked, not just logged. The registry compose still ships it fully off (see Enforcement Posture below).
diff --git a/docs/rag/tools/messaging-tools.md b/docs/rag/tools/messaging-tools.md
index 72220aa5..5db531a6 100644
--- a/docs/rag/tools/messaging-tools.md
+++ b/docs/rag/tools/messaging-tools.md
@@ -25,3 +25,7 @@ notify_ack(notification_id) # acknowledge after handling
```
When `i_am_idle()` reports unread A2A or @mentions, clear A2A with `read_a2a()` (see `a2a-tools.md`) and clear notifications with list -> get -> ack, then idle again. (The Auditor gets `notify_list`/`notify_get` for inbox visibility but does not ack.)
+
+## Unacked notifications re-escalate
+
+An ack-required `notify` left unacked past its `expires_at` is re-escalated to the recipient's up-role (your PM's PM, or the CEO) — but not on every sweep tick. The first re-escalation fires at expiry, each one after that doubles the wait (1h, 2h, 4h, ... capped at 24h), and after a fixed number of attempts it stops and is logged as permanently unacked. Acking promptly is the only way to stop the clock — there is no way to snooze or dismiss a notification other than `notify_ack`.
diff --git a/docs/rag/workflows/task-claiming.md b/docs/rag/workflows/task-claiming.md
index 4477907a..23e6c276 100644
--- a/docs/rag/workflows/task-claiming.md
+++ b/docs/rag/workflows/task-claiming.md
@@ -50,6 +50,7 @@ If your task has `dependency_ids` in the same repo, the fresh branch cut also ba
- **Self-documentation prevention**: Documenter cannot claim tasks they developed
- **Branch requirement**: Branch auto-created on `i_will_work_on`
- **Sequence order (strict, assignee-blind)**: if a task has a parent and a `sequence` number, it cannot be claimed while any sibling with a strictly lower sequence is still non-terminal — regardless of who owns which task. Siblings on the SAME sequence run in parallel (independent work ties at 0, or at the wave a delegating PM stamped from the collision graph). This is independent of, and stricter than, `dependency_ids`: a claim attempt on a sequence-held task fails even with no unmet dependency. The error names the blocking sibling by title — `unclaim`/wait is the only remedy, there is no override verb. The dispatcher pre-filters sequence-held (and dependency-held) tasks before attempting a claim, so you should rarely see this in practice — but a claim you make directly (rather than via `give_me_work`) can still hit it.
+- **Project budget cap** (when task budgets are armed): `i_will_work_on` / `i_will_plan` are refused once the project's `monthly_budget_usd` has been reached this calendar month — a WORK-STARTING claim only, so a QA/doc/PR-review/PM-merge claim on already-in-flight work is never blocked by this. There is no override; wait for the next month or ask the CEO to raise the cap.
## Releasing a Claimed Task
diff --git a/panel/src/components/projects/__tests__/edit-project-dialog.test.tsx b/panel/src/components/projects/__tests__/edit-project-dialog.test.tsx
index 3183868d..152906eb 100644
--- a/panel/src/components/projects/__tests__/edit-project-dialog.test.tsx
+++ b/panel/src/components/projects/__tests__/edit-project-dialog.test.tsx
@@ -489,4 +489,34 @@ describe("EditProjectDialog — Monthly Budget (USD)", () => {
};
expect(call.updates.monthly_budget_usd).toBe(100);
});
+
+ it("shows this month's spend against the cap when monthly_spend_usd is present", async () => {
+ renderDialog(
+ makeProject({ monthly_budget_usd: 100, monthly_spend_usd: 42.5 }),
+ );
+ await screen.findByRole("button", { name: /Save Changes/i });
+ openAutonomySection();
+
+ expect(screen.getByTestId("project-spend").textContent).toBe(
+ "Spent: $42.50 this month / $100.00",
+ );
+ });
+
+ it("hides the ratio (but still shows spend) when there is no monthly cap", async () => {
+ renderDialog(makeProject({ monthly_budget_usd: null, monthly_spend_usd: 10 }));
+ await screen.findByRole("button", { name: /Save Changes/i });
+ openAutonomySection();
+
+ expect(screen.getByTestId("project-spend").textContent).toBe(
+ "Spent: $10.00 this month",
+ );
+ });
+
+ it("hides the spend line entirely when monthly_spend_usd is absent (flag off)", async () => {
+ renderDialog(makeProject({ monthly_budget_usd: 100, monthly_spend_usd: null }));
+ await screen.findByRole("button", { name: /Save Changes/i });
+ openAutonomySection();
+
+ expect(screen.queryByTestId("project-spend")).toBeNull();
+ });
});
diff --git a/panel/src/components/projects/edit-project-dialog.tsx b/panel/src/components/projects/edit-project-dialog.tsx
index 2d9c0750..be8fd63e 100644
--- a/panel/src/components/projects/edit-project-dialog.tsx
+++ b/panel/src/components/projects/edit-project-dialog.tsx
@@ -833,6 +833,15 @@ function EditProjectForm({
Must be greater than 0 — a 0 budget would block every claim
immediately. Leave blank for no cap.
diff --git a/panel/src/components/settings/__tests__/ai-routing-card.test.tsx b/panel/src/components/settings/__tests__/ai-routing-card.test.tsx
index c0c2eb38..568511d4 100644
--- a/panel/src/components/settings/__tests__/ai-routing-card.test.tsx
+++ b/panel/src/components/settings/__tests__/ai-routing-card.test.tsx
@@ -50,6 +50,11 @@ const {
provider_type: "openai",
display_name: "GPT-5.3 Codex",
},
+ {
+ model_name: "gemini-2.5-pro",
+ provider_type: "gemini",
+ display_name: "Gemini 2.5 Pro",
+ },
]),
getOllamaKey: vi.fn(async () => ({ has_key: false, enabled: true })),
setOllamaKey: vi.fn(async () => ({ has_key: true, enabled: true })),
@@ -387,6 +392,13 @@ function withQueryClient(ui: ReactNode) {
return {ui};
}
+// Finds a per-agent Mix row's container div by its agent-id text. `.closest`
+// on a non-tag-name CSS selector types as `Element | null`, not `HTMLElement`
+// — cast once here rather than at every call site.
+function mixRowFor(agentId: string): HTMLElement {
+ return screen.getByText(agentId).closest("div.grid") as HTMLElement;
+}
+
describe("AIRoutingCard", () => {
beforeEach(() => {
catalog.mockClear();
@@ -761,6 +773,102 @@ describe("AIRoutingCard", () => {
});
});
+ // -------------------------------------------------------------------------
+ // Codex and Gemini mode buttons + Mix picker visibility (the headline gap:
+ // both were built but unreachable from the panel — no apply-mode card, no
+ // Mix group).
+ // -------------------------------------------------------------------------
+
+ describe("Codex and Gemini mode buttons", () => {
+ it("renders the Codex button and applies mode='codex' on confirm", async () => {
+ const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
+ render(withQueryClient());
+ await screen.findByText("Grok (xAI) API key");
+
+ fireEvent.click(screen.getByText("Codex"));
+
+ await waitFor(() =>
+ expect(applyMode).toHaveBeenCalledWith({ mode: "codex" }),
+ );
+ confirmSpy.mockRestore();
+ });
+
+ it("renders the Gemini button and applies mode='gemini' on confirm", async () => {
+ const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
+ render(withQueryClient());
+ await screen.findByText("Grok (xAI) API key");
+
+ fireEvent.click(screen.getByText("Gemini"));
+
+ await waitFor(() =>
+ expect(applyMode).toHaveBeenCalledWith({ mode: "gemini" }),
+ );
+ confirmSpy.mockRestore();
+ });
+
+ it("neither button is gated on a key (no key card exists for either provider)", async () => {
+ render(withQueryClient());
+ await screen.findByText("Grok (xAI) API key");
+
+ expect(screen.getByText("Codex").closest("button")).not.toBeDisabled();
+ expect(screen.getByText("Gemini").closest("button")).not.toBeDisabled();
+ });
+ });
+
+ describe("Mix picker Codex/Gemini group visibility", () => {
+ it("shows Codex and Gemini provider groups for a delivery role's per-agent select", async () => {
+ render(withQueryClient());
+ await screen.findByText("Per-agent override (mix mode)");
+
+ const beDevRow = mixRowFor("be-dev-1");
+ // The catalog query resolves asynchronously — the per-agent groups are
+ // absent on the first render pass, so wait for them (findByText) rather
+ // than asserting synchronously.
+ expect(
+ await within(beDevRow).findByText("Codex (OpenAI)"),
+ ).toBeInTheDocument();
+ expect(
+ within(beDevRow).getByText("Gemini (Google)"),
+ ).toBeInTheDocument();
+ });
+
+ it("excludes Codex and Gemini from the Intake/Secretary/PR Review group, with an inline note", async () => {
+ render(withQueryClient());
+ await screen.findByText("Per-agent override (mix mode)");
+
+ expect(
+ screen.getByText(/Codex and Gemini are delivery-roles-only/i),
+ ).toBeInTheDocument();
+
+ // Wait for the catalog query to resolve (an unrelated row's groups)
+ // before asserting absence on this group's rows below.
+ await within(mixRowFor("be-dev-1")).findByText("Codex (OpenAI)");
+
+ const secretaryRow = mixRowFor("secretary-1");
+ expect(
+ within(secretaryRow).queryByText("Codex (OpenAI)"),
+ ).not.toBeInTheDocument();
+ expect(
+ within(secretaryRow).queryByText("Gemini (Google)"),
+ ).not.toBeInTheDocument();
+
+ const intakeRow = mixRowFor("intake-1");
+ expect(
+ within(intakeRow).queryByText("Codex (OpenAI)"),
+ ).not.toBeInTheDocument();
+ expect(
+ within(intakeRow).queryByText("Gemini (Google)"),
+ ).not.toBeInTheDocument();
+
+ // The root PR reviewer shares the same group/note, even though it is
+ // technically one-shot-capable — the panel restricts the whole group.
+ const prReviewerRow = mixRowFor("pr-reviewer-1");
+ expect(
+ within(prReviewerRow).queryByText("Codex (OpenAI)"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
// -------------------------------------------------------------------------
// Mode switches preserve complexity overrides (2026-07-17-style incident:
// these same buttons once wiped AGENT_SLUG pins) — the confirm text says so
diff --git a/panel/src/components/settings/ai-routing-card.tsx b/panel/src/components/settings/ai-routing-card.tsx
index 2594f893..771d29d8 100644
--- a/panel/src/components/settings/ai-routing-card.tsx
+++ b/panel/src/components/settings/ai-routing-card.tsx
@@ -40,8 +40,10 @@ import {
import { Separator } from "@/components/ui/separator";
import {
AlertTriangle,
+ Bot,
Cpu,
Gauge,
+ Gem,
Key,
KeyRound,
Server,
@@ -115,6 +117,12 @@ const AGENT_GROUP_DEFS: {
},
];
+// Codex/Gemini are V1 delivery-roles-only — no interactive Intake/Secretary
+// support (see roboco.llm.providers.codex / .gemini). This group's per-agent
+// picker excludes both providers below instead of offering a route that
+// would silently misroute the persistent Intake/Secretary session at spawn.
+const INTERACTIVE_ONLY_GROUP_TITLE = "Intake / Secretary / PR Review";
+
// Stable within-group ordering (PM/lead first, devs, QA, doc, reviewer last)
// so the picker doesn't churn alphabetically as the live roster loads —
// ties (e.g. dev-1/dev-2) break on slug, which already sorts correctly.
@@ -280,6 +288,10 @@ export function AIRoutingCard() {
(c: { provider_type: ModelProvider }) =>
c.provider_type === ModelProvider.OPENAI,
);
+ const catalogGeminiOnly = catalog.filter(
+ (c: { provider_type: ModelProvider }) =>
+ c.provider_type === ModelProvider.GEMINI,
+ );
const catalogAnthropicOnly = catalog.filter(
(c: { provider_type: ModelProvider }) =>
c.provider_type === ModelProvider.ANTHROPIC,
@@ -326,6 +338,46 @@ export function AIRoutingCard() {
}
};
+ const flipToCodex = async () => {
+ if (
+ !confirm(
+ "Switch every agent to Codex? Per-agent pins and complexity " +
+ "overrides are kept; other role/global assignments are replaced. " +
+ "Intake and Secretary stay on Anthropic (Codex has no interactive " +
+ "chat support).",
+ )
+ )
+ return;
+ try {
+ await applyMode.mutateAsync({ mode: "codex" });
+ toast.success(
+ "Role/global routing now on Codex — pins/overrides kept, Intake & Secretary stay on Anthropic",
+ );
+ } catch (e) {
+ toast.error("Switch failed: " + errMsg(e));
+ }
+ };
+
+ const flipToGemini = async () => {
+ if (
+ !confirm(
+ "Switch every agent to Gemini? Per-agent pins and complexity " +
+ "overrides are kept; other role/global assignments are replaced. " +
+ "Intake and Secretary stay on Anthropic (Gemini has no interactive " +
+ "chat support).",
+ )
+ )
+ return;
+ try {
+ await applyMode.mutateAsync({ mode: "gemini" });
+ toast.success(
+ "Role/global routing now on Gemini — pins/overrides kept, Intake & Secretary stay on Anthropic",
+ );
+ } catch (e) {
+ toast.error("Switch failed: " + errMsg(e));
+ }
+ };
+
const flipToOllama = async () => {
if (!hasOllamaKey) {
toast.error("Save an Ollama API key first");
@@ -551,6 +603,126 @@ export function AIRoutingCard() {
}
};
+ // The full per-agent model-picker option list, shared by every group's
+ // Select — factored out so the Codex/Gemini exclusion for the interactive
+ // group (`restrictInteractiveOnly`) doesn't require duplicating the whole
+ // catalog-grouped SelectContent tree.
+ const renderMixSelectOptions = (restrictInteractiveOnly: boolean) => (
+ <>
+ (inherit global)
+
+ {/* Anthropic models */}
+ {catalogAnthropicOnly.length > 0 && (
+
+
+
+ Anthropic
+
+ {catalogAnthropicOnly.map(
+ (c: { model_name: string; display_name: string }) => (
+
+ {c.display_name}
+
+ ),
+ )}
+
+ )}
+
+ {/* Grok (xAI) models */}
+ {catalogGrokOnly.length > 0 && (
+
+
+
+ Grok (xAI)
+
+ {catalogGrokOnly.map(
+ (c: { model_name: string; display_name: string }) => (
+
+ {c.display_name}
+
+ ),
+ )}
+
+ )}
+
+ {/* Codex (OpenAI) models — excluded for the interactive-only group */}
+ {!restrictInteractiveOnly && catalogOpenaiOnly.length > 0 && (
+
+
+
+ Codex (OpenAI)
+
+ {catalogOpenaiOnly.map(
+ (c: { model_name: string; display_name: string }) => (
+
+ {c.display_name}
+
+ ),
+ )}
+
+ )}
+
+ {/* Gemini (Google) models — excluded for the interactive-only group */}
+ {!restrictInteractiveOnly && catalogGeminiOnly.length > 0 && (
+
+
+
+ Gemini (Google)
+
+ {catalogGeminiOnly.map(
+ (c: { model_name: string; display_name: string }) => (
+
+ {c.display_name}
+
+ ),
+ )}
+
+ )}
+
+ {/* Ollama Cloud models */}
+ {catalogOllamaOnly.length > 0 && (
+
+
+
+ Ollama Cloud
+
+ {catalogOllamaOnly.map(
+ (c: { model_name: string; display_name: string }) => (
+
+ {c.display_name}
+
+ ),
+ )}
+
+ )}
+
+ {/* Self-Hosted models */}
+ {selfHostedModels.length > 0 && (
+
+
+
+ Self-Hosted
+
+ {selfHostedModels.map((m: SelfHostedModel) => (
+
+ {m.display_name}
+
+ ))}
+
+ )}
+
+ {/* Fallback: un-grouped catalog when no grouping is possible */}
+ {catalogAnthropicOnly.length === 0 &&
+ catalogOllamaOnly.length === 0 &&
+ selfHostedModels.length === 0 &&
+ catalogForMix.map((c: { model_name: string; display_name: string }) => (
+
+ {c.display_name} — {c.model_name}
+
+ ))}
+ >
+ );
+
return (
@@ -560,8 +732,10 @@ export function AIRoutingCard() {
Decide which model backs each agent. Anthropic uses the mounted
~/.claude auth; Grok (xAI) and Ollama
- Cloud use the API keys you save below; Self-Hosted connects to any
- OpenAI-compatible endpoint you run locally.
+ Cloud use the API keys you save below; Codex and Gemini authenticate
+ via their own mounted CLI subscriptions (no key needed) — V1:
+ delivery roles only, not Intake/Secretary; Self-Hosted connects to
+ any OpenAI-compatible endpoint you run locally.
@@ -700,10 +874,10 @@ export function AIRoutingCard() {
{/* -------- Mode toggle -------- */}
-
+
-
+
}
label="Anthropic"
@@ -724,6 +898,24 @@ export function AIRoutingCard() {
onClick={flipToGrok}
disabled={applyMode.isPending || !hasGrokKey}
/>
+ }
+ label="Codex"
+ description="Every agent uses Codex (gpt-5.3-codex)."
+ active={currentMode === "codex"}
+ onClick={flipToCodex}
+ disabled={applyMode.isPending}
+ labelHint="Codex authenticates via a mounted ~/.codex subscription (ChatGPT, no API key) — always available once the CLI is logged in on the host. V1: delivery roles only, not offered for Intake/Secretary."
+ />
+ }
+ label="Gemini"
+ description="Every agent uses Gemini (gemini-2.5-pro)."
+ active={currentMode === "gemini"}
+ onClick={flipToGemini}
+ disabled={applyMode.isPending}
+ labelHint="Gemini authenticates via a mounted ~/.gemini OAuth login (no API key) — always available once the CLI is logged in on the host. V1: delivery roles only, not offered for Intake/Secretary."
+ />
}
label="Ollama"
@@ -784,6 +976,22 @@ export function AIRoutingCard() {
per-agent cost cap all apply.
) : null}
+ {currentMode === "codex" || currentMode === "mix" ? (
+
+ Codex agents run on OpenAI's official Codex CLI (ChatGPT
+ subscription, mounted ~/.codex); the same command /
+ secret-exfiltration guard, prompt-injection guard, and per-agent
+ cost cap apply. V1: delivery roles only — not available for
+ Intake/Secretary.
+
+ Gemini agents run on Google's official gemini CLI (OAuth
+ login, mounted ~/.gemini); the same guards apply. V1: delivery
+ roles only — not available for Intake/Secretary.
+