""" RoboCo Configuration Environment-based settings using Pydantic Settings. """ from functools import lru_cache from pydantic import Field, computed_field from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """ Application settings loaded from environment variables. Environment variables are prefixed with ROBOCO_ by default. """ model_config = SettingsConfigDict( env_prefix="ROBOCO_", env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) # ========================================================================== # Application # ========================================================================== app_version: str = "0.11.0" debug: bool = False environment: str = Field( default="development", pattern="^(development|staging|production)$" ) # ========================================================================== # API Server # ========================================================================== host: str = Field(default="127.0.0.1", description="Use 0.0.0.0 for containers") port: int = 8000 api_url: str | None = Field( default=None, description="Override API URL for containerized agents (e.g., http://roboco-orchestrator:8000)", ) # CORS cors_origins: list[str] = Field( default=[ "http://localhost:3000", "http://localhost:5173", ] ) cors_allow_credentials: bool = True @computed_field # type: ignore[prop-decorator] @property def internal_api_url(self) -> str: """ Internal API base URL for service-to-service communication. Uses api_url if set (for containerized agents), otherwise builds from host/port. Note: 0.0.0.0 is only valid for binding, not connecting - use 127.0.0.1 instead. """ if self.api_url: return f"{self.api_url.rstrip('/')}/api" connect_host = "127.0.0.1" if self.host == "0.0.0.0" else self.host # nosec B104 return f"http://{connect_host}:{self.port}/api" # ========================================================================== # Database # ========================================================================== database_host: str = "localhost" database_port: int = 5432 database_user: str = "roboco" database_password: str = "roboco" database_name: str = "roboco" database_echo: bool = Field(default=False, description="Log SQL queries") database_pool_size: int = Field(default=10, ge=1) database_max_overflow: int = Field(default=20, ge=0) database_pool_timeout: int = Field(default=10, ge=1) database_pool_recycle: int = Field(default=1800, ge=60) @computed_field # type: ignore[prop-decorator] @property def database_url(self) -> str: """Async PostgreSQL connection URL.""" return ( f"postgresql+asyncpg://{self.database_user}:{self.database_password}" f"@{self.database_host}:{self.database_port}/{self.database_name}" ) @computed_field # type: ignore[prop-decorator] @property def database_url_sync(self) -> str: """Sync PostgreSQL connection URL (for Alembic).""" return ( f"postgresql://{self.database_user}:{self.database_password}" f"@{self.database_host}:{self.database_port}/{self.database_name}" ) # ========================================================================== # Redis # ========================================================================== redis_host: str = "localhost" redis_port: int = 6379 redis_db: int = 0 redis_password: str | None = None @computed_field # type: ignore[prop-decorator] @property def redis_url(self) -> str: """Redis connection URL.""" if self.redis_password: return f"redis://:{self.redis_password}@{self.redis_host}:{self.redis_port}/{self.redis_db}" return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}" # ========================================================================== # RAG (in-house engine with pgvector) # ========================================================================== rag_persist_dir: str = ".roboco" rag_chunk_strategy: str = Field( default="fixed", pattern="^(fixed|semantic|hierarchical|contextual)$", description="Chunking strategy (fixed recommended, semantic loads extra model)", ) rag_chunk_size: int = Field(default=512, ge=100) rag_chunk_size_docs: int = Field( default=1536, ge=100, description="Chunk size for docs (larger for 8K context)" ) rag_chunk_size_journals: int = Field( default=1024, ge=100, description="Chunk size for journals/reflections" ) rag_chunk_overlap: int = Field(default=128, ge=0) rag_auto_update_enabled: bool = Field(default=True) rag_auto_update_interval: int = Field( default=300, ge=60, description="Seconds between auto-updates" ) @computed_field # type: ignore[prop-decorator] @property def rag_store_url(self) -> str: """PostgreSQL connection URL for the in-house vector store.""" return ( f"postgres://{self.database_user}:{self.database_password}" f"@{self.database_host}:{self.database_port}/{self.database_name}" ) # ========================================================================== # AI/LLM Providers # ========================================================================== anthropic_api_key: str | None = None # Default models default_embedding_model: str = Field( default="qwen3-embedding:0.6b", description="Embedding model. Qwen3 Embedding for quality + 32K context.", ) embedding_dimensions: int = Field( default=1024, description="Embedding dimensions (1024 for qwen3-embedding)", ) # Local LLM for RAG answer synthesis local_llm_model: str = Field( default="glm-5:cloud", description="Local LLM for RAG answer synthesis " "(non-thinking models are faster)", ) local_llm_base_url: str = Field( default="http://roboco-ollama:11434/v1", description="Base URL for local LLM (Ollama OpenAI-compat API)", ) ollama_base_url: str = Field( default="http://roboco-ollama:11434", description="Base URL for Ollama native API (embeddings, model mgmt)", ) # ========================================================================== # Agent runtime toolchain matching (default-off) # ========================================================================== # When enabled, an agent's workspace is provisioned with the Python the # TARGET project declares (uv resolves requires-python), and a delivery role # that cannot execute the suite blocks instead of passing on a source read. # When off, provisioning behaves exactly as today (system interpreter). toolchain_match_enabled: bool = Field( default=False, description=( "Provision the agent workspace with the target project's Python " "(uv resolves requires-python) and block delivery gates when the " "suite cannot be executed. Off => today's behavior." ), ) overload_break_enabled: bool = Field( default=True, description=( "Park a provider on a persistent server overload (HTTP 529 / 500 / " "503 from the model API) the same way a 429 rate limit is parked: " "queue that provider's spawns and probe until it recovers, instead " "of crash-retrying into the overload. Off => crash-retry behavior." ), ) gateway_health_enabled: bool = Field( default=True, description=( "Detect a broken-but-alive agent gateway (a corrupted /app venv so no " "gateway verb can fire) and kill + respawn the container, instead of " "the reaper protecting it forever as a 'live' agent. Off => live " "containers are spared on verb-heartbeat liveness alone." ), ) gateway_health_grace_seconds: int = Field( default=180, description=( "How long an agent gateway may probe as broken before the reaper " "recovers it — tolerates a transient probe miss (the gateway mid-call)." ), ) # ========================================================================== # Architectural Conventions (per-project placement + house-style standard) # ========================================================================== # A repo-canonical .roboco/conventions.yml plus the roboco-conventions # validator gate i_am_done / pr_pass on block-level placement and hygiene # violations. Default-off; every hook (scaffold, ambient injection, baseline # constraints, the gates) is inert when off. conventions_enabled: bool = Field( default=False, description=( "Master switch for the architectural-conventions standard: " "auto-scaffold .roboco/conventions.yml, inject the architecture map, " "attach baseline constraints, and block gates on violations. Off => " "fully inert." ), ) # ========================================================================== # Web Research (pluggable external search/fetch for Board + PM roles) # ========================================================================== # Calls go agent -> roboco-search MCP -> /api/research/* -> ResearchService # -> provider. The provider key lives ONLY in this server-side process; it # is never injected into agent containers, and agents never egress — the # provider's own API does. Unset key => graceful NullProvider (empty # results, no hard fail). research_enabled: bool = Field( default=True, description=( "Master switch for the web-research capability. When false the " "roboco-search MCP server is not mounted into any agent container." ), ) research_provider: str = Field( default="tavily", pattern="^(tavily|brave|exa|null)$", description=( "Web-search provider adapter. 'tavily' (LLM-native cited results " "+ extract), 'brave' (independent index; no fetch), 'exa' " "(neural search + contents), or 'null' (always-empty stub). " "Swapping providers is a config change only." ), ) research_api_key: str | None = Field( default=None, description=( "API key for the selected research provider. Server-side only — " "never reaches an agent container. Unset => NullProvider." ), ) research_max_results: int = Field( default=5, ge=1, le=20, description="Hard cap on web_search results per call (top-k clamp).", ) research_fetch_max_chars: int = Field( default=20000, ge=500, description="Hard cap on extracted characters returned by web_fetch.", ) research_timeout_seconds: float = Field( default=15.0, gt=0, description="Per-request timeout for outbound provider HTTP calls.", ) research_daily_quota_per_agent: int = Field( default=50, ge=1, description=( "Maximum web_search + web_fetch calls per agent per UTC day. " "Tracked in Redis; fails open if Redis is unreachable." ), ) # ========================================================================== # Security # ========================================================================== encryption_key: str = Field( default="", description="Fernet encryption key for secrets.", ) # ========================================================================== # GitHub repository provisioning (pitch -> approve -> auto-provision) # ========================================================================== # The only place that CREATES GitHub repos (vs. clone/branch/PR existing # ones). Server-side only; never injected into agent containers. Unset # token/org => disabled => the pitch approval path is inert (no repo is # created) until the CEO configures it. provisioning_enabled: bool = Field( default=True, description=( "Master switch for pitch auto-provisioning. With no token/org set " "the capability is inert regardless of this flag." ), ) provisioning_token: str = Field( default="", description=( "GitHub PAT used to create repos in the provisioning org " "(needs repo + org admin scope). Server-side only." ), ) provisioning_org: str = Field( default="", description="GitHub organization where new repos are provisioned.", ) github_api_base_url: str = Field( default="https://api.github.com", description="GitHub REST API base URL (override for GitHub Enterprise).", ) provisioning_timeout_seconds: float = Field( default=30.0, gt=0, description="Per-request timeout for outbound GitHub provisioning calls.", ) provisioning_repo_private: bool = Field( default=True, description="Whether provisioned repos are created private.", ) # ========================================================================== # Autonomous strategy engine ("engine 2") — DORMANT by default # ========================================================================== # A separate background loop that watches the company against its standing # goals and surfaces drift/idle/stranded work to the CEO (notify-only — # never spends or builds). Default OFF: the loop never starts and the # existing delivery lifecycle is untouched until the CEO opts in. strategy_engine_enabled: bool = Field( default=False, description=( "Master switch for the autonomous strategy engine. OFF by default; " "when off the background loop does not run at all." ), ) strategy_engine_interval_seconds: int = Field( default=1800, ge=60, description="Seconds between strategy-engine assessment passes.", ) strategy_stranded_blocked_minutes: int = Field( default=120, ge=5, description=( "A task blocked longer than this is surfaced as stranded " "(needs a human decision)." ), ) # ========================================================================== # External-PR review ("engine 3") — DORMANT by default # ========================================================================== # An inbound path: a background loop that lists open PRs per active project, # flags ones from external/fork authors, and creates a one-shot review task # for the dedicated reviewer agent. Default OFF — the loop never starts and # no inbound GitHub call is made until the CEO opts in. Untrusted contributor # code is never fetched or executed until ``confirmed_by_human`` is set. external_pr_enabled: bool = Field( default=False, description=( "Master switch for inbound external-PR review. OFF by default; " "when off the poll loop does not run at all." ), ) external_pr_poll_interval_seconds: int = Field( default=300, ge=60, description="Seconds between inbound external-PR discovery passes.", ) external_pr_author_allowlist: list[str] = Field( default_factory=list, description=( "GitHub usernames trusted as known contributors. Empty means no " "author is auto-trusted; every external PR needs human confirmation." ), ) external_pr_require_human_confirm: bool = Field( default=True, description=( "Require an explicit human confirmation before any agent fetches, " "checks out, or executes external contributor code." ), ) internal_pr_enabled: bool = Field( default=False, description=( "Master switch for the internal-PR safety reviewer. OFF by default. " "When on, the same poll also reviews org-repo (non-fork) PRs that are " "NOT tied to an active task — i.e. branches pushed outside the agent " "task-flow. The org's own in-flight integration PRs (whose branch a " "live task owns) are skipped, since they already pass QA + PM review." ), ) # ========================================================================== # Production self-healing ("engine 4") — DORMANT by default # ========================================================================== # RoboCo heals ITSELF. A closed loop that watches RoboCo's OWN repo CI (the # single project named by self_heal_project_slug — NOT other/client repos), # detects a regression (a failing CI run on its default branch), notifies the # CEO, and — behind a second opt-in — opens a PENDING fix task into RoboCo's # own delivery lifecycle and STOPS. It never starts, merges, or deploys; every # downstream step stays a human decision. Default OFF: the loop never runs and # no GitHub call is made. self_heal_enabled: bool = Field( default=False, description=( "Master switch for the self-healing loop (detect + notify the CEO). " "OFF by default; when off the background loop does not run at all and " "no CI telemetry is fetched." ), ) self_heal_project_slug: str = Field( default="", description=( "The registered project that IS RoboCo itself — the self-heal loop " "watches ONLY this repo's CI and opens fix tasks ONLY into it (RoboCo " "healing itself, not other repos). Empty = no target; the loop no-ops " "even when enabled." ), ) self_heal_ci_workflow: str = Field( default="ci.yml", description=( "GitHub Actions workflow file name to scope the CI signal to. " "Defaults to 'ci.yml' (RoboCo's own gate). Set empty ONLY for a " "single-workflow repo — an empty value reads the latest completed run " "across ALL workflows on the default branch, which on a " "multi-workflow repo lets an unrelated green run mask a red CI run " "and makes the self-heal signal flicker." ), ) self_heal_originate_enabled: bool = Field( default=False, description=( "Second opt-in: when on (and self_heal_enabled), a detected regression " "also opens a PENDING fix task into the regressed project's lifecycle. " "OFF by default — the loop is notify-only. The loop NEVER starts, " "approves, merges, or deploys the task; it stops at PENDING for the CEO." ), ) self_heal_interval_seconds: int = Field( default=1800, ge=60, description="Seconds between self-healing telemetry assessment passes.", ) self_heal_max_open_tasks: int = Field( default=3, ge=1, description=( "Rolling cap on concurrently-open self-heal tasks across all repos; " "the loop originates nothing more while this many are still open." ), ) self_heal_max_per_cycle: int = Field( default=1, ge=1, description="Max self-heal fix tasks the loop may originate in one cycle.", ) # ========================================================================== # Workspaces (Multi-Agent Git) # ========================================================================== workspaces_root: str = Field( default="/data/workspaces", description="Root directory for all agent workspaces", ) workspace_auto_clone: bool = Field( default=True, description="Automatically clone repos when workspace is first accessed", ) workspace_clone_timeout: int = Field( default=300, ge=30, description="Timeout in seconds for git clone operations", ) workspace_refresh_fetch_timeout_seconds: int = Field( default=60, ge=5, description=( "Timeout in seconds for the best-effort `git fetch origin` " "that runs on every healthy-clone re-entry into " "ensure_workspace. Refresh fetches transfer small deltas only " "— blocking 300s (the full-clone timeout) on every spawn " "against a hung remote is operationally bad." ), ) workspace_install_dev_deps: bool = Field( default=True, description=( "After cloning an agent workspace, install the project's dev " "dependencies into the workspace's own environment so the " "`make quality` gate (ruff/mypy/pytest for Python, the lint/" "typecheck toolchain for the TS panel) is available without " "the agent re-downloading tooling per task. Detects Python " "(pyproject.toml → `uv sync`) and Node/TS (package.json → " "`pnpm install`/`npm install`). Idempotent; skipped when the " "relevant lockfile is unchanged since the last install." ), ) workspace_dep_install_timeout_seconds: int = Field( default=600, ge=30, description=( "Timeout in seconds for the post-clone dev-dependency install " "(`uv sync` / `pnpm install`). Cold installs of a large TS " "panel or a Python project with native wheels can take several " "minutes; the default clone timeout is too short for this." ), ) # ========================================================================== # Agent container images (spawn source) # ========================================================================== # By default the orchestrator builds each specialized agent image locally # from docker/agent-*.Dockerfile the first time it spawns that role (the # build/test flow). Set a registry to run the PRE-BUILT images the release # workflow publishes instead — the orchestrator then pulls # `{registry}/roboco-agent-*[:tag]` rather than building, so a deployment # never needs the source tree or a build toolchain. Empty = local build # (unchanged behavior). agent_image_registry: str = Field( default="", description=( "Registry namespace for pre-built agent images, e.g. " "'ghcr.io/rennf93' or 'docker.io/renzof93'. Empty builds locally." ), ) agent_image_tag: str = Field( default="", description=( "Tag for pre-built agent images (e.g. 'latest' or '0.11.0'). Empty " "leaves the tag implicit (':latest'); only meaningful with " "agent_image_registry set." ), ) # ========================================================================== # Transcript retention (agent Claude Code transcripts under ~/.claude) # ========================================================================== transcript_retention_days: int = Field( default=14, ge=1, description=( "Default retention window, in days, for agent Claude Code " "transcripts (the *.jsonl files agents write under " "~/.claude/projects). A background sweep prunes agent-owned " "transcripts older than this. Panel-editable: a stored " "`transcript_retention_days` system setting overrides this default " "when present; this is the fallback used before one is set." ), ) transcript_prune_enabled: bool = Field( default=True, description=( "Whether the orchestrator background sweep prunes old agent " "transcripts. Disable to keep every transcript indefinitely." ), ) transcript_prune_interval_seconds: int = Field( default=3600, ge=300, description=( "Minimum seconds between transcript-retention prune passes. The " "prune is age-based (days), so it need not run more than hourly." ), ) # ========================================================================== # Git command execution # ========================================================================== git_command_timeout_seconds: int = Field( default=30, ge=5, description=( "Default timeout in seconds for a single orchestrator-side git " "subprocess (status, log, checkout, fetch, push, …). Short by " "design — most git operations are sub-second." ), ) git_commit_timeout_seconds: int = Field( default=180, ge=30, description=( "Timeout in seconds for staging + committing a changeset " "(`git add` / `git commit`). Large multi-file changesets (e.g. " "the Next.js panel) can exceed the 30s default-git timeout while " "git hashes every object and the orchestrator re-chowns the " "tree, so the commit choreography uses this longer budget." ), ) git_network_timeout_seconds: int = Field( default=120, ge=30, description=( "Timeout in seconds for git ops that talk to origin (fetch / pull " "/ push). A push or fetch on a large private monorepo from a " "self-hosted runner can far exceed the sub-second local-op " "default; short-budgeting it is what made open_pr time out before " "the branch reached the remote." ), ) session_idle_timeout_seconds: int = Field( default=3600, ge=30, description=( "Idle seconds before a messaging session is swept closed. The " "previous 300s default was shorter than a human conversation pause, " "so a person's chat session expired and reopened between messages." ), ) protected_git_urls: list[str] = Field( default_factory=list, description=( "Repo URL substrings a project may not point at (e.g. the roboco " "source repo). Blocks agent commits/merges from reaching a protected " "repository; set this to sandbox smoke-test projects." ), ) # ========================================================================== # Agent Guardrails (per-session budgets, loop detection, SLAs) # ========================================================================== agent_tool_call_warn: int = Field( default=50, ge=1, description="Soft warning threshold for per-session tool calls", ) agent_tool_call_halt: int = Field( default=150, ge=1, description="Hard cap for per-session tool calls; orchestrator stops container", ) agent_loop_threshold: int = Field( default=3, ge=2, description="Identical tool+args repeats in the window that flag a loop", ) agent_loop_window: int = Field( default=10, ge=2, description="How many recent tool calls to inspect for loop detection", ) agent_stop_attempt_allowance: int = Field( default=1, ge=1, description="Stop-without-terminal attempts before auto-substitute", ) # Per-(role, state) SLAs for stuck-task sweep; seconds. agent_sla_developer_in_progress: int = Field(default=2 * 3600, ge=60) agent_sla_developer_verifying: int = Field(default=30 * 60, ge=60) agent_sla_qa_claimed: int = Field(default=30 * 60, ge=60) agent_sla_documenter_claimed: int = Field(default=60 * 60, ge=60) agent_sla_cell_pm_claimed: int = Field(default=4 * 3600, ge=60) # ========================================================================== # Agent Gateway # ========================================================================== manifest_host_dir: str = Field( default="/app/manifests", description=( "Orchestrator-side directory where per-agent tool manifests are " "written. Must be a path that's bind-mounted from the host " "(see docker-compose.yml) so the docker daemon can in turn mount " "the file into spawned agent containers as /app/tool-manifest.json." ), ) public_base_url: str = Field( default="http://127.0.0.1:8000", description="Public base URL for commit-trailer links", ) # Gateway coordination thresholds # Single source of truth for "claim heartbeat is stale": consumed both by # `trigger_filter` (deciding whether to QUEUE a fresh spawn) and by # `_reap_stale_claims` (deciding whether to RELEASE the claim back to # pending). Keeping them on one field guarantees both layers agree on # the same tick — the reaper runs first, releases the row, and the # queued spawn finds an unclaimed task. Splitting them into two fields # opens a window where trigger_filter queues duplicate spawns against a # claim the reaper hasn't yet released — pure dispatcher churn. claim_stale_seconds: int = Field( default=180, ge=60, description="Claim heartbeat staleness threshold (seconds)", ) # Debounce for respawning a PM to CLOSE a paused parent once its subtasks # are terminal. It guards only the narrow i_am_idle race (the parent # auto-pauses, then the agent is marked IDLE + its container tears down) — # the live-session case is already covered by the `_is_agent_active` check. # It must therefore be SHORT (a few dispatch ticks), NOT the multi-minute # reaper window: a paused parent's heartbeat reflects when the PM last # worked, so a PM that worked right up to idle leaves a fresh heartbeat and # any large window strands the whole chain until it expires. Was wrongly # bound to stale_claim_reap_seconds (600s default, 1800s on the NAS), which # delayed every cell/main closure by up to 10-30 minutes. pm_closure_recently_paused_seconds: int = Field( default=45, ge=5, description=( "Debounce (seconds) before respawning a PM to close a recently " "paused parent; override via ROBOCO_PM_CLOSURE_RECENTLY_PAUSED_SECONDS" ), ) # Reaper window for stale-claim detection. Dogfooding reaped agents at # ~180s while they were actively # retrying — LLM inference + retry loops routinely exceed 3 min # between verb successes. 600s is large enough to accommodate that # without letting a genuinely-stuck container linger. # Distinct from claim_stale_seconds (which drives trigger_filter # spawn queueing); keeping them separate avoids a window where a # higher reap threshold would also delay spawn-queue decisions. stale_claim_reap_seconds: int = Field( default=600, ge=60, description=( "Reaper-only stale claim threshold (seconds); " "override via ROBOCO_STALE_CLAIM_REAP_SECONDS" ), ) # A GROK agent that wedges — an idle model call / stream with no gateway # verb — is ACTIVE-yet-silent, so the heartbeat reaper's live-container skip # would shield its task forever (the grok CLI emits no SDK budget signal and # advances no heartbeat while parked, unlike a Claude agent that at least # reports). After this longer window the orchestrator kills + evicts the # container so the reaper releases the task. Longer than # stale_claim_reap_seconds so only a truly-dead run trips it, never a # slow-but-working agent. grok_idle_kill_seconds: int = Field( default=900, ge=120, description=( "Idle-container kill threshold for GROK agents (seconds); " "override via ROBOCO_GROK_IDLE_KILL_SECONDS" ), ) # Budget kill-switch parity for GROK. Claude Code's per-agent token-budget # hook fires against the SDK :9000 server; the grok CLI exposes no live usage # hook, so the orchestrator enforces the cap by reading each live GROK # container's captured cost from its usage.json and killing it when it crosses # this ceiling (also catches runaway-loop token burn). USD; 0 = off. grok_max_cost_usd: float = Field( default=0.0, ge=0, description=( "Per-agent GROK cost ceiling (USD) before the container is killed; " "0 disables. Override via ROBOCO_GROK_MAX_COST_USD" ), ) # An interactive intake/secretary chat the human abandoned (closed the tab # without confirming/stopping) otherwise leaks its container until the # orchestrator restarts. The sweeper reaps a live session whose # time-since-last-turn (push/deliver) exceeds this; measured on activity, NOT # connection state, so an active or page-reloaded chat that keeps exchanging # turns is never reaped (board-review-parked sessions are also exempt). # Seconds; 0 disables. Provider-agnostic (Claude + Grok interactive). interactive_idle_reap_seconds: int = Field( default=1800, ge=0, description=( "Idle-reap threshold for live intake/secretary chats (seconds); " "0 disables. Override via ROBOCO_INTERACTIVE_IDLE_REAP_SECONDS" ), ) # A task left CLAIMED/IN_PROGRESS with an assignee but no running container # (e.g. a reassignment that didn't spawn) is invisibly stuck — the heartbeat # reaper can't see it because its heartbeat was seeded fresh at claim time. # After this short grace window the dispatcher (re)spawns the assignee, or # releases the task to pending for re-dispatch. Shorter than the # heartbeat reaper window: this is the "no agent at all" case, not the # "agent went silent mid-run" case. claimed_no_agent_grace_seconds: int = Field( default=120, ge=30, description=( "Grace window (seconds) before the orchestrator (re)spawns or " "releases a claimed/in_progress task that has no running agent; " "override via ROBOCO_CLAIMED_NO_AGENT_GRACE_SECONDS" ), ) # Pre-gateway parity: PMs wrote a fresh # journal:decision around each decision point, not once at task # creation. The PM-decision tracing gate (delegate, unblock, # escalate_up, escalate_to_ceo) treats decisions older than this # window as missing, forcing a new note(scope='decision', ...) on # each pass through the gate. pm_decision_window_seconds: int = Field( default=300, ge=1, description=( "Recency window (seconds) for PM journal:decision to satisfy " "gating verbs; override via ROBOCO_PM_DECISION_WINDOW_SECONDS" ), ) spawn_cooldown_seconds: int = Field( default=60, ge=1, description="Per-task spawn rate cooldown (seconds)", ) role_spawn_rate_per_minute: int = Field( default=6, ge=1, description="Per-role spawn rate limit (per minute)", ) # Tracing-gate thresholds qa_notes_min_chars: int = Field( default=80, ge=1, description="Minimum characters for QA notes", ) docs_notes_min_chars: int = Field( default=20, ge=1, description="Minimum characters for docs notes", ) dev_notes_min_chars: int = Field( default=40, ge=1, description="Minimum characters for a developer's dev_notes section", ) pr_reviewer_notes_min_chars: int = Field( default=40, ge=1, description="Minimum characters for a PR reviewer's pr_reviewer_notes section", ) quick_context_min_chars: int = Field( default=30, ge=1, description="Minimum characters for a PM's quick_context resumption section", ) # Commit-validator thresholds (wired into the gateway commit() gate) commit_subject_min_chars: int = Field( default=20, ge=1, description="Minimum characters for a commit subject", ) commit_banned_words: tuple[str, ...] = Field( default=( "wip", "tmp", "asdf", "oops", "fix", "update", "change", "stuff", "things", ), description="Banned single-word commit subjects", ) @lru_cache def get_settings() -> Settings: """Get cached settings instance.""" return Settings() # Global settings instance settings = get_settings()