diff --git a/.env.quickstart.example b/.env.quickstart.example index 4a6ae8e..f972233 100644 --- a/.env.quickstart.example +++ b/.env.quickstart.example @@ -188,23 +188,11 @@ POSTHOG_ENABLED=false # FIGMA_ACCESS_TOKEN= # --- Rosetta / KnowledgeBase (project knowledge for codegen agents) --- -# Two modes — choose one: -# -# Mode 1: Bundled plugin (default, open-source quickstart) -# Generic KB, runs locally, no external credentials needed. -# Leave ROSETTA_MCP_ENABLED=false and keep the default ROSETTA_PLUGIN_PATH. -# -# Mode 2: Live MCP (project-tailored KB, Grid Dynamics managed) -# Connects to the Grid Dynamics IMS service for project-specific knowledge. -# Set ROSETTA_MCP_ENABLED=true and fill in the credentials below. -# Live MCP takes precedence when both are configured. +# The Rosetta knowledge base ships as a plugin baked into the Docker image. It is a generic KB, +# runs locally, and needs no external credentials. # # Docs: https://github.com/griddynamics/rosetta # -# ROSETTA_MCP_ENABLED -# What: Toggle between bundled plugin (false) and live IMS-MCP server (true). -ROSETTA_MCP_ENABLED=false -# # ROSETTA_PLUGIN_PATH # What: Path to the generic KB plugin baked into the Docker image. # How: Keep as-is. Only change if you mount a custom plugin. @@ -215,14 +203,6 @@ ROSETTA_PLUGIN_PATH=/opt/rosetta-plugin # the image at BUILD time (build arg, not a runtime setting). Pins the plugin version. # How: Bump to a newer tag and rebuild the backend image to upgrade the bundled KB plugin. ROSETTA_MARKETPLACE_REF=v2.0.55 -# -# ROSETTA_API_KEY / ROSETTA_USER_EMAIL / ROSETTA_SERVER_URL -# What: Credentials for the live IMS-MCP Rosetta service. -# Only needed when ROSETTA_MCP_ENABLED=true. -# How: Obtain from your Grid Dynamics IMS admin. -# ROSETTA_API_KEY= -# ROSETTA_USER_EMAIL= -# ROSETTA_SERVER_URL=https://ims.evergreen.gcp.griddynamics.net/ # --- Agent MCP servers --- # MCP_SERVERS_ENABLED diff --git a/CLAUDE.md b/CLAUDE.md index e489b12..a7afb63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Spec analysis and implementation planning happen **locally in the user's IDE** v 0. **Local (no backend)** — user calls `check_specification_completeness` and `run_planning` in their IDE; both produce markdown files in the user's project directory. Repeatable, free, no session created. 1. **File upload + contract validation** — `run_generation` uploads the user's `specs/`, optional `src/`, and `outputs_dir/` to the primary workspace. The contract validator (`backend/app/services/contract_validator.py` + `run_contract_validator` in `workflow_steps.py`) fuzzy-matches required files, runs keyword-only MCP prune, converts plans markdown→JSON, and writes Firestore plan data. If any required file is missing or unparseable, `run_generation` fails immediately with a human-readable message. No spec/planning/KB agents in this step (plan conversion agent only). -2. **KB init + Generation** — Rosetta unpacking and KB init run as the first generation step (not before). Then code generation runs across all workspaces in parallel, committing incrementally. +2. **KB init + Generation** — the provisioned Rosetta plugin initializes the knowledge base as the first generation step (not before). Then code generation runs across all workspaces in parallel, committing incrementally. 3. **Deploy & E2E** — deploy loop starts immediately after generation; no user pause in between. Consequences that must be upheld in code: diff --git a/agents/IMPLEMENTATION.md b/agents/IMPLEMENTATION.md index 2f75b3e..69feb34 100644 --- a/agents/IMPLEMENTATION.md +++ b/agents/IMPLEMENTATION.md @@ -1,6 +1,15 @@ # Implementation Status -**Updated**: June 23, 2026 | **Branch**: (current) | **Tests**: 1228+ passing (`make unit-tests`) +**Updated**: July 15, 2026 | **Branch**: (current) | **Tests**: 1228+ passing (`make unit-tests`) + +## Current Work (July 2026) + +- **Rosetta plugin-only cleanup (implementation stage):** Runtime KB initialization now validates + only `ROSETTA_PLUGIN_PATH`, uses the ordinary empty KB-init MCP selection, and supplies the + provisioned plugin's `Skill` / `SlashCommand` tools directly. Prompts and runtime configuration + now describe one direct-to-final-location workflow. Production implementation validation: + changed-file Ruff, Python compilation, residual source/doc searches, and `git diff --check` + pass. Tests remain in the approved workflow. ## Core Systems (Production Ready) @@ -11,7 +20,7 @@ - **Database**: `backend/app/database/` — firestore, emulator, in_memory (IDatabase interface) - **Prompts**: `backend/app/prompts/agents_claude_code.py` — all agent templates - **Schemas**: `backend/app/schemas/` — estimation_enums (statuses/checkpoints), specification (SpecReadiness) -- **Config**: `backend/app/core/config.py` — env vars including `TOKEN_ENCRYPTION_KEY`, `GITHUB_TOKEN_DEFAULT`, `GIT_USER_NAME_DEFAULT`, K8s secret key name settings; Rosetta `ims-mcp` via `uvx ims-mcp@latest` with `ROSETTA_SERVER_URL`, `ROSETTA_API_KEY`, `ROSETTA_USER_EMAIL`, `ROSETTA_IMS_VERSION` → subprocess `VERSION` (`build_rosetta_mcp_config`); `github_platform_secrets.py` loads Fernet + default PAT (K8s API or env); `workspace_pool_names.py` — pool allowlist; `artifact_subdirs.py` — `ANALYSIS_SUBDIR`, `PLANNING_SUBDIR`, `REPORT_SUBDIR` (SSOT for artifact/workspace output paths); `backend/app/prompts/mcp_workflow_registry.py` — matrix of optional agent MCPs per workflow + prune keyword field map +- **Config**: `backend/app/core/config.py` — env vars including `TOKEN_ENCRYPTION_KEY`, `GITHUB_TOKEN_DEFAULT`, `GIT_USER_NAME_DEFAULT`, K8s secret key name settings; Rosetta KB via the bundled plugin at `ROSETTA_PLUGIN_PATH` (see `app/core/rosetta_kb.py` + `WorkspaceManager.provision_rosetta_plugin`); `github_platform_secrets.py` loads Fernet + default PAT (K8s API or env); `workspace_pool_names.py` — pool allowlist; `artifact_subdirs.py` — `ANALYSIS_SUBDIR`, `PLANNING_SUBDIR`, `REPORT_SUBDIR` (SSOT for artifact/workspace output paths); `backend/app/prompts/mcp_workflow_registry.py` — matrix of optional agent MCPs per workflow + prune keyword field map - **MCP**: `server.py` — tools: check_specification_completeness, run_planning, run_generation, check_status, retry_generation, download_estimation_outputs; workspace tools take `ctx: Context` and call `apply_mcp_project_root_from_context` so `resolve_path` / session file resolve before `gain_session.json` exists (MCP `list_roots()` → Pydantic `FileUrl` → `_project_root`). ## Key Decisions (PR83) @@ -86,8 +95,6 @@ `test_estimation` (`update_completed_estimation_result`), `test_report_generation` (skipped/coverage sections), `test_workflow_integration`, `test_model_selection`. -- **Rosetta unpack in standalone planning + Rosetta MCP on codegen (Apr 9, ops v0.4.0 #3)**: `planning_workflow` calls `unpack_rosetta_artifacts` on the primary workspace after KB init (or KB_INIT_DONE resume) and before `run_planning_agent`, matching full-estimation behavior from `prepare_parallel_workspaces`. `coding_mcp_servers_and_tools` merges `_rosetta_pair` when `ROSETTA_MCP_ENABLED` so generation/deploy phase agents get KnowledgeBase MCP like planning/KB init. `unpack_rosetta_artifacts` also maps `rosetta/skills/` and `rosetta/commands/` to `.claude/skills/` and `.claude/commands/` (same pattern as `rosetta/agents/`). Tests: `test_agents_claude_code` (coding MCP + rosetta), `test_execute_all_phases_mcps` mocks set `ROSETTA_MCP_ENABLED=False` where isolating Playwright/Figma. - - **Async spec analyze + usage counters + milestone notifications (Apr 3)**: `POST /specification/analyze` returns immediately; background task awaits `archive_analysis` before clearing `spec_analysis_in_progress` and sending email/Slack `notify_spec_check_complete`. `GET /specification/outputs/{ws}` → **410 Gone**; MCP uses short analyze timeout and `download_outputs` only. Cumulative `num_turns` / `total_tokens_used` on estimation docs via transactional `add_agent_query_totals` from successful `agent_query` (TelemetryContext handler from `build_workflow_context`, spec/planning bg tasks, estimation run/retry). `GET .../estimations/{id}/status` + MCP `check_status` expose counters and `total_tokens_used_display` (`format_token_count`). `notify_planning_complete` after `archive_planning` + `PLANNING_DONE` in `planning_workflow`. Edge-case tests: `test_specification_analyze_async_edges.py` (API key lock, `begin_analysis` failure, workflow/archive failures, archive-before-notify ordering, notify failure cleanup), `test_get_estimation_status_includes_usage_and_spec_analysis_fields`. **TODO**: crash recovery could clear stale `spec_analysis_in_progress` if the process dies mid-task. ## Recently Completed (March 2026) @@ -104,7 +111,7 @@ flows from MCP `server.py` / sync params → estimation `parameters` → workflows. Backend `SUPPORTED_MCPS` (`playwright`, `figma`) filters names; `FIGMA_ACCESS_TOKEN` / `FIGMA_API_KEY` + `FIGMA_MCP_*` / `PLAYWRIGHT_MCP_*` in backend env build stdio MCP configs. Playwright wired for generation + deploy/E2E phase agents; Figma for - spec analysis, planning (with Rosetta), generation, and deploy/E2E. PostHog event `mcp_servers_configuration` + spec analysis, planning, generation, and deploy/E2E. PostHog event `mcp_servers_configuration` records `mcp_configuration_source` (form / estimation_parameters / backend_settings / workspace_sync_params), `mcp_configuration_raw`, resolved enablement, and `mcp_supported_ids` at that moment. API form fields + tests in `test_mcp_config.py`, `test_mcp_configuration_telemetry.py`. @@ -132,7 +139,7 @@ - **Background jobs fix**: Removed PENDING/FAILED blocking from `cleanup_workspace()`. Fixed stuck_cleaning_recovery for missing timestamps. - **Multi-model**: Comma-separated model lists in `LLM_MEDIUM`/`LLM_HIGH`/`LLM_LOW`, round-robin assignment, OpenRouter validation. - **Integration readiness**: SpecReadiness enum, Part F, dual plans, DEPLOY_AND_E2E_DONE checkpoint, conditional workflow. -- **KB Init**: `ROSETTA_MCP_ENABLED` flag, kb_init workflow step, rosetta artifact unpacking. +- **KB Init**: `kb_init` workflow step (bundled Rosetta plugin — see **Config** above). - **Post-refactor audit**: State machine enforcement, rogue DB writes removed, CI guards. ## Gaps & Debt diff --git a/backend/Dockerfile b/backend/Dockerfile index 824ca71..41c68b6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -112,13 +112,11 @@ RUN mkdir -p /root/.claude COPY claude-settings.json /root/.claude/settings.json # --------------------------------------------------------------------------- -# Rosetta knowledge-base plugin — bundled so KB init runs WITHOUT the ims-mcp service / -# ROSETTA_API_KEY. Plugin mode is the DEFAULT in every environment (ROSETTA_MCP_ENABLED=false, -# ROSETTA_PLUGIN_PATH=/opt/rosetta-plugin). At workspace prep time the backend copies this -# plugin's agents/skills/commands/hooks into each workspace's .claude/ (see -# WorkspaceManager.provision_rosetta_plugin); the agents discover them via -# setting_sources=["project"]. Set ROSETTA_MCP_ENABLED=true to opt back into the live MCP, -# which then ignores this path. +# Rosetta knowledge-base plugin used by KB initialization. +# The plugin lives at ROSETTA_PLUGIN_PATH=/opt/rosetta-plugin in every environment. At workspace +# prep time the backend copies this plugin's agents/skills/commands/hooks into each workspace's +# .claude/ (see WorkspaceManager.provision_rosetta_plugin); the agents discover them via +# setting_sources=["project"]. # # Installed from the public griddynamics/rosetta marketplace via the `claude` CLI that ships # bundled inside claude-agent-sdk (a self-contained native binary at @@ -133,8 +131,7 @@ COPY claude-settings.json /root/.claude/settings.json # (falling back to the default below), so it can be bumped without editing this Dockerfile: # docker build --build-arg ROSETTA_MARKETPLACE_REF=v2.0.56 ... # -# The guard asserts the staged plugin actually ships the offline toolset (manifest + skills + -# workflows), failing the build if the marketplace ever serves an empty/MCP-only plugin. +# The guard asserts the installed plugin ships the required manifest, skills, and workflows. # --------------------------------------------------------------------------- ENV HOME=/root ARG ROSETTA_MARKETPLACE_REF=v2.0.55 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4609be6..c5a4fb0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -15,7 +15,6 @@ MCP_PLAYWRIGHT = "playwright" MCP_FIGMA = "figma" MCP_FIGMA_SERVER_KEY = "Figma" # figma-developer-mcp registers under this server name -ROSETTA_SERVER_KEY = "KnowledgeBase" # ims-mcp registers under this server name SUPPORTED_MCPS: FrozenSet[str] = frozenset({MCP_PLAYWRIGHT, MCP_FIGMA}) MCP_SERVERS_ENABLED_ENV = "MCP_SERVERS_ENABLED" MCP_SERVERS_ENABLED_DEFAULT = MCP_PLAYWRIGHT @@ -295,28 +294,14 @@ def _empty_str_to_none_int(cls, v: object) -> object: FIGMA_ACCESS_TOKEN: Optional[str] = None FIGMA_API_KEY: Optional[str] = None - # KnowledgeBase/Rosetta MCP — matches `claude mcp add ... -- uvx ims-mcp@latest` env surface. - ROSETTA_MCP_COMMAND: str = "uvx" - ROSETTA_MCP_ARGS: str = "ims-mcp@latest" - ROSETTA_SERVER_URL: Optional[str] = "https://ims.evergreen.gcp.griddynamics.net/" - ROSETTA_API_KEY: Optional[str] = None - ROSETTA_USER_EMAIL: Optional[str] = None - # Passed to ims-mcp subprocess as env VERSION (e.g. r2). - ROSETTA_IMS_VERSION: str = "r2" - # Plugin mode is the DEFAULT for every environment (quickstart AND hosted): KB init - # runs against the bundled Rosetta plugin, no ims-mcp service / ROSETTA_API_KEY needed. - # Set ROSETTA_MCP_ENABLED=true only to opt back into the live ims-mcp server (it then - # wins over the plugin). See app/core/mcp_selection.py:for_kb_init. - ROSETTA_MCP_ENABLED: bool = False - ROSETTA_OUTPUT_DIR: str = "rosetta" - # Path to the Rosetta plugin bundled into the image at build time (backend/Dockerfile - # stages it here). When ROSETTA_MCP_ENABLED is False, WorkspaceManager.provision_rosetta_plugin - # copies this plugin's agents/skills/commands into each workspace's .claude/ and merges its - # hooks into .claude/settings.json so setting_sources=["project"] discovers them (no - # ~/.claude, no SDK plugins= loader). This same path is also exported as CLAUDE_PLUGIN_ROOT - # per agent (claude_code.setup_rosetta_plugin_env) so the merged hooks' ${CLAUDE_PLUGIN_ROOT} - # resolves to the read-only image plugin. Set to None / empty to disable plugin provisioning - # (KB init then no-ops unless MCP is enabled). + # Path to the Rosetta plugin bundled into the image at build time. + # WorkspaceManager.provision_rosetta_plugin copies this plugin's agents/skills/commands into + # each workspace's .claude/ and merges its hooks into .claude/settings.json so + # setting_sources=["project"] discovers them. This same path is exported as + # CLAUDE_PLUGIN_ROOT per agent + # (claude_code.setup_rosetta_plugin_env) so the merged hooks' ${CLAUDE_PLUGIN_ROOT} + # resolves to the read-only image plugin. Set to None / empty to disable plugin + # provisioning (KB init then no-ops). ROSETTA_PLUGIN_PATH: Optional[str] = "/opt/rosetta-plugin" # PostHog Telemetry Configuration diff --git a/backend/app/core/mcp_config.py b/backend/app/core/mcp_config.py index cd654cc..7ca11dc 100644 --- a/backend/app/core/mcp_config.py +++ b/backend/app/core/mcp_config.py @@ -8,8 +8,8 @@ import shlex from typing import FrozenSet, List, Literal, Optional -from app.core.config import MCP_FIGMA, MCP_FIGMA_SERVER_KEY, MCP_PLAYWRIGHT, ROSETTA_SERVER_KEY, SUPPORTED_MCPS, Settings -from app.core.tool_usage import get_figma_mcp_tools, get_playwright_mcp_tools, get_rosetta_kb_tools +from app.core.config import MCP_FIGMA, MCP_FIGMA_SERVER_KEY, MCP_PLAYWRIGHT, SUPPORTED_MCPS, Settings +from app.core.tool_usage import get_figma_mcp_tools, get_playwright_mcp_tools from app.prompts.mcp_workflow_registry import ( CODE_GENERATION_AND_DEPLOY_OPTIONAL_MCPS, PLANNING_OPTIONAL_AGENT_MCPS, @@ -205,12 +205,6 @@ def _figma_pair(settings: Settings, enabled_mcps: FrozenSet[str]) -> tuple[dict, return config, (get_figma_mcp_tools() if config else []) -def _rosetta_pair(settings: Settings) -> tuple[dict, List[str]]: - """Rosetta MCP config + tools (enablement controlled by settings.ROSETTA_MCP_ENABLED).""" - config = build_rosetta_mcp_config(settings) - return config, (get_rosetta_kb_tools() if config else []) - - def _combine_pairs(*pairs: tuple[dict, List[str]]) -> tuple[dict, List[str]]: """Merge N (config_dict, tools_list) pairs into one.""" servers: dict = {} @@ -246,14 +240,9 @@ def coding_mcp_servers_and_tools( settings: Settings, enabled_mcps: FrozenSet[str], ) -> tuple[dict, List[str]]: - """Playwright + Figma + optional Rosetta — used by generation and deploy/QA phase agents. - - Rosetta is appended when ``ROSETTA_MCP_ENABLED`` (same as planning/KB init); not part of - ``SUPPORTED_MCPS`` / prune candidates. - """ + """Playwright + Figma — used by generation and deploy/QA phase agents.""" allowed = enabled_mcps & CODE_GENERATION_AND_DEPLOY_OPTIONAL_MCPS return _combine_pairs( - _rosetta_pair(settings), _playwright_pair(settings, allowed), _figma_pair(settings, allowed), ) @@ -263,7 +252,7 @@ def planning_mcp_servers_and_tools( settings: Settings, enabled_mcps: FrozenSet[str], ) -> tuple[dict, List[str]]: - """Figma only — used by planning agents. Rosetta is intentionally excluded from planning.""" + """Figma only — used by planning agents.""" allowed = enabled_mcps & PLANNING_OPTIONAL_AGENT_MCPS return _combine_pairs( _figma_pair(settings, allowed), @@ -287,35 +276,3 @@ def mcp_prompt_hints(enabled_mcps: FrozenSet[str]) -> str: if not lines: return "" return "\n## Available MCP Tools\n" + "\n".join(f"- {line}" for line in lines) + "\n" - - -def build_rosetta_mcp_config(settings: Settings) -> dict[str, dict]: - """Build MCP server config dict for ClaudeAgentOptions.mcp_servers. - - Returns an empty dict when the feature is disabled, which signals - callers to skip KB init entirely. - """ - if not settings.ROSETTA_MCP_ENABLED: - return {} - - env: dict[str, str] = {} - server_url = (settings.ROSETTA_SERVER_URL or "").strip() - if server_url: - env["ROSETTA_SERVER_URL"] = server_url - rosetta_api_key = (settings.ROSETTA_API_KEY or "").strip() - if rosetta_api_key: - env["ROSETTA_API_KEY"] = rosetta_api_key - user_email = (settings.ROSETTA_USER_EMAIL or "").strip() - if user_email: - env["ROSETTA_USER_EMAIL"] = user_email - ims_version = settings.ROSETTA_IMS_VERSION.strip() - if ims_version: - env["VERSION"] = ims_version - - return { - ROSETTA_SERVER_KEY: { - "command": settings.ROSETTA_MCP_COMMAND, - "args": settings.ROSETTA_MCP_ARGS.split(), - "env": env, - } - } diff --git a/backend/app/core/mcp_selection.py b/backend/app/core/mcp_selection.py index 422c66b..b02fc4d 100644 --- a/backend/app/core/mcp_selection.py +++ b/backend/app/core/mcp_selection.py @@ -5,10 +5,9 @@ Instances may be short-lived (one per step or phase call) or held across a phase loop; settings and enabled_mcps are fixed per instance. -Policy (Rosetta always on when ROSETTA_MCP_ENABLED; Playwright/Figma from enabled_mcps): - KB_INIT — Rosetta servers + kb_tools + rosetta write-allowed tools - GENERATION — Rosetta + Playwright + Figma (per-phase intersection) - DEPLOY_AND_E2E — Rosetta + Playwright + Figma (per-phase intersection) +Policy (Playwright/Figma from enabled_mcps): + GENERATION — Playwright + Figma (per-phase intersection) + DEPLOY_AND_E2E — Playwright + Figma (per-phase intersection) all other steps — no MCP servers """ @@ -17,17 +16,8 @@ from typing import FrozenSet, List from app.core.config import Settings -from app.core.mcp_config import ( - build_rosetta_mcp_config, - coding_mcp_servers_and_tools, -) -from app.core.rosetta_kb import RosettaKbMode, resolve_rosetta_kb_mode +from app.core.mcp_config import coding_mcp_servers_and_tools from app.core.telemetry_context import TelemetryContext -from app.core.tool_usage import ( - get_rosetta_allowed_tools, - get_rosetta_kb_tools, - get_rosetta_plugin_tools, -) from app.schemas.generation_workflow_enums import WorkflowStepName @@ -43,33 +33,18 @@ class McpSelection: servers: dict[str, dict] allowed_tools: List[str] - # Which Rosetta KB source this selection represents. Only meaningful for KB_INIT; - # every other step leaves it DISABLED. PROVISIONED_PLUGIN means the bundled plugin - # is vendored into the workspace ``.claude/`` (WorkspaceManager.provision_rosetta_plugin) - # and discovered via setting_sources=["project"] — no MCP server, no SDK ``plugins=`` - # loader. Mutually exclusive with ``servers`` (live MCP wins over the bundled plugin). - kb_mode: RosettaKbMode = RosettaKbMode.DISABLED - - @property - def uses_provisioned_plugin(self) -> bool: - """True when the KB comes from the provisioned (vendored) Rosetta plugin.""" - return self.kb_mode is RosettaKbMode.PROVISIONED_PLUGIN @property def is_empty(self) -> bool: - """True when there is nothing to attach — no MCP servers and no provisioned plugin.""" - return not self.servers and not self.uses_provisioned_plugin + """True when there are no MCP servers or tools to attach.""" + return not self.servers and not self.allowed_tools class McpSelector: """Dispatch MCP selection per workflow step with structured logging. - ``for_step`` covers PLANNING, GENERATION, DEPLOY_AND_E2E, and all no-MCP - steps. ``for_kb_init`` handles the special case where the agent also needs - write-access tools scoped to the rosetta output directory. - - Usage: create one instance per phase loop (e.g. ``execute_all_phases``) or - per individual step call (e.g. ``run_kb_init_agent``). + Usage: create one instance per phase loop (e.g. ``execute_all_phases``) or per + individual workflow step. Both patterns are valid — the selector is stateless between calls. """ @@ -95,14 +70,7 @@ def for_step( intersection of ``plan.applicable_agent_mcps ∩ enabled_mcps ∩ SUPPORTED_MCPS``. When ``phase_mcps`` is None it falls back to ``enabled_mcps``. - Raises ValueError for KB_INIT — use ``for_kb_init`` instead. """ - if step == WorkflowStepName.KB_INIT: - raise ValueError( - "Use for_kb_init(workspace_root, rosetta_dir) for the kb_init step — " - "it needs workspace paths to scope rosetta write-allowed tools." - ) - if step in (WorkflowStepName.GENERATION, WorkflowStepName.DEPLOY_AND_E2E): mcps = phase_mcps if phase_mcps is not None else self._enabled_mcps servers, tools = coding_mcp_servers_and_tools(self._settings, mcps) @@ -113,40 +81,6 @@ def for_step( self._log(step, selection, phase_mcps=phase_mcps) return selection - def for_kb_init(self, workspace_root: str) -> McpSelection: - """Return MCP selection for the kb_init step, dispatched on ``RosettaKbMode``. - - The mode is resolved once in ``app.core.rosetta_kb`` (the single source of truth) - so this selection and the actions keyed on it — provisioning, ``CLAUDE_PLUGIN_ROOT`` - injection, unpack-skip, KB prompt shape — can never disagree: - - - ``LIVE_MCP`` -> attach the live Rosetta ``KnowledgeBase`` MCP server. - - ``PROVISIONED_PLUGIN`` -> the default: the bundled plugin is vendored into the - workspace ``.claude/`` (WorkspaceManager.provision_rosetta_plugin) and discovered - via setting_sources=["project"]; the agent drives init via its Skills/commands. - No MCP server, no SDK ``plugins=`` loader. - - ``DISABLED`` -> empty selection; callers check ``is_empty`` and skip. - - Both non-empty modes need the rosetta write-scoped tools so the agent can populate - the output dir. Callers prepend ``get_common_allowed_tools(workspace_root)`` for - general file access. ``settings.ROSETTA_OUTPUT_DIR`` is only read when a mode is - active, so it need not exist on the settings mock when KB is DISABLED. - """ - mode = resolve_rosetta_kb_mode(self._settings) - if mode is RosettaKbMode.LIVE_MCP: - rosetta_dir = self._settings.ROSETTA_OUTPUT_DIR - servers = build_rosetta_mcp_config(self._settings) - tools = get_rosetta_kb_tools() + get_rosetta_allowed_tools(workspace_root, rosetta_dir) - selection = McpSelection(servers=servers, allowed_tools=tools, kb_mode=mode) - elif mode is RosettaKbMode.PROVISIONED_PLUGIN: - rosetta_dir = self._settings.ROSETTA_OUTPUT_DIR - tools = get_rosetta_plugin_tools() + get_rosetta_allowed_tools(workspace_root, rosetta_dir) - selection = McpSelection(servers={}, allowed_tools=tools, kb_mode=mode) - else: - selection = McpSelection(servers={}, allowed_tools=[], kb_mode=mode) - self._log(WorkflowStepName.KB_INIT, selection) - return selection - def _log( self, step: WorkflowStepName, diff --git a/backend/app/core/rosetta_kb.py b/backend/app/core/rosetta_kb.py index d96ea38..9bed025 100644 --- a/backend/app/core/rosetta_kb.py +++ b/backend/app/core/rosetta_kb.py @@ -1,78 +1,14 @@ -"""Single source of truth for *which* Rosetta knowledge-base source is active. +"""Resolve the bundled Rosetta plugin used by knowledge-base initialization.""" -There are three mutually-exclusive KB sources, modelled by ``RosettaKbMode``: - -- ``LIVE_MCP`` — the live ims-mcp ``KnowledgeBase`` server (Grid-Dynamics-internal). - Off by default; opt in with ``ROSETTA_MCP_ENABLED=true`` (+ ``ROSETTA_API_KEY`` …). -- ``PROVISIONED_PLUGIN`` — the **default**. The Rosetta plugin is baked into the - backend image at ``ROSETTA_PLUGIN_PATH`` (pod-wide), then *provisioned* into each - workspace at runtime: ``WorkspaceManager.provision_rosetta_plugin`` copies its - agents/skills/commands into ``.claude/`` and merges its hooks into - ``.claude/settings.json``, discovered via ``setting_sources=["project"]``. This is - NOT the SDK ``plugins=`` loader and NOT a live MCP — the plugin is transiently - vendored into the project tree. Hence "provisioned plugin". -- ``DISABLED`` — neither MCP nor a usable plugin on disk; KB init no-ops. - -**Why this module exists.** The mode predicate used to be re-implemented in four -places (KB-init selection, plugin provisioning, the unpack-skip guard, and the -``CLAUDE_PLUGIN_ROOT`` env setup) and they drifted: the KB-init selection gated on -path *truthiness* while the others required the path to *exist on disk*, so a -misconfigured ``ROSETTA_PLUGIN_PATH`` could make the agent believe the toolset was -provisioned when nothing had been copied. Every caller now funnels through -``resolve_rosetta_kb_mode`` so the decision and the actions keyed on it can never -disagree. - -**Removing a source cleanly (the design goal).** Each KB source is one enum value -plus the call-site arms that match it. To delete the live-MCP path in a future PR: -drop ``LIVE_MCP`` here, delete the ``RosettaKbMode.LIVE_MCP`` branch in -``resolve_rosetta_kb_mode``, and remove the (grep-able) ``is LIVE_MCP`` / ``for_step`` -MCP arms — no hunting for re-derived predicates. -""" - -from enum import Enum from pathlib import Path from typing import Optional from app.core.config import Settings -class RosettaKbMode(str, Enum): - """Which Rosetta KB source is active for this run. See module docstring.""" - - DISABLED = "disabled" - LIVE_MCP = "live_mcp" - PROVISIONED_PLUGIN = "provisioned_plugin" - - -def resolve_rosetta_kb_mode(settings: Settings) -> RosettaKbMode: - """Resolve the active KB mode from settings — the only place this is decided. - - Precedence: the live MCP wins over the bundled plugin (so an operator can opt - back into the project-tailored KB). ``PROVISIONED_PLUGIN`` requires the plugin to - actually exist on disk; a configured-but-missing ``ROSETTA_PLUGIN_PATH`` resolves - to ``DISABLED`` rather than a half-active plugin state. - """ - if settings.ROSETTA_MCP_ENABLED: - return RosettaKbMode.LIVE_MCP - if _existing_plugin_root(settings.ROSETTA_PLUGIN_PATH) is not None: - return RosettaKbMode.PROVISIONED_PLUGIN - return RosettaKbMode.DISABLED - - def rosetta_plugin_root(settings: Settings) -> Optional[str]: - """The on-disk plugin root when ``PROVISIONED_PLUGIN`` mode is active, else ``None``. - - Used by the provisioning copy and by ``CLAUDE_PLUGIN_ROOT`` env injection so both - read the exact path the mode resolver validated. - """ - if settings.ROSETTA_MCP_ENABLED: - return None - return _existing_plugin_root(settings.ROSETTA_PLUGIN_PATH) - - -def _existing_plugin_root(plugin_path: Optional[str]) -> Optional[str]: - """Normalize ``plugin_path`` and return it only if it is an existing directory.""" - plugin_root = (plugin_path or "").strip() + """Return the normalized plugin root when it is an existing directory.""" + plugin_root = (settings.ROSETTA_PLUGIN_PATH or "").strip() if not plugin_root or not Path(plugin_root).is_dir(): return None return plugin_root diff --git a/backend/app/core/tool_usage.py b/backend/app/core/tool_usage.py index 5c1ecbf..186533f 100644 --- a/backend/app/core/tool_usage.py +++ b/backend/app/core/tool_usage.py @@ -7,7 +7,7 @@ from typing import List -from app.core.config import MCP_FIGMA_SERVER_KEY, MCP_PLAYWRIGHT, ROSETTA_SERVER_KEY +from app.core.config import MCP_FIGMA_SERVER_KEY, MCP_PLAYWRIGHT from app.core.ttl_config import GenerationLifecyclePolicy @@ -324,10 +324,6 @@ def get_tools(cls) -> List[str]: return [t + _MCP_TOOL_WILDCARD for t in cls._tools] -class _RosettaKbMcpTools(McpToolSet): - _tools = [f"{MCP_TOOL_PREFIX}{ROSETTA_SERVER_KEY}"] - - # Microsoft @playwright/mcp — server key MCP_PLAYWRIGHT. class _PlaywrightMcpTools(McpToolSet): _tools = [f"{MCP_TOOL_PREFIX}{MCP_PLAYWRIGHT}"] @@ -338,18 +334,13 @@ class _FigmaMcpTools(McpToolSet): _tools = [f"{MCP_TOOL_PREFIX}{MCP_FIGMA_SERVER_KEY}"] -def get_rosetta_kb_tools() -> List[str]: - return _RosettaKbMcpTools.get_tools() - - def get_rosetta_plugin_tools() -> List[str]: - """Tools the KB init agent needs in plugin mode (no MCP server). + """Tools the KB init agent needs to drive the provisioned Rosetta plugin. The Rosetta plugin ships its init-workspace flow as Agent Skills / slash-commands, - so the agent drives initialization via ``Skill`` / ``SlashCommand`` instead of the - ``mcp__KnowledgeBase__*`` tools used in MCP mode. ``Skill`` is also in - ``skill_usage`` (folded into ``get_common_allowed_tools``); listed here so the - plugin-mode allowlist is self-describing. + so the agent drives initialization via ``Skill`` / ``SlashCommand``. ``Skill`` is also + in ``skill_usage`` (folded into ``get_common_allowed_tools``); listed here so the + KB-init allowlist is self-describing. """ return ["Skill", "SlashCommand"] @@ -362,31 +353,6 @@ def get_figma_mcp_tools() -> List[str]: return _FigmaMcpTools.get_tools() -def get_rosetta_allowed_tools(workspace_path: str, rosetta_dir: str) -> List[str]: - """ - Get allowed tools for KB init agent to write to rosetta/ output directory. - - The agent stages all output under rosetta/ (no .claude/ in any path) to avoid - the SDK's hardcoded sensitive-file guard. Unpack remaps rosetta/agents/, - rosetta/skills/, and rosetta/commands/ to .claude/agents/, .claude/skills/, and - .claude/commands/ after the agent finishes. - - Args: - workspace_path: The workspace root path (e.g., "/workspaces/workspaceName") - rosetta_dir: The rosetta output directory name (e.g., "rosetta") - - Returns: - List of allowed tool strings for rosetta/ directory access - """ - rosetta_path = f"{workspace_path}/{rosetta_dir}" - return [ - f"Read({rosetta_path}/**)", - f"Write({rosetta_path}/**)", - f"Edit({rosetta_path}/**)", - f"StrReplace({rosetta_path}/**)", - f"Glob({rosetta_path}/**)", - ] - def get_workspace_rm_bash_allowlist(workspace_path: str) -> List[str]: """ Allow ``rm`` only under the isolated workspace (e.g. deleting ``*_part*.md`` after ``cat`` merge). diff --git a/backend/app/prompts/knowledge_base_agent.py b/backend/app/prompts/knowledge_base_agent.py index 5a2639a..61f7218 100644 --- a/backend/app/prompts/knowledge_base_agent.py +++ b/backend/app/prompts/knowledge_base_agent.py @@ -1,118 +1,49 @@ -"""Prompt for the Rosetta knowledge-base (KB) initialization agent. - -Split out of ``agents_claude_code.py`` to keep that module focused. The two output -regimes (live MCP vs. the provisioned bundled plugin) are selected by ``use_plugin``; -see ``kb_init_agent_template`` for the details. -""" - -from app.core.config import ROSETTA_SERVER_KEY +"""Prompt for knowledge-base initialization with the provisioned Rosetta plugin.""" def kb_init_agent_template( - rosetta_output_dir: str, model: str, - use_plugin: bool = False, outputs_dir: str = "docs", ) -> str: - """Prompt for the KB initialization agent. - - Two output regimes, selected by ``use_plugin``: - - - **MCP mode** (``use_plugin`` False): the agent drives init through the live Rosetta - ``KnowledgeBase`` MCP server and stages ALL output under ``{rosetta_output_dir}/`` (it - cannot write ``.claude/`` directly — the SDK sensitive-file guard blocks it). A later - ``unpack_rosetta_artifacts`` step remaps that staging tree into the workspace root and - ``.claude/``. - - **Plugin mode** (``use_plugin`` True): the Rosetta agents/skills/commands are copied into - ``.claude/`` programmatically (``WorkspaceManager.provision_rosetta_plugin``), so there is - no unpack step. The agent's only job is to write the project documents to their FINAL - locations directly — ``CLAUDE.md`` at the workspace root and the docs under - ``{outputs_dir}/`` — and to leave ``.claude/`` alone. - """ - if use_plugin: - source_clause = "using the Rosetta knowledge-base toolset provided in this workspace" - overrides = f"""Follow those instructions with these **overrides**: + """Prompt for direct, plugin-driven workspace initialization.""" + overrides = f"""Follow those instructions with these **overrides**: - **SKIP implementation plan generation** — SpecFlow has its own planning agent for that. - - **SKIP the workflow's "shells" phase** — the Rosetta agents, skills, and commands are - already provided by the plugin under `.claude/`; do NOT generate shell shims and do NOT - write anything under `.claude/`. + - **SKIP the workflow's "shells" phase** — use the provisioned plugin assets and do not + generate shell shims. - **Write project documents to their FINAL locations** — `CLAUDE.md` at the workspace root, - other docs under `{outputs_dir}/`. There is no unpack step; do NOT stage under `{rosetta_output_dir}/`. + other docs under `{outputs_dir}/`. - **No human in the loop** — process all phases without pauses, do not ask questions.""" - usage_section = f"""## Rosetta Plugin Usage + usage_section = f"""## Rosetta Plugin Usage -The Rosetta knowledge-base toolset is already present in this workspace: its Skills, -subagents, and slash-commands were copied into `.claude/` and are discovered automatically — -there is NO MCP server to call and nothing to download. +The Rosetta knowledge-base plugin is provisioned in this workspace. Its Skills, subagents, and +slash commands are available through the project-scoped `.claude/` tree. 1. Invoke the Rosetta **init-workspace** workflow: run its `init-workspace-flow` Skill (use the `Skill` tool; if it is exposed as a slash command, use `SlashCommand`). That - workflow holds the full initialization instructions and bundles every referenced document - locally, so no remote fetching is needed. + workflow contains the full initialization instructions and referenced documents. 2. {overrides}""" - output_paths_section = f"""## Output Paths + output_paths_section = f"""## Output Paths -There is NO unpack step in plugin mode — write every document to its FINAL workspace location: +Write every document to its FINAL workspace location: - `CLAUDE.md` at the workspace root — project-level instructions (auto-loaded by the SDK). - `{outputs_dir}/CONTEXT.md`, `{outputs_dir}/ARCHITECTURE.md`, `{outputs_dir}/CODEMAP.md`, and the other generated documents — project documentation discovered by downstream agents. -Do NOT write anything under `.claude/` — the Rosetta agents/skills/commands are already -installed there by the plugin. Do NOT stage output under `{rosetta_output_dir}/`.""" - constraints_tail = f"""- Produce KB documents only (`CLAUDE.md` and the project docs under `{outputs_dir}/`). Do NOT +Leave the provisioned `.claude/` content unchanged.""" + constraints_tail = f"""- Produce KB documents only (`CLAUDE.md` and the project docs under `{outputs_dir}/`). Do NOT produce an implementation plan. NEVER write files named IMPLEMENTATION_PLAN.md, e2e-test-plan.md, or any planning artifacts — those are written exclusively by the planning agent, not by this agent. Do NOT start coding. - Do NOT create `.cursor/` directories and do NOT write under `.claude/` — the plugin owns it.""" - else: - source_clause = "by calling the Rosetta KnowledgeBase MCP" - overrides = f"""Follow those instructions with these **overrides**: - - **SKIP implementation plan generation** — SpecFlow has its own planning agent for that. - - **All output paths MUST be prefixed with `{rosetta_output_dir}/`**. - - **Use `{rosetta_output_dir}/agents/`, `{rosetta_output_dir}/skills/`, and - `{rosetta_output_dir}/commands/` for those artifacts, NOT `.cursor/` paths** — - unpack maps them to `.claude/agents/`, `.claude/skills/`, and `.claude/commands/` automatically. - - **No human in the loop** — process all phases without pauses, do not ask questions.""" - usage_section = f"""## Rosetta MCP Usage - -1. Call `mcp__{ROSETTA_SERVER_KEY}__query_instructions` with title `"workflows/init-workspace-flow.md"` to get initialization - instructions. -2. **Parallelize the fetch-and-write step (PERFORMANCE CRITICAL):** - `init workflow` references many documents (often 18+) that are each fetched via - `mcp__{ROSETTA_SERVER_KEY}__query_instructions` and written verbatim to a destination path. - Doing this sequentially is the main bottleneck. Instead, enumerate the - `(title, destination_path)` pairs, then dispatch up to **5 parallel worker - subagents** via the Task tool, splitting the pairs into roughly even shards. Each worker's - job is trivial and self-contained: for each pair, call `query_instructions` with the title and - write the returned content verbatim to the destination path. Workers need no other context. -3. {overrides}""" - output_paths_section = f"""## Output Paths - -ALL output MUST go under `{rosetta_output_dir}/`. The Rosetta initialization instructions - define which files to produce. Place them using these path conventions: - -- `{rosetta_output_dir}/CLAUDE.md` — project-level instructions (will be unpacked to workspace root) -- `{rosetta_output_dir}/agents/*.md` — subagent definitions with YAML frontmatter - (unpacked to `.claude/agents/` so Claude Code SDK auto-discovers them) -- `{rosetta_output_dir}/skills//SKILL.md` — Agent Skills (unpacked to `.claude/skills/`) -- `{rosetta_output_dir}/commands/*.md` — slash-command definitions (unpacked to `.claude/commands/`) -- `{rosetta_output_dir}/docs/*.md` — project documentation (will be unpacked to workspace root) - -Do NOT write directly to the workspace root. Do NOT write outside `{rosetta_output_dir}/`.""" - constraints_tail = f"""- Produce KB artifacts only (agents, docs, skills, commands as instructed by workflows/init-workspace-flow.md). Do NOT produce an implementation plan. - NEVER write files named IMPLEMENTATION_PLAN.md, e2e-test-plan.md, or any planning - artifacts — those are written exclusively by the planning agent, not by this agent. - Do NOT start coding. Do NOT modify existing files outside `{rosetta_output_dir}/`. -- Do NOT create `.cursor/` directories. Stage subagents/skills/commands under `{rosetta_output_dir}/agents/`, `{rosetta_output_dir}/skills/`, `{rosetta_output_dir}/commands/` — NOT under `.claude/` directly.""" return f"""You are a Knowledge Base initialization agent for a software project workspace used by AI coding agents running in Claude Code SDK (not Cursor IDE). ## Your Task -Initialize project knowledge {source_clause}, then follow the workflow instructions to write -structured output files that downstream agents will auto-discover. +Initialize project knowledge with the provisioned Rosetta plugin, then follow the workflow +instructions to write structured output files that downstream agents will auto-discover. Every agent starts configured for a fixed model name. diff --git a/backend/app/prompts/mcp_workflow_registry.py b/backend/app/prompts/mcp_workflow_registry.py index aa52628..fc78e45 100644 --- a/backend/app/prompts/mcp_workflow_registry.py +++ b/backend/app/prompts/mcp_workflow_registry.py @@ -1,5 +1,5 @@ """ -Central matrix: optional agent MCPs (playwright, figma) and Rosetta per harness workflow. +Central matrix: optional agent MCPs (playwright, figma) per harness workflow. SSOT for "which user-toggleable MCP ids can attach where" and where prune keywords are read from. Runtime enablement: resolve_enabled_mcps* → intersection with these allowlists → stdio builders in mcp_config. @@ -27,11 +27,6 @@ # MCP prune (optional LLM after keyword grep): no MCP servers attached to that agent session. MCP_PRUNE_AGENT_ATTACHED_OPTIONAL_MCPS: Final[FrozenSet[str]] = frozenset() -# --- Rosetta (KnowledgeBase MCP): deployment-controlled, not user-selectable --- -# Gate: settings.ROSETTA_MCP_ENABLED. When True, attached to kb_init, generation, -# and deploy_and_e2e (see mcp_config.coding_mcp_servers_and_tools). -# NOT attached to: planning, sync_*, baseline, archive_outputs, generation (p10y). - # Keyword scan before / instead of LLM prune: same files as mcp_prune.scan_mcp_keyword_evidence MCP_PRUNE_SCANS_SPEC_INDEX_AND_SPEC_TREE: Final[bool] = True diff --git a/backend/app/schemas/generation_workflow_enums.py b/backend/app/schemas/generation_workflow_enums.py index ec7fdc8..45a9784 100644 --- a/backend/app/schemas/generation_workflow_enums.py +++ b/backend/app/schemas/generation_workflow_enums.py @@ -36,7 +36,7 @@ class GenerationCheckpoint(str, Enum): 1. FILES_UPLOADED - Specs/src/outputs uploaded to primary workspace 2. CONTRACT_VALIDATED - Required files at canonical paths, plans converted to JSON - 3. KB_INIT_DONE - Knowledge base context fetched (Rosetta MCP) + 3. KB_INIT_DONE - Knowledge base context fetched (Rosetta plugin) 4. GENERATION_STARTED - Code generation started (telemetry continuity) 5. GENERATION_DONE - All execution phases completed for all workspaces 6. DEPLOY_AND_E2E_DONE - Deploy + e2e loop (INTEGRATION_TESTS_READY only) diff --git a/backend/app/services/claude_code.py b/backend/app/services/claude_code.py index 845eccb..30b41bb 100644 --- a/backend/app/services/claude_code.py +++ b/backend/app/services/claude_code.py @@ -279,21 +279,19 @@ async def clear_workspace_caches(workspace_ids: List[str]) -> None: def setup_rosetta_plugin_env() -> Dict[str, str]: - """Return ``CLAUDE_PLUGIN_ROOT`` for the bundled Rosetta plugin in provisioned-plugin mode. - - In provisioned-plugin mode ``WorkspaceManager.provision_rosetta_plugin`` copies the plugin's - agents/skills/commands into each workspace ``.claude/`` and merges its hooks into - ``.claude/settings.json``. The plugin's own files (hook scripts, rules/, templates/) stay in - the read-only image at ``ROSETTA_PLUGIN_PATH``; pointing ``CLAUDE_PLUGIN_ROOT`` there lets the - merged hooks' ``${CLAUDE_PLUGIN_ROOT}`` resolve. Hooks run as CLI subprocesses (not the - agent's sandboxed tools), so the read-only ``/opt`` path is reachable; this env var is just a - string and does not widen the agent's file-tool sandbox. - - Returns an empty dict in any other mode (live MCP, or no usable plugin on disk), so the env - var is simply not set. The path is resolved through ``app.core.rosetta_kb`` — the same single - source of truth that gates provisioning — so the env var is set exactly when the plugin was - provisioned. The ``is_dir`` stat is intentionally not cached: it is cheap, the path can be - mounted lazily, and a stale cache would desync this from provisioning. + """Return ``CLAUDE_PLUGIN_ROOT`` for the bundled Rosetta plugin. + + ``WorkspaceManager.provision_rosetta_plugin`` copies the plugin's agents/skills/commands into + each workspace ``.claude/`` and merges its hooks into ``.claude/settings.json``. The plugin's + own files (hook scripts, rules/, templates/) stay in the read-only image at + ``ROSETTA_PLUGIN_PATH``; pointing ``CLAUDE_PLUGIN_ROOT`` there lets the merged hooks' + ``${CLAUDE_PLUGIN_ROOT}`` resolve. + + Returns an empty dict when there is no usable plugin on disk, so the env var is simply not + set. The path is resolved through ``app.core.rosetta_kb`` — the same single source of truth + that gates provisioning — so the env var is set exactly when the plugin was provisioned. The + ``is_dir`` stat is intentionally not cached: it is cheap, the path can be mounted lazily, and + a stale cache would desync this from provisioning. """ plugin_root = rosetta_plugin_root(settings) return {"CLAUDE_PLUGIN_ROOT": plugin_root} if plugin_root else {} @@ -754,7 +752,7 @@ async def agent_query( cache_env = setup_workspace_cache_directories(workspace_path) env_config.update(cache_env) - # CLAUDE_PLUGIN_ROOT for the bundled Rosetta plugin (plugin mode); empty otherwise. + # CLAUDE_PLUGIN_ROOT for the bundled Rosetta plugin; empty when no plugin is on disk. env_config.update(setup_rosetta_plugin_env()) env_config.update(setup_claude_code_tmpdir()) @@ -1440,14 +1438,14 @@ async def phase_agent_fn( mcp_tool_list = phase_mcp_tools or [] common_tools = get_common_allowed_tools(isolated_root) base_phase_tools = common_tools + (extra_allowed_tools or []) + mcp_tool_list - # By design there is no programmatic coding subagent: Rosetta is always-on - # (the plugin is baked into the image and provisioned into every workspace, or - # the live MCP is enabled), so the phase agent delegates to the Rosetta agents - # (engineer/architect/reviewer/...) in .claude/agents/, discovered via - # setting_sources=["project"]. See WorkspaceManager.provision_rosetta_plugin and - # the coding-flow guidance in generate_production_agent_template. If Rosetta is - # unavailable (KB DISABLED, or a non-fatal KB-init failure), the phase prompt's - # explicit fallback has the agent implement the phase directly. + # By design there is no programmatic coding subagent: the Rosetta plugin is baked + # into the image and provisioned into every workspace, so the phase agent + # delegates to the Rosetta agents (engineer/architect/reviewer/...) in + # .claude/agents/, discovered via setting_sources=["project"]. See + # WorkspaceManager.provision_rosetta_plugin and the coding-flow guidance in + # generate_production_agent_template. If Rosetta is unavailable (KB DISABLED, or a + # non-fatal KB-init failure), the phase prompt's explicit fallback has the agent + # implement the phase directly. result: AgentResult = await agent_query_with_resume( system_prompt=phase_prompt, phase_number=phase_num, diff --git a/backend/app/services/telemetry.py b/backend/app/services/telemetry.py index 74a2e10..e6ece9a 100644 --- a/backend/app/services/telemetry.py +++ b/backend/app/services/telemetry.py @@ -255,7 +255,7 @@ def capture_kb_init_event( generation_id: Current generation ID. workspace_name: Workspace where KB init ran. status: "success", "skipped", or "failed". - generated_files: List of relative paths under rosetta/ that were created. + generated_files: List of relative paths (CLAUDE.md + outputs_dir docs) created by KB init. duration_seconds: Wall-clock time the agent ran. error_message: Error string when status == "failed". """ diff --git a/backend/app/services/workflow_steps.py b/backend/app/services/workflow_steps.py index fa5b824..90ce6fa 100644 --- a/backend/app/services/workflow_steps.py +++ b/backend/app/services/workflow_steps.py @@ -31,7 +31,9 @@ build_coding_complete_pre_deploy_response, notifications, ) +from app.core.rosetta_kb import rosetta_plugin_root from app.core.telemetry_context import TelemetryContext +from app.core.tool_usage import get_rosetta_plugin_tools from app.schemas.telemetry_workflow import TelemetryWorkflowLabel from app.services.langfuse import tracer from app.prompts.agents_claude_code import ( @@ -44,6 +46,7 @@ from app.prompts.knowledge_base_agent import kb_init_agent_template from app.prompts.plan_conversion_agent import plan_conversion_agent_template from app.schemas.deploy_context import DeployGithubContext +from app.schemas.generation_workflow_enums import WorkflowStepName from app.schemas.llm_tier import ( WORKFLOW_TIER_MAP, WorkflowName, @@ -429,19 +432,6 @@ async def build_workflow_context( ) -def _collect_rosetta_file_manifest(workspace_root: str, rosetta_dir: str) -> list[str]: - """Walk rosetta/ directory and return a sorted list of relative file paths.""" - workspace_root_path = Path(workspace_root) - rosetta_path = workspace_root_path / rosetta_dir - if not rosetta_path.exists(): - return [] - files: list[str] = [] - for item in rosetta_path.rglob("*"): - if item.is_file(): - files.append(str(item.relative_to(workspace_root_path))) - return sorted(files) - - # Root project-instructions file the provisioned-plugin KB-init agent writes (outside outputs_dir). _ROOT_CLAUDE_MD: Final[str] = "CLAUDE.md" @@ -458,13 +448,13 @@ def _snapshot_outputs_dir_files(workspace_root: str, outputs_dir: str) -> set[st def _collect_provisioned_plugin_manifest( workspace_root: str, outputs_dir: str, pre_existing: set[str] ) -> list[str]: - """KB artifacts a provisioned-plugin KB-init run actually produced. + """KB artifacts the KB-init run actually produced. - Unlike MCP mode (which stages output under its own rosetta/ dir), the provisioned-plugin agent - writes ``CLAUDE.md`` at the workspace root and docs under ``outputs_dir`` — but ``outputs_dir`` - was already populated with the user's uploaded specs/plans before KB init. Report only the - root ``CLAUDE.md`` plus files under ``outputs_dir`` that did NOT exist before the run, so the - manifest reflects KB output rather than pre-existing uploads. + The provisioned-plugin agent writes ``CLAUDE.md`` at the workspace root and docs under + ``outputs_dir`` — but ``outputs_dir`` was already populated with the user's uploaded + specs/plans before KB init. Report only the root ``CLAUDE.md`` plus files under + ``outputs_dir`` that did NOT exist before the run, so the manifest reflects KB output rather + than pre-existing uploads. """ root = Path(workspace_root) new_under_outputs = _snapshot_outputs_dir_files(workspace_root, outputs_dir) - pre_existing @@ -474,37 +464,33 @@ def _collect_provisioned_plugin_manifest( return sorted(files) -def _generate_mock_rosetta_artifacts( - workspace_root: str, rosetta_dir: str, logger: logging.Logger +def _generate_mock_kb_init_artifacts( + workspace_root: str, outputs_dir: str, logger: logging.Logger ) -> None: - """Create minimal mock rosetta/ artifacts for SKIP_MODE. + """Create minimal mock KB-init artifacts for SKIP_MODE. - Produces the same directory structure a real KB init agent would, - so the downstream unpack/sync flow is exercised end-to-end. + Mirrors what the real provisioned-plugin KB-init agent produces: ``CLAUDE.md`` at the + workspace root and project docs under ``outputs_dir/``. ``.claude/`` is left untouched — + the plugin's discovery trees are copied in separately by ``provision_rosetta_plugin``. """ - root = Path(workspace_root) / rosetta_dir - agents_dir = root / "agents" - docs_dir = root / "docs" - agents_dir.mkdir(parents=True, exist_ok=True) + root = Path(workspace_root) + docs_dir = root / outputs_dir docs_dir.mkdir(parents=True, exist_ok=True) - (root / "CLAUDE.md").write_text( + (root / _ROOT_CLAUDE_MD).write_text( "# CLAUDE.md\n\n[SKIP_MODE] Mock project instructions.\n" ) - (agents_dir / "default.md").write_text( - "---\ndescription: Default agent\n---\n\n[SKIP_MODE] Mock subagent.\n" - ) (docs_dir / "CONTEXT.md").write_text( "# Context\n\n[SKIP_MODE] Mock project context.\n" ) - logger.info(f"[SKIP_MODE] Created mock rosetta artifacts under {root}") + logger.info(f"[SKIP_MODE] Created mock KB-init artifacts (CLAUDE.md + {outputs_dir}/CONTEXT.md)") async def run_kb_init_and_sync_workspaces(ctx: WorkflowContext) -> None: - """Run KB init on the primary workspace, then sync specs/outputs/rosetta and src to all workspaces. + """Run KB init on the primary workspace, then sync specs/outputs and src to all workspaces. - MCP upload targets the primary workspace only (``sync_to_all=False``). Extra workspaces must - receive specs, plans, ``rosetta/``, and optional ``src/`` before parallel codegen. + KB init runs on the primary workspace only. Extra workspaces must receive specs, plans, + and optional ``src/`` before parallel codegen. """ await run_kb_init_agent(ctx) await sync_specs_to_workspaces(ctx) @@ -512,21 +498,23 @@ async def run_kb_init_and_sync_workspaces(ctx: WorkflowContext) -> None: async def run_kb_init_agent(ctx: WorkflowContext) -> None: - """Run KB initialization agent on primary workspace via Rosetta MCP. + """Run KB initialization agent on the primary workspace via the provisioned Rosetta plugin. - Writes output to rosetta/ folder in the primary workspace. - The sync step will later unpack these to SDK discovery paths. + The agent writes CLAUDE.md and project docs to their final locations directly. Non-fatal: if this fails, workflow continues without KB artifacts. Emits a kb_init_completed PostHog event with the generated file manifest. """ generation_id = getattr(ctx.request, "generation_id", "unknown") workspace_name = ctx.primary_workspace.workspace_path.name ws_root = ctx.primary_workspace.get_isolated_root() - mcp = McpSelector(ctx.settings, ctx.enabled_mcps, ctx.logger).for_kb_init(ws_root) - if mcp.is_empty: - ctx.logger.info( - "KB init skipped: Rosetta MCP disabled and no ROSETTA_PLUGIN_PATH configured" + configured_plugin_path = (ctx.settings.ROSETTA_PLUGIN_PATH or "").strip() + if rosetta_plugin_root(ctx.settings) is None: + reason = ( + "ROSETTA_PLUGIN_PATH is not configured" + if not configured_plugin_path + else f"ROSETTA_PLUGIN_PATH is not an existing directory: {configured_plugin_path}" ) + ctx.logger.info(f"KB init skipped: {reason}") telemetry.capture_kb_init_event( generation_id=generation_id, workspace_name=workspace_name, @@ -535,30 +523,29 @@ async def run_kb_init_agent(ctx: WorkflowContext) -> None: ) return - rosetta_dir = ctx.settings.ROSETTA_OUTPUT_DIR - uses_provisioned_plugin = mcp.uses_provisioned_plugin + mcp = McpSelector(ctx.settings, ctx.enabled_mcps, ctx.logger).for_step( + WorkflowStepName.KB_INIT + ) + plugin_tools = get_rosetta_plugin_tools() # Bind a manifest collector here (before the try) so both the success and except paths read it. - # MCP mode stages output under rosetta/, so the manifest is that whole tree. Provisioned-plugin - # mode writes docs to their FINAL locations (root CLAUDE.md + outputs_dir), where outputs_dir - # already holds the user's uploaded specs/plans — so snapshot it first and report only what KB - # init newly produced, otherwise the telemetry manifest is inflated with pre-existing uploads. - if uses_provisioned_plugin: - outputs_dir = ctx.request.outputs_dir - outputs_pre_existing = _snapshot_outputs_dir_files(ws_root, outputs_dir) - def collect_manifest() -> list[str]: - return _collect_provisioned_plugin_manifest(ws_root, outputs_dir, outputs_pre_existing) - else: - def collect_manifest() -> list[str]: - return _collect_rosetta_file_manifest(ws_root, rosetta_dir) + # The plugin agent writes docs to their FINAL locations (root CLAUDE.md + outputs_dir), where + # outputs_dir already holds the user's uploaded specs/plans — so snapshot it first and report + # only what KB init newly produced, otherwise the telemetry manifest is inflated with + # pre-existing uploads. + outputs_dir = ctx.request.outputs_dir + outputs_pre_existing = _snapshot_outputs_dir_files(ws_root, outputs_dir) + def collect_manifest() -> list[str]: + return _collect_provisioned_plugin_manifest(ws_root, outputs_dir, outputs_pre_existing) if is_skip_mode_enabled(): - # SKIP_MODE exercises the rosetta/ staging path (mock + collect) regardless of mode; it - # does NOT run the real provision/unpack, so the mock manifest is always read from rosetta/. - _generate_mock_rosetta_artifacts(ws_root, rosetta_dir, ctx.logger) - generated_files = _collect_rosetta_file_manifest(ws_root, rosetta_dir) + # SKIP_MODE writes the same artifacts a real KB init would (root CLAUDE.md + a doc under + # outputs_dir) without running the agent, and reports them via the same collector — so the + # mock and the real path stay in lockstep. Plugin provisioning still happens later in + # prepare_parallel_workspaces, exactly as in the real path. + _generate_mock_kb_init_artifacts(ws_root, outputs_dir, ctx.logger) + generated_files = collect_manifest() ctx.logger.warning( - f"[SKIP_MODE] KB init agent skipped — created {len(generated_files)} " - f"mock artifacts under {rosetta_dir}/" + f"[SKIP_MODE] KB init agent skipped — created {len(generated_files)} mock KB artifacts" ) TelemetryContext.set_workspace_name(workspace_name) TelemetryContext.set_workflow(TelemetryWorkflowLabel.plain(WorkflowName.KB_INIT)) @@ -597,15 +584,13 @@ def collect_manifest() -> list[str]: return_first=True, ) kb_init_prompt = kb_init_agent_template( - rosetta_output_dir=rosetta_dir, model=kb_init_model, - use_plugin=uses_provisioned_plugin, outputs_dir=ctx.request.outputs_dir, ) - # Provisioned-plugin mode: copy the bundled Rosetta plugin into the primary workspace - # .claude/ so the KB init agent discovers init-workspace-flow (and the rest of the - # toolset) via setting_sources=["project"]. No-op in MCP mode. Extra workspaces are - # provisioned later in prepare_parallel_workspaces. + # Copy the bundled Rosetta plugin into the primary workspace .claude/ so the KB init + # agent discovers init-workspace-flow (and the rest of the toolset) via + # setting_sources=["project"]. Extra workspaces are provisioned later in + # prepare_parallel_workspaces. ctx.workspace_manager.provision_rosetta_plugin(ctx.primary_workspace) provider_instance = ctx.workspace_manager.get_provider(ctx.primary_workspace) # Required for the TUI live-message stream: the StreamPublisher keys events @@ -624,7 +609,7 @@ def collect_manifest() -> list[str]: workspace_path=ws_root, model=kb_init_model, mcp_servers=mcp.servers, - allowed_tools=get_common_allowed_tools(ws_root) + mcp.allowed_tools, + allowed_tools=get_common_allowed_tools(ws_root) + plugin_tools, max_turns=100, logger=create_agent_logger( f"{workspace_name}-kb-init", generation_id=generation_id, diff --git a/backend/app/services/workspace_manager.py b/backend/app/services/workspace_manager.py index bc173b6..e1430ef 100644 --- a/backend/app/services/workspace_manager.py +++ b/backend/app/services/workspace_manager.py @@ -15,7 +15,7 @@ from app.services.git_utils import GitCommandError, run_git from app.core.config import DEFAULT_MODEL, Settings, WORKSPACE_DEFAULT_BRANCH, settings as global_settings -from app.core.rosetta_kb import RosettaKbMode, resolve_rosetta_kb_mode, rosetta_plugin_root +from app.core.rosetta_kb import rosetta_plugin_root from app.schemas.workspace import WorkspaceSettings from app.services.providers import BaseProvider, ProviderFactory from app.services.providers.credentials import ( @@ -23,12 +23,6 @@ resolve_provider_base_url, ) -# KB init writes under rosetta// (not `.claude/`) to avoid the SDK sensitive-path guard. -# unpack_rosetta_artifacts maps these into `.claude//` for Claude Code discovery. -_ROSETTA_DOT_CLAUDE_SUBDIRS: Final[frozenset[str]] = frozenset( - {"agents", "skills", "commands"} -) - # Conventional fallback (plugin source dir, workspace `.claude/` destination) pairs, used when # the plugin manifest can't be read. The real mapping is resolved per-plugin from plugin.json by # `_resolve_plugin_claude_map` (the Rosetta plugin declares "commands": "./workflows/", so @@ -43,9 +37,9 @@ ("workflows", "commands"), ) -# Project-level instructions file the KB-init agent produces at the workspace root. Propagated -# from the primary to each extra workspace (in provisioned-plugin mode the agent writes it there -# directly; in MCP mode unpack also recreates it per-workspace, so re-syncing it is harmless). +# Project-level instructions file the KB-init agent produces at the workspace root. The agent +# writes it on the primary; it is propagated from there to each extra workspace (harmless no-op +# when KB init was skipped and the file is absent). _ROSETTA_ROOT_CLAUDE_MD: Final[str] = "CLAUDE.md" # Names that must NEVER be copied between workspaces or removed when clearing the workspace @@ -285,12 +279,10 @@ def prepare_parallel_workspaces( # Prepare primary workspace self.prepare_single_workspace(primary_workspace, spec_path, outputs_dir, standards_source) - # Provision the bundled Rosetta plugin (plugin mode) and unpack KB artifacts on the - # primary. Provisioning is idempotent — run_kb_init_agent already provisioned the - # primary before KB init so its agent could discover the Rosetta skills. + # Provision the bundled Rosetta plugin on the primary. Provisioning is idempotent — + # run_kb_init_agent already provisioned the primary before KB init so its agent could + # discover the Rosetta skills. self.provision_rosetta_plugin(primary_workspace) - rosetta_dir = self.settings.ROSETTA_OUTPUT_DIR - self.unpack_rosetta_artifacts(primary_workspace, rosetta_dir) # Prepare each extra workspace for i, extra_ws in enumerate(extra_workspaces, start=1): @@ -299,24 +291,23 @@ def prepare_parallel_workspaces( # Clear src directory to avoid copying partial work self.clear_src_directory(extra_ws, src_dir=src_dir) - # Sync directories from primary to extra workspace. rosetta/ carries the KB in MCP - # mode; in plugin mode the agent wrote docs to outputs_dir and CLAUDE.md to the root - # directly, so both must propagate too (sync_directories handles the CLAUDE.md file - # and skips it harmlessly when KB init was disabled). + # Sync directories from primary to extra workspace. The KB init agent wrote docs to + # outputs_dir and CLAUDE.md to the root directly, so both must propagate + # (sync_directories handles the CLAUDE.md file and skips it harmlessly when KB init + # was disabled). self.sync_directories( source_ws=primary_workspace, target_ws=extra_ws, - directories=[spec_path, outputs_dir, rosetta_dir, _ROSETTA_ROOT_CLAUDE_MD], + directories=[spec_path, outputs_dir, _ROSETTA_ROOT_CLAUDE_MD], ) # Copy standards files if standards_source: self.copy_standards_to_workspace(extra_ws, standards_source) - # Provision the bundled Rosetta plugin and unpack KB artifacts on each extra - # workspace so parallel codegen agents discover the same Rosetta toolset. + # Provision the bundled Rosetta plugin on each extra workspace so parallel codegen + # agents discover the same Rosetta toolset. self.provision_rosetta_plugin(extra_ws) - self.unpack_rosetta_artifacts(extra_ws, rosetta_dir) ensure_workspace_gitignore(Path(extra_ws.get_isolated_root())) @@ -681,85 +672,12 @@ async def commit_and_push_outstanding( f"Pushed to origin/{WORKSPACE_DEFAULT_BRANCH} for {ws_path}" ) - def unpack_rosetta_artifacts( - self, - workspace: WorkspaceSettings, - rosetta_dir: str = "rosetta", - ) -> None: - """Unpack rosetta/ output into workspace root for Claude Code SDK discovery. - - Copies contents of rosetta/ into the workspace root, preserving structure: - rosetta/CLAUDE.md -> workspace_root/CLAUDE.md - rosetta/agents/ -> workspace_root/.claude/agents/ - rosetta/skills/ -> workspace_root/.claude/skills/ - rosetta/commands/ -> workspace_root/.claude/commands/ - rosetta/docs/ -> workspace_root/docs/ - - The agents/, skills/, and commands/ remappings exist because the SDK's - sensitive-file guard blocks agent writes under `.claude/` — so the KB init - agent stages those trees under rosetta/ and we remap here during unpack. - - No-op if rosetta/ doesn't exist (KB init was skipped or failed). - - Unpack is an **MCP-mode** operation: the KB-init agent stages everything under rosetta/ - because it cannot write `.claude/` directly. In plugin mode the plugin trees are copied - into `.claude/` by ``provision_rosetta_plugin`` and the agent writes its docs to their - final locations directly, so there is nothing to unpack — this returns early. - """ - if self._plugin_mode_active(): - self.logger.debug( - "Plugin mode active — skipping rosetta/ unpack " - f"({workspace.get_isolated_root()}); plugin provisions .claude/ directly" - ) - return - - rosetta_path = Path(workspace.resolve_path_in_workspace(rosetta_dir)) - if not rosetta_path.exists(): - self.logger.debug( - f"No {rosetta_dir}/ directory in " - f"{workspace.get_isolated_root()}, skipping unpack" - ) - return - - ws_root = Path(workspace.get_isolated_root()) - - for item in rosetta_path.iterdir(): - if item.is_dir() and item.name in _ROSETTA_DOT_CLAUDE_SUBDIRS: - target = ws_root / ".claude" / item.name - else: - target = ws_root / item.name - if item.is_dir(): - # Merge into existing directory (dirs_exist_ok) rather than replacing. - # Planning files written before this call (e.g. IMPLEMENTATION_PLAN.md, - # e2e-test-plan.md in docs/) are preserved; rosetta files are added/updated - # alongside them. Replacing via rmtree would wipe planning outputs when - # rosetta/docs/ and the outputs_dir share the same name ("docs"). - target.mkdir(parents=True, exist_ok=True) - shutil.copytree(item, target, dirs_exist_ok=True) - else: - shutil.copy2(item, target) - - self.logger.info(f"Unpacked rosetta/ artifacts to {ws_root}") - - def _plugin_mode_active(self) -> bool: - """True when the provisioned Rosetta plugin (not the live MCP) supplies the KB. - - Delegates to the single mode resolver in ``app.core.rosetta_kb`` so this and every other - site keyed on the mode agree. In provisioned-plugin mode ``provision_rosetta_plugin`` - copies the plugin trees into ``.claude/`` and the KB-init agent writes its docs to final - locations directly, so the MCP-mode ``unpack_rosetta_artifacts`` staging remap must not - run. With KB DISABLED this is False — unpack then runs but no-ops because rosetta/ was - never created. - """ - return resolve_rosetta_kb_mode(self.settings) is RosettaKbMode.PROVISIONED_PLUGIN - def provision_rosetta_plugin(self, workspace: WorkspaceSettings) -> bool: """Copy the bundled Rosetta plugin's discovery trees into a workspace. - Provisioned-plugin mode (the default) ships the Rosetta agents/skills/commands/hooks with - the image at ``settings.ROSETTA_PLUGIN_PATH`` instead of fetching them from the live - ims-mcp service. Only the discovery-shaped trees are copied into the workspace (project - scope cannot read ``/opt`` or ``~/.claude``): + The Rosetta agents/skills/commands/hooks ship with the image at + ``settings.ROSETTA_PLUGIN_PATH``. Only the discovery-shaped trees are copied into the + workspace (project scope cannot read ``/opt`` or ``~/.claude``): /agents/ -> .claude/agents/ /skills/ -> .claude/skills/ @@ -772,13 +690,12 @@ def provision_rosetta_plugin(self, workspace: WorkspaceSettings) -> bool: commands, so they run as CLI subprocesses without needing an in-workspace plugin copy. Idempotent: re-copy merges (``dirs_exist_ok``) and hook merge de-duplicates. Returns - False (no-op) when the mode resolver reports anything other than PROVISIONED_PLUGIN — i.e. - the live MCP is enabled (it supplies the KB instead), or ``ROSETTA_PLUGIN_PATH`` is unset - or missing on disk (e.g. an image without the plugin). + False (no-op) when ``ROSETTA_PLUGIN_PATH`` is unset or missing on disk (e.g. an image + without the plugin). """ plugin_root = rosetta_plugin_root(self.settings) if plugin_root is None: - if not self.settings.ROSETTA_MCP_ENABLED and (self.settings.ROSETTA_PLUGIN_PATH or "").strip(): + if (self.settings.ROSETTA_PLUGIN_PATH or "").strip(): # Configured to use the plugin but the path isn't a directory — surface it. self.logger.warning( "ROSETTA_PLUGIN_PATH not found, skipping plugin provisioning: " diff --git a/backend/app/standards/feature_implementation_standards.md b/backend/app/standards/feature_implementation_standards.md index 43905d9..100fd57 100644 --- a/backend/app/standards/feature_implementation_standards.md +++ b/backend/app/standards/feature_implementation_standards.md @@ -10,7 +10,7 @@ validation rules, size limits, and access control is not a complete feature desc topic heading. Flag every such underspecified feature as a GAP. For implementation patterns and detailed sub-task checklists for specific feature types, use the -**Rosetta KnowledgeBase MCP** (`get_context_instructions` / `query_instructions` / `list_instructions`). +**Rosetta knowledge-base toolset** provisioned into this workspace's `.claude/` (its Skills and subagents). --- diff --git a/backend/app/standards/tech_stacks.md b/backend/app/standards/tech_stacks.md index cf37cdc..b9ddd58 100644 --- a/backend/app/standards/tech_stacks.md +++ b/backend/app/standards/tech_stacks.md @@ -10,7 +10,8 @@ CRITICAL: if the specification does not specify a decision, flag it as a GAP. "M "team's choice" are not acceptable answers — every dimension below must have a concrete, named value. For detailed guidance on specific technologies, architecture patterns, or stack trade-offs, use the -**Rosetta KnowledgeBase MCP** (`get_context_instructions` / `query_instructions` / `list_instructions`) to retrieve up-to-date recommendations. +**Rosetta knowledge-base toolset** provisioned into this workspace's `.claude/` (its Skills and +subagents) to retrieve up-to-date recommendations. --- diff --git a/backend/app/workflows/generate_poc.py b/backend/app/workflows/generate_poc.py index f6e687d..f3b33da 100644 --- a/backend/app/workflows/generate_poc.py +++ b/backend/app/workflows/generate_poc.py @@ -34,7 +34,7 @@ async def generate_app_workflow( canonical paths, then convert markdown to JSON. Hard-fails the run with ContractRejection if required files are missing or unparseable. 2. kb_init — Rosetta KB initialization on the primary workspace, then sync specs, - outputs (including rosetta/), and src to all allocated workspaces. + outputs, and src to all allocated workspaces. 3. generation — parallel codegen across all allocated workspaces. 4. deploy_and_e2e — only when integration_readiness == INTEGRATION_TESTS_READY. 5. archive_outputs — Steel Commandment XI: persist before P10Y. diff --git a/backend/test/services/test_workflow_steps.py b/backend/test/services/test_workflow_steps.py index 9dbc484..d0e271b 100644 --- a/backend/test/services/test_workflow_steps.py +++ b/backend/test/services/test_workflow_steps.py @@ -6,7 +6,6 @@ import pytest -from app.core.config import ROSETTA_SERVER_KEY from app.schemas.specification import SpecReadiness @@ -365,9 +364,9 @@ async def run_parallel(workspaces, agent_fn): # --------------------------------------------------------------------------- class TestPlanningMcpServersAndTools: - """Tests for planning_mcp_servers_and_tools(): Rosetta + optional Figma.""" + """Tests for planning_mcp_servers_and_tools(): optional Figma only.""" - def _ctx(self, *, enabled_mcps=frozenset(), rosetta_enabled=False, figma_token=None): + def _ctx(self, *, enabled_mcps=frozenset(), figma_token=None): from unittest.mock import Mock from app.services.workflow_steps import WorkflowContext @@ -375,13 +374,6 @@ def _ctx(self, *, enabled_mcps=frozenset(), rosetta_enabled=False, figma_token=N ctx.enabled_mcps = frozenset(enabled_mcps) s = Mock() - s.ROSETTA_MCP_ENABLED = rosetta_enabled - s.ROSETTA_MCP_COMMAND = "uvx" - s.ROSETTA_MCP_ARGS = "ims-mcp@latest" - s.ROSETTA_SERVER_URL = None - s.ROSETTA_USER_EMAIL = None - s.ROSETTA_IMS_VERSION = "" - s.ROSETTA_API_KEY = None s.FIGMA_MCP_COMMAND = "npx" s.FIGMA_MCP_ARGS = "-y figma-developer-mcp --stdio" s.FIGMA_ACCESS_TOKEN = figma_token @@ -397,15 +389,6 @@ def test_nothing_enabled_returns_empty(self): assert servers == {} assert tools == [] - def test_rosetta_excluded_from_planning(self): - from app.core.mcp_config import planning_mcp_servers_and_tools as _planning_mcp_servers_and_tools - - ctx = self._ctx(rosetta_enabled=True) - servers, tools = _planning_mcp_servers_and_tools(ctx.settings, ctx.enabled_mcps) - assert ROSETTA_SERVER_KEY not in servers - assert servers == {} - assert tools == [] - def test_figma_only_with_token(self): from app.core.mcp_config import planning_mcp_servers_and_tools as _planning_mcp_servers_and_tools from app.core.tool_usage import get_figma_mcp_tools @@ -413,17 +396,6 @@ def test_figma_only_with_token(self): ctx = self._ctx(enabled_mcps={"figma"}, figma_token="secret") servers, tools = _planning_mcp_servers_and_tools(ctx.settings, ctx.enabled_mcps) assert "Figma" in servers - assert ROSETTA_SERVER_KEY not in servers - assert tools == get_figma_mcp_tools() - - def test_rosetta_excluded_figma_present_when_rosetta_and_figma_both_enabled(self): - from app.core.mcp_config import planning_mcp_servers_and_tools as _planning_mcp_servers_and_tools - from app.core.tool_usage import get_figma_mcp_tools - - ctx = self._ctx(enabled_mcps={"figma"}, rosetta_enabled=True, figma_token="secret") - servers, tools = _planning_mcp_servers_and_tools(ctx.settings, ctx.enabled_mcps) - assert ROSETTA_SERVER_KEY not in servers - assert "Figma" in servers assert tools == get_figma_mcp_tools() def test_figma_requested_but_token_missing_skips_figma(self): diff --git a/backend/test/test_agents_claude_code.py b/backend/test/test_agents_claude_code.py index 21a83f8..1f32b2e 100644 --- a/backend/test/test_agents_claude_code.py +++ b/backend/test/test_agents_claude_code.py @@ -10,11 +10,10 @@ MCP_FIGMA, MCP_FIGMA_SERVER_KEY, MCP_PLAYWRIGHT, - ROSETTA_SERVER_KEY, Settings, ) from app.core.mcp_config import coding_mcp_servers_and_tools -from app.core.tool_usage import get_figma_mcp_tools, get_playwright_mcp_tools, get_rosetta_kb_tools +from app.core.tool_usage import get_figma_mcp_tools, get_playwright_mcp_tools from app.prompts.agents_claude_code import is_agent_complete from app.schemas.agent import AgentResult @@ -257,7 +256,7 @@ async def test_raises_when_result_and_file_both_missing(self, tmp_path): class TestCodingMcpServersAndTools: - """Tests for coding_mcp_servers_and_tools() — Playwright + Figma + optional Rosetta.""" + """Tests for coding_mcp_servers_and_tools() — Playwright + Figma (Rosetta is a plugin).""" def _settings(self, *, pw_cmd="npx", figma_token="tok"): s = Mock(spec=Settings) @@ -267,7 +266,6 @@ def _settings(self, *, pw_cmd="npx", figma_token="tok"): s.FIGMA_MCP_ARGS = "-y figma-developer-mcp --stdio" s.FIGMA_ACCESS_TOKEN = figma_token s.FIGMA_API_KEY = None - s.ROSETTA_MCP_ENABLED = False return s def test_neither_enabled_returns_empty(self): @@ -309,21 +307,6 @@ def test_figma_token_missing_skips_figma(self): assert servers == {} assert tools == [] - def test_rosetta_enabled_merges_with_playwright(self): - s = self._settings() - s.ROSETTA_MCP_ENABLED = True - s.ROSETTA_MCP_COMMAND = "uvx" - s.ROSETTA_MCP_ARGS = "ims-mcp@latest" - s.ROSETTA_SERVER_URL = None - s.ROSETTA_USER_EMAIL = None - s.ROSETTA_IMS_VERSION = "" - s.ROSETTA_API_KEY = None - - servers, tools = coding_mcp_servers_and_tools(s, frozenset({MCP_PLAYWRIGHT})) - assert ROSETTA_SERVER_KEY in servers - assert MCP_PLAYWRIGHT in servers - assert set(tools) == set(get_rosetta_kb_tools() + get_playwright_mcp_tools()) - if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/backend/test/test_claude_code_setup.py b/backend/test/test_claude_code_setup.py index df68f2d..a85674f 100644 --- a/backend/test/test_claude_code_setup.py +++ b/backend/test/test_claude_code_setup.py @@ -22,27 +22,17 @@ # setup_rosetta_plugin_env # --------------------------------------------------------------------------- -def test_rosetta_plugin_env_points_at_plugin_path_in_plugin_mode(tmp_path: Path) -> None: - """Plugin mode (MCP off) + existing ROSETTA_PLUGIN_PATH -> CLAUDE_PLUGIN_ROOT = that path.""" - with patch.object(cc.settings, "ROSETTA_MCP_ENABLED", False), \ - patch.object(cc.settings, "ROSETTA_PLUGIN_PATH", str(tmp_path)): +def test_rosetta_plugin_env_points_at_plugin_path(tmp_path: Path) -> None: + """Existing ROSETTA_PLUGIN_PATH -> CLAUDE_PLUGIN_ROOT = that path.""" + with patch.object(cc.settings, "ROSETTA_PLUGIN_PATH", str(tmp_path)): assert setup_rosetta_plugin_env() == {"CLAUDE_PLUGIN_ROOT": str(tmp_path)} -def test_rosetta_plugin_env_empty_when_mcp_enabled(tmp_path: Path) -> None: - """Live MCP mode -> no CLAUDE_PLUGIN_ROOT (the plugin is not used).""" - with patch.object(cc.settings, "ROSETTA_MCP_ENABLED", True), \ - patch.object(cc.settings, "ROSETTA_PLUGIN_PATH", str(tmp_path)): - assert setup_rosetta_plugin_env() == {} - - def test_rosetta_plugin_env_empty_when_path_unset_or_missing(tmp_path: Path) -> None: - """Plugin mode but path unset / missing on disk -> no env var set.""" - with patch.object(cc.settings, "ROSETTA_MCP_ENABLED", False), \ - patch.object(cc.settings, "ROSETTA_PLUGIN_PATH", None): + """Path unset / missing on disk -> no env var set.""" + with patch.object(cc.settings, "ROSETTA_PLUGIN_PATH", None): assert setup_rosetta_plugin_env() == {} - with patch.object(cc.settings, "ROSETTA_MCP_ENABLED", False), \ - patch.object(cc.settings, "ROSETTA_PLUGIN_PATH", str(tmp_path / "does-not-exist")): + with patch.object(cc.settings, "ROSETTA_PLUGIN_PATH", str(tmp_path / "does-not-exist")): assert setup_rosetta_plugin_env() == {} @@ -53,8 +43,7 @@ def test_rosetta_plugin_env_reevaluates_disk_state_each_call(tmp_path: Path) -> desync CLAUDE_PLUGIN_ROOT from a lazily-mounted (or removed) plugin dir. """ plugin = tmp_path / "plugin-probe" - with patch.object(cc.settings, "ROSETTA_MCP_ENABLED", False), \ - patch.object(cc.settings, "ROSETTA_PLUGIN_PATH", str(plugin)): + with patch.object(cc.settings, "ROSETTA_PLUGIN_PATH", str(plugin)): # Not yet on disk -> no env var. assert setup_rosetta_plugin_env() == {} # Appears later (e.g. lazy mount) -> picked up without any cache clear. @@ -459,7 +448,6 @@ def test_settings_backed_secrets_are_redacted(self): "P10Y_API_KEY", "FIGMA_API_KEY", "FIGMA_ACCESS_TOKEN", - "ROSETTA_API_KEY", "POSTHOG_API_KEY", "GITHUB_TOKEN_DEFAULT", "TOKEN_ENCRYPTION_KEY", diff --git a/backend/test/test_execute_all_phases_mcps.py b/backend/test/test_execute_all_phases_mcps.py index efc0d73..4c19cf6 100644 --- a/backend/test/test_execute_all_phases_mcps.py +++ b/backend/test/test_execute_all_phases_mcps.py @@ -10,7 +10,6 @@ import pytest -from app.core.config import ROSETTA_SERVER_KEY from app.schemas.agent import AgentResult from app.schemas.planning import PhaseInfo, PlanningResult from app.schemas.specification import GenerateAppRequest, SpecReadiness @@ -25,7 +24,7 @@ def _mock_workspace(tmp_path: Path) -> Mock: return ws -def _mock_manager(*, rosetta_enabled: bool = False) -> Mock: +def _mock_manager() -> Mock: mgr = Mock() mgr.settings = Mock() mgr.settings.PLAYWRIGHT_MCP_COMMAND = "npx" @@ -33,7 +32,6 @@ def _mock_manager(*, rosetta_enabled: bool = False) -> Mock: mgr.settings.FIGMA_MCP_COMMAND = "npx" mgr.settings.FIGMA_MCP_ARGS = "-y figma-developer-mcp --stdio" mgr.settings.FIGMA_ACCESS_TOKEN = "tok" - mgr.settings.ROSETTA_MCP_ENABLED = rosetta_enabled mgr.commit_and_push_outstanding = AsyncMock(return_value=True) return mgr @@ -245,21 +243,15 @@ async def fake_phase_fn(**kwargs): @pytest.mark.asyncio -async def test_execute_all_phases_rosetta_present_when_enabled(tmp_path: Path) -> None: - """ROSETTA_MCP_ENABLED=True — Rosetta server key appears in phase_mcp_servers for every phase.""" +async def test_execute_all_phases_rosetta_not_attached_as_mcp(tmp_path: Path) -> None: + """The Rosetta KB is a provisioned plugin, not an MCP — it never appears in phase_mcp_servers.""" from app.services.claude_code import execute_all_phases planning = _planning_two_phases(p1_mcp=None, p2_mcp=None) enabled = frozenset({"playwright"}) mock_ws = _mock_workspace(tmp_path) - mock_mgr = _mock_manager(rosetta_enabled=True) - mock_mgr.settings.ROSETTA_MCP_COMMAND = "uvx" - mock_mgr.settings.ROSETTA_MCP_ARGS = "ims-mcp@latest" - mock_mgr.settings.ROSETTA_SERVER_URL = None - mock_mgr.settings.ROSETTA_USER_EMAIL = None - mock_mgr.settings.ROSETTA_IMS_VERSION = "" - mock_mgr.settings.ROSETTA_API_KEY = None + mock_mgr = _mock_manager() request = GenerateAppRequest(spec_path="specs", outputs_dir="specflow", generation_id="e1") @@ -282,6 +274,6 @@ async def fake_phase_fn(**kwargs): assert len(captured_servers) == 2 for phase_servers in captured_servers: - assert ROSETTA_SERVER_KEY in phase_servers, ( - f"Rosetta server key '{ROSETTA_SERVER_KEY}' missing from phase MCP servers: {list(phase_servers)}" - ) + assert "KnowledgeBase" not in phase_servers + # Only the user-selectable Playwright MCP is wired here. + assert set(phase_servers) <= {"playwright"} diff --git a/backend/test/test_kb_init_prompt.py b/backend/test/test_kb_init_prompt.py index 6591efc..0ea7654 100644 --- a/backend/test/test_kb_init_prompt.py +++ b/backend/test/test_kb_init_prompt.py @@ -1,83 +1,49 @@ -"""Unit tests for kb_init_agent_template prompt.""" +"""Unit tests for kb_init_agent_template prompt (provisioned Rosetta plugin).""" -from app.core.config import LLM_MEDIUM_DEFAULT_FIRST_MODEL, ROSETTA_SERVER_KEY +from app.core.config import LLM_MEDIUM_DEFAULT_FIRST_MODEL from app.prompts.knowledge_base_agent import kb_init_agent_template class TestKbInitAgentTemplate: - """Tests for kb_init_agent_template().""" + """Tests for the direct plugin workflow.""" - def test_contains_rosetta_output_path(self) -> None: - prompt = kb_init_agent_template(rosetta_output_dir="rosetta", model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) - assert "rosetta/" in prompt - assert "rosetta/CLAUDE.md" in prompt + def test_writes_to_final_locations(self) -> None: + prompt = kb_init_agent_template(model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) + assert "CLAUDE.md` at the workspace root" in prompt + assert "docs/CONTEXT.md" in prompt - def test_custom_output_dir(self) -> None: - prompt = kb_init_agent_template(rosetta_output_dir="my_kb", model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) - assert "my_kb/" in prompt - assert "my_kb/CLAUDE.md" in prompt - assert "my_kb/agents/" in prompt + def test_honors_custom_outputs_dir(self) -> None: + prompt = kb_init_agent_template( + model=LLM_MEDIUM_DEFAULT_FIRST_MODEL, + outputs_dir="specflow", + ) + assert "specflow/CONTEXT.md" in prompt def test_skip_implementation_plan_instruction(self) -> None: - prompt = kb_init_agent_template(rosetta_output_dir="rosetta", model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) + prompt = kb_init_agent_template(model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) assert "SKIP implementation plan" in prompt - def test_mcp_tool_references(self) -> None: - prompt = kb_init_agent_template(rosetta_output_dir="rosetta", model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) - assert f"mcp__{ROSETTA_SERVER_KEY}__query_instructions" in prompt - assert "init-workspace-flow.md" in prompt + def test_plugin_entry_points_are_explicit(self) -> None: + prompt = kb_init_agent_template(model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) + assert "## Rosetta Plugin Usage" in prompt + assert "init-workspace-flow" in prompt + assert "`Skill` tool" in prompt + assert "`SlashCommand`" in prompt - def test_uses_claude_paths_not_cursor(self) -> None: - prompt = kb_init_agent_template(rosetta_output_dir="rosetta", model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) - assert "rosetta/agents/" in prompt - assert "rosetta/skills/" in prompt - assert "rosetta/commands/" in prompt - assert ".claude/agents/" in prompt # mentioned as the final destination after sync - assert ".claude/skills/" in prompt - assert ".claude/commands/" in prompt + def test_leaves_dot_claude_to_the_plugin(self) -> None: + prompt = kb_init_agent_template(model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) + assert "Leave the provisioned `.claude/` content unchanged." in prompt assert "Do NOT create `.cursor/` directories" in prompt + def test_skips_shells_phase(self) -> None: + prompt = kb_init_agent_template(model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) + assert "shells" in prompt.lower() + def test_no_human_in_the_loop(self) -> None: - prompt = kb_init_agent_template(rosetta_output_dir="rosetta", model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) + prompt = kb_init_agent_template(model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) assert "No human in the loop" in prompt def test_scope_constraints(self) -> None: - prompt = kb_init_agent_template(rosetta_output_dir="rosetta", model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) + prompt = kb_init_agent_template(model=LLM_MEDIUM_DEFAULT_FIRST_MODEL) assert "Do NOT start coding" in prompt - assert "Do NOT modify existing files" in prompt - - # --- Plugin mode: docs go to FINAL locations, no rosetta/ staging, no .claude/ writes --- - - def test_plugin_mode_writes_to_final_locations_not_rosetta(self) -> None: - prompt = kb_init_agent_template( - rosetta_output_dir="rosetta", - model=LLM_MEDIUM_DEFAULT_FIRST_MODEL, - use_plugin=True, - outputs_dir="docs", - ) - # Final locations, not rosetta/ staging. - assert "CLAUDE.md` at the workspace root" in prompt - assert "docs/CONTEXT.md" in prompt - assert "do not stage under `rosetta/`" in prompt.lower() - # No MCP, and the agent must not touch .claude/ (the plugin owns it). - assert "mcp__" not in prompt - assert "write under `.claude/`" in prompt.lower() - - def test_plugin_mode_honors_custom_outputs_dir(self) -> None: - prompt = kb_init_agent_template( - rosetta_output_dir="rosetta", - model=LLM_MEDIUM_DEFAULT_FIRST_MODEL, - use_plugin=True, - outputs_dir="specflow", - ) - assert "specflow/CONTEXT.md" in prompt - - def test_plugin_mode_skips_shells_phase(self) -> None: - prompt = kb_init_agent_template( - rosetta_output_dir="rosetta", - model=LLM_MEDIUM_DEFAULT_FIRST_MODEL, - use_plugin=True, - ) - assert "shells" in prompt.lower() - assert "## Rosetta Plugin Usage" in prompt diff --git a/backend/test/test_kb_init_telemetry.py b/backend/test/test_kb_init_telemetry.py index 89425f3..a98b3f9 100644 --- a/backend/test/test_kb_init_telemetry.py +++ b/backend/test/test_kb_init_telemetry.py @@ -27,7 +27,7 @@ def test_emits_success_event(self, mock_ctx: Mock) -> None: generation_id="est-1", workspace_name="ws1", status="success", - generated_files=["rosetta/CLAUDE.md", "rosetta/docs/CONTEXT.md"], + generated_files=["CLAUDE.md", "docs/CONTEXT.md"], duration_seconds=45.3, ) @@ -41,7 +41,7 @@ def test_emits_success_event(self, mock_ctx: Mock) -> None: assert props["generation_id"] == "est-1" assert props["generated_files_count"] == 2 assert props["duration_seconds"] == 45.3 - assert "rosetta/CLAUDE.md" in props["generated_files"] + assert "CLAUDE.md" in props["generated_files"] assert "error_message" not in props @patch("app.services.telemetry.TelemetryContext") @@ -95,5 +95,5 @@ def test_noop_when_disabled(self) -> None: generation_id="est-4", workspace_name="ws4", status="success", - generated_files=["rosetta/CLAUDE.md"], + generated_files=["CLAUDE.md"], ) diff --git a/backend/test/test_mcp_config.py b/backend/test/test_mcp_config.py index 07b6aee..557959b 100644 --- a/backend/test/test_mcp_config.py +++ b/backend/test/test_mcp_config.py @@ -3,11 +3,10 @@ import pytest from unittest.mock import Mock -from app.core.config import ROSETTA_SERVER_KEY, Settings +from app.core.config import Settings from app.core.mcp_config import ( build_figma_mcp_config, build_playwright_mcp_config, - build_rosetta_mcp_config, enabled_mcps_to_parameter_string, merge_mcp_server_dicts, mcp_resolution_from_workspace_sync_parameters, @@ -17,72 +16,6 @@ ) -class TestBuildRosettaMcpConfig: - """Tests for build_rosetta_mcp_config() — parity with `uvx ims-mcp@latest` + ROSETTA_* / VERSION env.""" - - @pytest.fixture - def mock_settings(self) -> Mock: - settings = Mock(spec=Settings) - settings.ROSETTA_MCP_ENABLED = True - settings.ROSETTA_MCP_COMMAND = "uvx" - settings.ROSETTA_MCP_ARGS = "ims-mcp@latest" - settings.ROSETTA_SERVER_URL = "https://ims.example.com/" - settings.ROSETTA_API_KEY = "key" - settings.ROSETTA_USER_EMAIL = "user@example.com" - settings.ROSETTA_IMS_VERSION = "r2" - return settings - - def test_returns_empty_when_disabled(self, mock_settings: Mock) -> None: - """Scenario: feature flag off -> returns empty dict, KB init is skipped.""" - mock_settings.ROSETTA_MCP_ENABLED = False - result = build_rosetta_mcp_config(mock_settings) - assert result == {} - - def test_returns_full_config_when_enabled(self, mock_settings: Mock) -> None: - """Scenario: ims-mcp env surface set -> MCP stdio config returned.""" - result = build_rosetta_mcp_config(mock_settings) - - assert ROSETTA_SERVER_KEY in result - kb = result[ROSETTA_SERVER_KEY] - assert kb["command"] == "uvx" - assert kb["args"] == ["ims-mcp@latest"] - assert kb["env"]["ROSETTA_SERVER_URL"] == "https://ims.example.com/" - assert kb["env"]["ROSETTA_API_KEY"] == "key" - assert kb["env"]["ROSETTA_USER_EMAIL"] == "user@example.com" - assert kb["env"]["VERSION"] == "r2" - - def test_strips_secrets(self, mock_settings: Mock) -> None: - """Scenario: ROSETTA_API_KEY has surrounding whitespace -> stripped in env.""" - mock_settings.ROSETTA_API_KEY = " rk-secret " - result = build_rosetta_mcp_config(mock_settings) - assert result[ROSETTA_SERVER_KEY]["env"]["ROSETTA_API_KEY"] == "rk-secret" - - def test_handles_partial_env_vars(self, mock_settings: Mock) -> None: - """Scenario: only server URL set -> env has only that key (+ VERSION if set).""" - mock_settings.ROSETTA_SERVER_URL = "https://only-url/" - mock_settings.ROSETTA_API_KEY = None - mock_settings.ROSETTA_USER_EMAIL = None - mock_settings.ROSETTA_IMS_VERSION = "" - - result = build_rosetta_mcp_config(mock_settings) - - env = result[ROSETTA_SERVER_KEY]["env"] - assert env == {"ROSETTA_SERVER_URL": "https://only-url/"} - - def test_all_optional_cleared(self, mock_settings: Mock) -> None: - """Scenario: all optional strings empty -> env dict is empty; command still ims-mcp@latest.""" - mock_settings.ROSETTA_SERVER_URL = None - mock_settings.ROSETTA_API_KEY = None - mock_settings.ROSETTA_USER_EMAIL = None - mock_settings.ROSETTA_IMS_VERSION = "" - - result = build_rosetta_mcp_config(mock_settings) - - assert result[ROSETTA_SERVER_KEY]["env"] == {} - assert result[ROSETTA_SERVER_KEY]["command"] == "uvx" - assert result[ROSETTA_SERVER_KEY]["args"] == ["ims-mcp@latest"] - - class TestParseMcpServersEnabled: def test_filters_unknown_names(self) -> None: assert parse_mcp_servers_enabled_string("playwright,figma,unknown") == frozenset( @@ -276,36 +209,6 @@ def test_enabled_mcps_to_parameter_string() -> None: # Blank-config no-op regression tests (FR-10 / S4.1 insurance) # --------------------------------------------------------------------------- -class TestRosettaBlankConfigNoOp: - """ROSETTA_MCP_ENABLED=False with blank credentials → empty dict, no error.""" - - def test_disabled_flag_returns_empty_no_error(self) -> None: - """Scenario: ROSETTA_MCP_ENABLED=False → build_rosetta_mcp_config returns {} without raising.""" - m = Mock(spec=Settings) - m.ROSETTA_MCP_ENABLED = False - m.ROSETTA_MCP_COMMAND = "uvx" - m.ROSETTA_MCP_ARGS = "ims-mcp@latest" - m.ROSETTA_SERVER_URL = None - m.ROSETTA_API_KEY = None - m.ROSETTA_USER_EMAIL = None - m.ROSETTA_IMS_VERSION = "" - result = build_rosetta_mcp_config(m) - assert result == {} - - def test_disabled_flag_with_blank_creds_returns_empty(self) -> None: - """Scenario: flag off AND all creds blank → still returns {}, no KeyError or AttributeError.""" - m = Mock(spec=Settings) - m.ROSETTA_MCP_ENABLED = False - m.ROSETTA_MCP_COMMAND = "" - m.ROSETTA_MCP_ARGS = "" - m.ROSETTA_SERVER_URL = "" - m.ROSETTA_API_KEY = "" - m.ROSETTA_USER_EMAIL = "" - m.ROSETTA_IMS_VERSION = "" - result = build_rosetta_mcp_config(m) - assert result == {} - - class TestFigmaBlankConfigNoOp: """Blank FIGMA_ACCESS_TOKEN and FIGMA_API_KEY → empty dict, no error (FR-10).""" diff --git a/backend/test/test_rosetta_kb.py b/backend/test/test_rosetta_kb.py index cc9c514..464f111 100644 --- a/backend/test/test_rosetta_kb.py +++ b/backend/test/test_rosetta_kb.py @@ -1,48 +1,29 @@ -"""Tests for app.core.rosetta_kb — the single source of truth for the Rosetta KB mode.""" +"""Tests for Rosetta plugin-path validation.""" from pathlib import Path from unittest.mock import Mock from app.core.config import Settings -from app.core.rosetta_kb import ( - RosettaKbMode, - resolve_rosetta_kb_mode, - rosetta_plugin_root, -) +from app.core.rosetta_kb import rosetta_plugin_root -def _settings(mcp_enabled: bool, plugin_path) -> Mock: +def _settings(plugin_path) -> Mock: s = Mock(spec=Settings) - s.ROSETTA_MCP_ENABLED = mcp_enabled s.ROSETTA_PLUGIN_PATH = plugin_path return s -def test_live_mcp_wins_over_plugin(tmp_path: Path) -> None: - """ROSETTA_MCP_ENABLED true -> LIVE_MCP even if a plugin dir also exists on disk.""" - s = _settings(True, str(tmp_path)) - assert resolve_rosetta_kb_mode(s) is RosettaKbMode.LIVE_MCP - # The plugin root helper hides the path in MCP mode so the env var is never set. - assert rosetta_plugin_root(s) is None - - def test_provisioned_plugin_when_path_exists(tmp_path: Path) -> None: - s = _settings(False, str(tmp_path)) - assert resolve_rosetta_kb_mode(s) is RosettaKbMode.PROVISIONED_PLUGIN + s = _settings(str(tmp_path)) assert rosetta_plugin_root(s) == str(tmp_path) -def test_configured_but_missing_path_is_disabled_not_plugin(tmp_path: Path) -> None: - """A set-but-nonexistent path must NOT count as provisioned-plugin mode. - - This is the drift the central resolver fixes: the decision and the actions keyed on it - (provisioning, CLAUDE_PLUGIN_ROOT, unpack-skip, prompt shape) all require the path on disk. - """ - s = _settings(False, str(tmp_path / "missing")) - assert resolve_rosetta_kb_mode(s) is RosettaKbMode.DISABLED +def test_configured_but_missing_path_is_unavailable(tmp_path: Path) -> None: + """A configured path must point to an existing directory.""" + s = _settings(str(tmp_path / "missing")) assert rosetta_plugin_root(s) is None -def test_whitespace_and_none_paths_are_disabled() -> None: - assert resolve_rosetta_kb_mode(_settings(False, None)) is RosettaKbMode.DISABLED - assert resolve_rosetta_kb_mode(_settings(False, " ")) is RosettaKbMode.DISABLED +def test_whitespace_and_none_paths_are_unavailable() -> None: + assert rosetta_plugin_root(_settings(None)) is None + assert rosetta_plugin_root(_settings(" ")) is None diff --git a/backend/test/test_tool_usage.py b/backend/test/test_tool_usage.py index 3cf0f8e..8ddbc2b 100644 --- a/backend/test/test_tool_usage.py +++ b/backend/test/test_tool_usage.py @@ -6,7 +6,7 @@ ANDROID_SDK_BASH_USAGE, bash_usage, get_disallowed_tools, - get_rosetta_allowed_tools, + get_rosetta_plugin_tools, get_workspace_rm_bash_allowlist, GH_CLI_USAGE, ) @@ -58,49 +58,9 @@ def test_workspace_rm_allowlist_scoped_to_path(self): assert "Bash(rm:*)" not in tools -class TestGetRosettaAllowedTools: - """Tests for get_rosetta_allowed_tools function.""" - - def test_generates_all_operations(self): - """Scenario: Generates Read, Write, Edit, StrReplace, Glob operations for rosetta/ directory.""" - workspace_path = "/workspaces/test-ws" - rosetta_dir = "rosetta" - rosetta_path = f"{workspace_path}/{rosetta_dir}" - - result = get_rosetta_allowed_tools(workspace_path, rosetta_dir) - - assert f"Read({rosetta_path}/**)" in result - assert f"Write({rosetta_path}/**)" in result - assert f"Edit({rosetta_path}/**)" in result - assert f"StrReplace({rosetta_path}/**)" in result - assert f"Glob({rosetta_path}/**)" in result - # No .claude/ entries — agent writes to rosetta/agents/ (no sensitive path) - assert not any(".claude" in t for t in result) - - def test_handles_different_workspace_paths(self): - """Scenario: Works with different workspace paths.""" - workspace_path = "/workspaces/another-workspace" - rosetta_dir = "kb_output" - rosetta_path = f"{workspace_path}/{rosetta_dir}" - - result = get_rosetta_allowed_tools(workspace_path, rosetta_dir) - - assert f"Read({rosetta_path}/**)" in result - assert f"Write({rosetta_path}/**)" in result - - def test_includes_write_operations_for_claude_directories(self): - """Scenario: KB init agent can write to rosetta/agents/ (remapped to .claude/agents/ on unpack).""" - workspace_path = "/workspaces/test" - rosetta_dir = "rosetta" - rosetta_path = f"{workspace_path}/{rosetta_dir}" - - result = get_rosetta_allowed_tools(workspace_path, rosetta_dir) - - # Recursive wildcard covers all nested paths including rosetta/agents/ - assert f"Write({rosetta_path}/**)" in result - assert f"StrReplace({rosetta_path}/**)" in result - # No .claude/ paths — agent stages to rosetta/agents/, unpack remaps to .claude/agents/ - assert not any(".claude" in t for t in result) +def test_rosetta_plugin_tools_drive_the_provisioned_workflow() -> None: + """KB init exposes both supported entry points from the provisioned plugin.""" + assert get_rosetta_plugin_tools() == ["Skill", "SlashCommand"] class TestBashUsage: diff --git a/backend/test/test_workflow_steps_kb_init.py b/backend/test/test_workflow_steps_kb_init.py index 9e687c8..7c41385 100644 --- a/backend/test/test_workflow_steps_kb_init.py +++ b/backend/test/test_workflow_steps_kb_init.py @@ -1,41 +1,28 @@ -"""Unit tests for run_kb_init_agent workflow step.""" +"""Unit tests for run_kb_init_agent workflow step (provisioned Rosetta plugin).""" import pytest from pathlib import Path from unittest.mock import AsyncMock, Mock, patch -from app.core.config import ROSETTA_SERVER_KEY, Settings +from app.core.config import Settings from app.schemas.llm_tier import WorkflowName from app.schemas.telemetry_workflow import TelemetryWorkflowLabel from app.schemas.workspace import WorkspaceSettings from app.services.workflow_steps import ( WorkflowContext, run_kb_init_agent, - _collect_rosetta_file_manifest, - _generate_mock_rosetta_artifacts, + _generate_mock_kb_init_artifacts, ) from app.services.workspace_manager import WorkspaceManager def _make_ctx( - rosetta_enabled: bool = False, workspace_root: str = "/workspaces/ws1", rosetta_plugin_path: str | None = None, outputs_dir: str = "docs", ) -> WorkflowContext: - """Build a minimal WorkflowContext for testing.""" + """Build a minimal WorkflowContext for testing plugin availability.""" settings = Mock(spec=Settings) - settings.ROSETTA_MCP_ENABLED = rosetta_enabled - settings.ROSETTA_MCP_COMMAND = "uvx" - settings.ROSETTA_MCP_ARGS = "ims-mcp@latest" - settings.ROSETTA_SERVER_URL = "https://ims.example.com/" - settings.ROSETTA_API_KEY = None - settings.ROSETTA_USER_EMAIL = "e@x.com" - settings.ROSETTA_IMS_VERSION = "r2" - settings.ROSETTA_OUTPUT_DIR = "rosetta" - # Default None so disabled-MCP means "skip" (not provisioned-plugin) unless a test opts in. - # When a test opts in it must point at a real directory: provisioned-plugin mode requires the - # path to exist on disk (resolve_rosetta_kb_mode), matching the production gate. settings.ROSETTA_PLUGIN_PATH = rosetta_plugin_path settings.STANDARDS_DIR_NAME = "standards" settings.LLM_HIGH = "anthropic/claude-opus-4.5" @@ -62,32 +49,6 @@ def _make_ctx( ) -class TestCollectRosettaFileManifest: - """Tests for _collect_rosetta_file_manifest helper.""" - - def test_returns_empty_when_dir_missing(self, tmp_path: Path) -> None: - result = _collect_rosetta_file_manifest(str(tmp_path), "rosetta") - assert result == [] - - def test_collects_files_recursively(self, tmp_path: Path) -> None: - rosetta = tmp_path / "rosetta" - rosetta.mkdir() - (rosetta / "CLAUDE.md").write_text("instructions") - agents = rosetta / "agents" - agents.mkdir(parents=True) - (agents / "backend.md").write_text("agent") - docs = rosetta / "docs" - docs.mkdir() - (docs / "CONTEXT.md").write_text("ctx") - - result = _collect_rosetta_file_manifest(str(tmp_path), "rosetta") - - assert "rosetta/CLAUDE.md" in result - assert "rosetta/agents/backend.md" in result - assert "rosetta/docs/CONTEXT.md" in result - assert result == sorted(result) - - class TestCollectProvisionedPluginManifest: """Tests for _collect_provisioned_plugin_manifest — reports only what KB init produced.""" @@ -129,16 +90,20 @@ class TestRunKbInitAgent: """Tests for run_kb_init_agent step function.""" @pytest.mark.asyncio + @patch("app.services.workflow_steps.agent_query", new_callable=AsyncMock) @patch("app.services.workflow_steps.telemetry") - async def test_skips_when_disabled(self, mock_telemetry: Mock) -> None: - """Scenario: ROSETTA_MCP_ENABLED=False -> logs skip, emits skipped event.""" - ctx = _make_ctx(rosetta_enabled=False) + async def test_skips_when_no_plugin_configured( + self, mock_telemetry: Mock, mock_agent_query: AsyncMock, + ) -> None: + """Scenario: no ROSETTA_PLUGIN_PATH on disk -> logs skip, emits skipped event, no agent.""" + ctx = _make_ctx(rosetta_plugin_path=None) await run_kb_init_agent(ctx) ctx.logger.info.assert_any_call( - "KB init skipped: Rosetta MCP disabled and no ROSETTA_PLUGIN_PATH configured" + "KB init skipped: ROSETTA_PLUGIN_PATH is not configured" ) + mock_agent_query.assert_not_called() mock_telemetry.capture_kb_init_event.assert_called_once_with( generation_id="est-123", workspace_name="ws1", @@ -147,35 +112,56 @@ async def test_skips_when_disabled(self, mock_telemetry: Mock) -> None: ) mock_telemetry.capture_kb_init_triggered.assert_not_called() + @pytest.mark.asyncio + @patch("app.services.workflow_steps.agent_query", new_callable=AsyncMock) + @patch("app.services.workflow_steps.telemetry") + async def test_skips_when_plugin_path_is_missing( + self, + mock_telemetry: Mock, + mock_agent_query: AsyncMock, + tmp_path: Path, + ) -> None: + """Scenario: configured plugin directory is missing -> skip with an accurate reason.""" + missing_path = tmp_path / "missing-plugin" + ctx = _make_ctx(rosetta_plugin_path=str(missing_path)) + + await run_kb_init_agent(ctx) + + ctx.logger.info.assert_any_call( + f"KB init skipped: ROSETTA_PLUGIN_PATH is not an existing directory: {missing_path}" + ) + mock_agent_query.assert_not_called() + mock_telemetry.capture_kb_init_event.assert_called_once_with( + generation_id="est-123", + workspace_name="ws1", + status="skipped", + generated_files=[], + ) + @pytest.mark.asyncio @patch("app.services.workflow_steps.TelemetryContext") @patch("app.services.workflow_steps.telemetry") @patch("app.services.workflow_steps.agent_query", new_callable=AsyncMock) @patch("app.services.workflow_steps.create_agent_logger") - @patch("app.services.workflow_steps._collect_rosetta_file_manifest") - async def test_plugin_mode_provisions_plugin_and_runs_agent_without_mcp( + async def test_provisions_plugin_and_runs_agent_without_mcp( self, - mock_manifest: Mock, mock_create_logger: Mock, mock_agent_query: AsyncMock, mock_telemetry: Mock, mock_telemetry_ctx: Mock, tmp_path: Path, ) -> None: - """Scenario: MCP disabled but ROSETTA_PLUGIN_PATH points at an on-disk plugin -> provisioned - -plugin mode. + """Scenario: ROSETTA_PLUGIN_PATH points at an on-disk plugin. The bundled plugin is provisioned into the primary workspace (so its skills are - discoverable via project scope), agent_query is called with no MCP servers and no SDK - ``plugins=`` param, and the prompt is rendered in plugin mode (no `mcp__KnowledgeBase__`). - The plugin path must exist on disk — that is the gate the mode resolver enforces. + discoverable via project scope), and agent_query receives the plugin entry-point tools + with an empty optional-MCP configuration. """ plugin_dir = tmp_path / "rosetta-plugin" plugin_dir.mkdir() - ctx = _make_ctx(rosetta_enabled=False, rosetta_plugin_path=str(plugin_dir)) + ctx = _make_ctx(rosetta_plugin_path=str(plugin_dir)) mock_create_logger.return_value = Mock() mock_agent_query.return_value = Mock(result="ok", session_id="s1") - mock_manifest.return_value = ["rosetta/CLAUDE.md"] await run_kb_init_agent(ctx) @@ -185,81 +171,38 @@ async def test_plugin_mode_provisions_plugin_and_runs_agent_without_mcp( ) mock_agent_query.assert_awaited_once() call_kwargs = mock_agent_query.call_args.kwargs - assert "plugins" not in call_kwargs # SDK plugins= loader intentionally unused - assert not call_kwargs["mcp_servers"] # empty dict -> no MCP server attached + assert not call_kwargs["mcp_servers"] + assert {"Skill", "SlashCommand"} <= set(call_kwargs["allowed_tools"]) assert "## Rosetta Plugin Usage" in call_kwargs["system_prompt"] - assert "mcp__" not in call_kwargs["system_prompt"] - - mock_telemetry.capture_kb_init_triggered.assert_called_once() - event_kwargs = mock_telemetry.capture_kb_init_event.call_args.kwargs - assert event_kwargs["status"] == "success" - - @pytest.mark.asyncio - @patch("app.services.workflow_steps.TelemetryContext") - @patch("app.services.workflow_steps.telemetry") - @patch("app.services.workflow_steps.agent_query", new_callable=AsyncMock) - @patch("app.services.workflow_steps.create_agent_logger") - @patch("app.services.workflow_steps._collect_rosetta_file_manifest") - async def test_calls_agent_and_emits_success_event( - self, - mock_manifest: Mock, - mock_create_logger: Mock, - mock_agent_query: AsyncMock, - mock_telemetry: Mock, - mock_telemetry_ctx: Mock, - ) -> None: - """Scenario: enabled, agent succeeds -> emits success event with file list.""" - ctx = _make_ctx(rosetta_enabled=True) - mock_create_logger.return_value = Mock() - mock_agent_query.return_value = Mock(result="ok", session_id="s1") - mock_manifest.return_value = [ - "rosetta/CLAUDE.md", - "rosetta/agents/backend.md", - ] - - await run_kb_init_agent(ctx) + # Regression: the workspace name MUST be set on TelemetryContext before the agent runs, + # otherwise build_stream_publisher_from_context() returns None and the TUI live-message + # stream is empty for the entire KB-init step. + mock_telemetry_ctx.set_workspace_name.assert_called_once_with("ws1") mock_telemetry_ctx.set_workflow.assert_called_once_with( TelemetryWorkflowLabel.plain(WorkflowName.KB_INIT) ) - # Regression: the workspace name MUST be set on TelemetryContext before the - # agent runs, otherwise build_stream_publisher_from_context() returns None - # and the TUI live-message stream is empty for the entire KB-init step - # (only generation_id is set elsewhere; both are required). - mock_telemetry_ctx.set_workspace_name.assert_called_once_with("ws1") - mock_agent_query.assert_awaited_once() - call_kwargs = mock_agent_query.call_args.kwargs - assert "mcp_servers" in call_kwargs - assert ROSETTA_SERVER_KEY in call_kwargs["mcp_servers"] - - mock_telemetry.capture_kb_init_triggered.assert_called_once_with( - generation_id="est-123", - workspace_name="ws1", - ) - mock_telemetry.capture_kb_init_event.assert_called_once() + mock_telemetry.capture_kb_init_triggered.assert_called_once() event_kwargs = mock_telemetry.capture_kb_init_event.call_args.kwargs assert event_kwargs["status"] == "success" - assert event_kwargs["generation_id"] == "est-123" - assert len(event_kwargs["generated_files"]) == 2 - assert event_kwargs["duration_seconds"] >= 0 @pytest.mark.asyncio @patch("app.services.workflow_steps.telemetry") @patch("app.services.workflow_steps.agent_query", new_callable=AsyncMock) @patch("app.services.workflow_steps.create_agent_logger") - @patch("app.services.workflow_steps._collect_rosetta_file_manifest") async def test_catches_exceptions_and_emits_failed_event( self, - mock_manifest: Mock, mock_create_logger: Mock, mock_agent_query: AsyncMock, mock_telemetry: Mock, + tmp_path: Path, ) -> None: """Scenario: agent_query raises -> exception caught, failed event emitted.""" - ctx = _make_ctx(rosetta_enabled=True) + plugin_dir = tmp_path / "rosetta-plugin" + plugin_dir.mkdir() + ctx = _make_ctx(rosetta_plugin_path=str(plugin_dir)) mock_create_logger.return_value = Mock() - mock_agent_query.side_effect = RuntimeError("MCP connection failed") - mock_manifest.return_value = [] + mock_agent_query.side_effect = RuntimeError("agent run failed") await run_kb_init_agent(ctx) @@ -274,7 +217,7 @@ async def test_catches_exceptions_and_emits_failed_event( mock_telemetry.capture_kb_init_event.assert_called_once() event_kwargs = mock_telemetry.capture_kb_init_event.call_args.kwargs assert event_kwargs["status"] == "failed" - assert "MCP connection failed" in event_kwargs["error_message"] + assert "agent run failed" in event_kwargs["error_message"] @pytest.mark.asyncio @patch("app.services.workflow_steps.TelemetryContext") @@ -287,19 +230,25 @@ async def test_skip_mode_creates_mock_artifacts_and_returns( mock_telemetry_ctx: Mock, tmp_path: Path, ) -> None: - """Scenario: SKIP_MODE enabled -> mock artifacts created, agent_query never called.""" - ctx = _make_ctx(rosetta_enabled=True, workspace_root=str(tmp_path)) + """Scenario: SKIP_MODE enabled -> mock artifacts written to final locations, no agent_query. + + The mock mirrors the real plugin path: CLAUDE.md at the workspace root and a doc under + outputs_dir (here the default ``docs/``). The plugin owns ``.claude/`` and is provisioned + later in prepare_parallel_workspaces. + """ + plugin_dir = tmp_path / "rosetta-plugin" + plugin_dir.mkdir() + ws_root = tmp_path / "ws" + ws_root.mkdir() + ctx = _make_ctx(workspace_root=str(ws_root), rosetta_plugin_path=str(plugin_dir)) await run_kb_init_agent(ctx) - rosetta = tmp_path / "rosetta" - assert (rosetta / "CLAUDE.md").exists() - assert (rosetta / "agents" / "default.md").exists() - assert (rosetta / "docs" / "CONTEXT.md").exists() + assert (ws_root / "CLAUDE.md").exists() + assert (ws_root / "docs" / "CONTEXT.md").exists() ctx.logger.warning.assert_any_call( - "[SKIP_MODE] KB init agent skipped — created 3 " - "mock artifacts under rosetta/" + "[SKIP_MODE] KB init agent skipped — created 2 mock KB artifacts" ) mock_telemetry_ctx.set_workflow.assert_called_once_with( TelemetryWorkflowLabel.plain(WorkflowName.KB_INIT) @@ -308,58 +257,33 @@ async def test_skip_mode_creates_mock_artifacts_and_returns( mock_telemetry.capture_kb_init_event.assert_called_once() event_kwargs = mock_telemetry.capture_kb_init_event.call_args.kwargs assert event_kwargs["status"] == "success" - assert len(event_kwargs["generated_files"]) == 3 + assert set(event_kwargs["generated_files"]) == {"CLAUDE.md", "docs/CONTEXT.md"} assert event_kwargs["duration_seconds"] == 0.0 -# --------------------------------------------------------------------------- -# Blank-config no-op regression tests for Rosetta (FR-10 / S4.1 insurance) -# --------------------------------------------------------------------------- - -class TestRosettaBlankConfigNoOpKbInit: - """ROSETTA_MCP_ENABLED=False with all creds blank → no agent_query call, no error.""" - - @pytest.mark.asyncio - @patch("app.services.workflow_steps.telemetry") - @patch("app.services.workflow_steps.agent_query", new_callable=AsyncMock) - async def test_disabled_with_blank_creds_skips_agent_query( - self, - mock_agent_query: AsyncMock, - mock_telemetry: Mock, - ) -> None: - """Scenario: ROSETTA_MCP_ENABLED=False + all creds None → agent_query never called, no error.""" - ctx = _make_ctx(rosetta_enabled=False) - # Blank out all Rosetta credentials - ctx.settings.ROSETTA_API_KEY = None - ctx.settings.ROSETTA_USER_EMAIL = None - ctx.settings.ROSETTA_SERVER_URL = None - ctx.settings.ROSETTA_IMS_VERSION = "" - - await run_kb_init_agent(ctx) - - mock_agent_query.assert_not_called() - mock_telemetry.capture_kb_init_triggered.assert_not_called() - # Skipped event is still emitted so observability works - mock_telemetry.capture_kb_init_event.assert_called_once() - event_kwargs = mock_telemetry.capture_kb_init_event.call_args.kwargs - assert event_kwargs["status"] == "skipped" +class TestGenerateMockKbInitArtifacts: + """Tests for _generate_mock_kb_init_artifacts helper (final-location output).""" + def test_creates_expected_files(self, tmp_path: Path) -> None: + logger = Mock() + _generate_mock_kb_init_artifacts(str(tmp_path), "docs", logger) -class TestGenerateMockRosettaArtifacts: - """Tests for _generate_mock_rosetta_artifacts helper.""" + assert (tmp_path / "CLAUDE.md").exists() + assert (tmp_path / "docs" / "CONTEXT.md").exists() + assert "[SKIP_MODE]" in (tmp_path / "CLAUDE.md").read_text() + # The plugin owns .claude/ — the mock must not write there. + assert not (tmp_path / ".claude").exists() - def test_creates_expected_files(self, tmp_path: Path) -> None: + def test_honors_custom_outputs_dir(self, tmp_path: Path) -> None: logger = Mock() - _generate_mock_rosetta_artifacts(str(tmp_path), "rosetta", logger) + _generate_mock_kb_init_artifacts(str(tmp_path), "specflow", logger) - assert (tmp_path / "rosetta" / "CLAUDE.md").exists() - assert (tmp_path / "rosetta" / "agents" / "default.md").exists() - assert (tmp_path / "rosetta" / "docs" / "CONTEXT.md").exists() - assert "[SKIP_MODE]" in (tmp_path / "rosetta" / "CLAUDE.md").read_text() + assert (tmp_path / "CLAUDE.md").exists() + assert (tmp_path / "specflow" / "CONTEXT.md").exists() def test_idempotent_on_existing_dir(self, tmp_path: Path) -> None: logger = Mock() - _generate_mock_rosetta_artifacts(str(tmp_path), "rosetta", logger) - _generate_mock_rosetta_artifacts(str(tmp_path), "rosetta", logger) + _generate_mock_kb_init_artifacts(str(tmp_path), "docs", logger) + _generate_mock_kb_init_artifacts(str(tmp_path), "docs", logger) - assert (tmp_path / "rosetta" / "CLAUDE.md").exists() + assert (tmp_path / "CLAUDE.md").exists() diff --git a/backend/test/test_workspace_manager.py b/backend/test/test_workspace_manager.py index a054734..efbd52a 100644 --- a/backend/test/test_workspace_manager.py +++ b/backend/test/test_workspace_manager.py @@ -236,15 +236,13 @@ def mock_settings(self): settings.AGENT_BASE_PATH = "/agent" settings.ANTHROPIC_API_KEY = "test-key" settings.STANDARDS_DIR_NAME = "standards" - settings.ROSETTA_OUTPUT_DIR = "rosetta" # Exclude set used by sync_directories / clear_src_directory (must be a real list). settings.EXCLUDED_ARTIFACT_PATTERNS = [ ".git", ".venv", "venv", "__pycache__", "*.pyc", "node_modules", "dist", "build", "standards", ] settings.WORKSPACE_EXCLUDE_PATTERNS = [] - # Plugin provisioning is a no-op here (no plugin path); MCP gate not taken. - settings.ROSETTA_MCP_ENABLED = False + # Plugin provisioning is a no-op here (no plugin path). settings.ROSETTA_PLUGIN_PATH = None return settings @@ -554,14 +552,12 @@ def test_prepare_parallel_workspaces(self, mock_settings, temp_workspace_dir): def test_prepare_parallel_workspaces_creates_outputs_on_all(self, mock_settings, temp_workspace_dir): """prepare_parallel_workspaces ensures specflow/ exists on primary and extra workspaces.""" - mock_settings.ROSETTA_OUTPUT_DIR = "rosetta" manager = WorkspaceManager(mock_settings) primary_path = Path(temp_workspace_dir) / "primary" primary_path.mkdir() (primary_path / "specifications").mkdir() (primary_path / "specflow").mkdir() - (primary_path / "rosetta").mkdir() extra1_path = Path(temp_workspace_dir) / "extra1" extra1_path.mkdir() diff --git a/backend/test/test_workspace_manager_rosetta.py b/backend/test/test_workspace_manager_rosetta.py index 7ba31bc..124ccc4 100644 --- a/backend/test/test_workspace_manager_rosetta.py +++ b/backend/test/test_workspace_manager_rosetta.py @@ -1,4 +1,4 @@ -"""Unit tests for workspace manager rosetta unpack and sync functionality.""" +"""Unit tests for workspace manager Rosetta plugin provisioning and parallel prep.""" import pytest from pathlib import Path @@ -6,508 +6,23 @@ from app.core.config import Settings from app.schemas.workspace import WorkspaceSettings -from app.services.workflow_steps import WorkflowContext, sync_specs_to_workspaces from app.services.workspace_manager import WorkspaceManager def _mock_settings() -> Mock: settings = Mock(spec=Settings) settings.STANDARDS_DIR_NAME = "standards" - settings.ROSETTA_OUTPUT_DIR = "rosetta" # Exclude set used by sync_directories / clear_src_directory (must be a real list). settings.EXCLUDED_ARTIFACT_PATTERNS = [ ".git", ".venv", "venv", "__pycache__", "*.pyc", "node_modules", "dist", "build", "standards", ] settings.WORKSPACE_EXCLUDE_PATTERNS = [] - # Plugin provisioning is a no-op in these unpack-focused tests (no plugin path). - settings.ROSETTA_MCP_ENABLED = False + # No plugin path by default; provisioning is a no-op unless a test points it at a real dir. settings.ROSETTA_PLUGIN_PATH = None return settings -def _populate_rosetta(ws_root: Path) -> None: - """Create a representative rosetta/ directory structure in a workspace.""" - rosetta = ws_root / "rosetta" - rosetta.mkdir(exist_ok=True) - (rosetta / "CLAUDE.md").write_text("# Project Instructions") - - agents = rosetta / "agents" - agents.mkdir(parents=True) - (agents / "backend-agent.md").write_text("---\nname: backend\n---") - (agents / "frontend-agent.md").write_text("---\nname: frontend\n---") - - docs = rosetta / "docs" - docs.mkdir() - (docs / "CONTEXT.md").write_text("# Context") - (docs / "ARCHITECTURE.md").write_text("# Architecture") - - -class TestUnpackRosettaArtifacts: - """Tests for WorkspaceManager.unpack_rosetta_artifacts().""" - - @pytest.fixture - def workspace_dir(self, tmp_path: Path) -> Path: - ws = tmp_path / "workspace" - ws.mkdir() - return ws - - @pytest.fixture - def mock_settings(self) -> Mock: - return _mock_settings() - - @pytest.fixture - def workspace(self, workspace_dir: Path) -> WorkspaceSettings: - return WorkspaceSettings( - name="test-ws", - workspace_path=str(workspace_dir), - provider="anthropic", - model="claude-sonnet-4-0", - ) - - @pytest.fixture - def manager(self, mock_settings: Mock) -> WorkspaceManager: - return WorkspaceManager(mock_settings) - - def test_noop_when_rosetta_missing( - self, manager: WorkspaceManager, workspace: WorkspaceSettings, - ) -> None: - """Scenario: no rosetta/ directory -> no-op, no error.""" - manager.unpack_rosetta_artifacts(workspace) - ws_root = Path(workspace.get_isolated_root()) - assert not (ws_root / "CLAUDE.md").exists() - - def test_copies_files_correctly( - self, - manager: WorkspaceManager, - workspace: WorkspaceSettings, - workspace_dir: Path, - ) -> None: - """Scenario: rosetta/ has CLAUDE.md and docs/ -> unpacked to workspace root.""" - rosetta = workspace_dir / "rosetta" - rosetta.mkdir() - (rosetta / "CLAUDE.md").write_text("# Project Instructions") - docs = rosetta / "docs" - docs.mkdir() - (docs / "CONTEXT.md").write_text("# Context") - - manager.unpack_rosetta_artifacts(workspace) - - assert (workspace_dir / "CLAUDE.md").read_text() == "# Project Instructions" - assert (workspace_dir / "docs" / "CONTEXT.md").read_text() == "# Context" - - def test_agents_dir_remapped_to_dot_claude( - self, - manager: WorkspaceManager, - workspace: WorkspaceSettings, - workspace_dir: Path, - ) -> None: - """Scenario: rosetta/agents/ is remapped to workspace_root/.claude/agents/ on unpack. - - The KB init agent writes to rosetta/agents/ (not rosetta/.claude/agents/) to avoid - the SDK sensitive-file guard. unpack_rosetta_artifacts remaps this to .claude/agents/. - """ - rosetta = workspace_dir / "rosetta" - agents_dir = rosetta / "agents" - agents_dir.mkdir(parents=True) - (agents_dir / "backend-agent.md").write_text("---\nname: backend\n---") - - manager.unpack_rosetta_artifacts(workspace) - - unpacked = workspace_dir / ".claude" / "agents" / "backend-agent.md" - assert unpacked.exists(), "rosetta/agents/ must be remapped to .claude/agents/" - assert "backend" in unpacked.read_text() - # rosetta/agents/ must NOT be copied literally to workspace_root/agents/ - assert not (workspace_dir / "agents").exists() - - def test_skills_dir_remapped_to_dot_claude( - self, - manager: WorkspaceManager, - workspace: WorkspaceSettings, - workspace_dir: Path, - ) -> None: - """Scenario: rosetta/skills/ is remapped to workspace_root/.claude/skills/.""" - rosetta = workspace_dir / "rosetta" - skills = rosetta / "skills" / "my-skill" - skills.mkdir(parents=True) - (skills / "SKILL.md").write_text("---\nname: my-skill\n---\n") - - manager.unpack_rosetta_artifacts(workspace) - - unpacked = workspace_dir / ".claude" / "skills" / "my-skill" / "SKILL.md" - assert unpacked.exists() - assert "my-skill" in unpacked.read_text() - assert not (workspace_dir / "skills").exists() - - def test_commands_dir_remapped_to_dot_claude( - self, - manager: WorkspaceManager, - workspace: WorkspaceSettings, - workspace_dir: Path, - ) -> None: - """Scenario: rosetta/commands/ is remapped to workspace_root/.claude/commands/.""" - rosetta = workspace_dir / "rosetta" - cmd_dir = rosetta / "commands" - cmd_dir.mkdir(parents=True) - (cmd_dir / "review.md").write_text("# /review command\n") - - manager.unpack_rosetta_artifacts(workspace) - - unpacked = workspace_dir / ".claude" / "commands" / "review.md" - assert unpacked.exists() - assert not (workspace_dir / "commands").exists() - - def test_agents_remap_preserves_content( - self, - manager: WorkspaceManager, - workspace: WorkspaceSettings, - workspace_dir: Path, - ) -> None: - """Scenario: multiple agent files in rosetta/agents/ all land in .claude/agents/.""" - rosetta = workspace_dir / "rosetta" - agents_dir = rosetta / "agents" - agents_dir.mkdir(parents=True) - (agents_dir / "frontend.md").write_text("---\nname: frontend\n---\ncontent") - (agents_dir / "backend.md").write_text("---\nname: backend\n---\ncontent") - - manager.unpack_rosetta_artifacts(workspace) - - dot_claude_agents = workspace_dir / ".claude" / "agents" - assert (dot_claude_agents / "frontend.md").read_text() == "---\nname: frontend\n---\ncontent" - assert (dot_claude_agents / "backend.md").read_text() == "---\nname: backend\n---\ncontent" - - def test_overwrites_existing_target( - self, - manager: WorkspaceManager, - workspace: WorkspaceSettings, - workspace_dir: Path, - ) -> None: - """Scenario: target directory exists -> rosetta/ content is merged in. - - Pre-existing files (e.g. planning outputs such as IMPLEMENTATION_PLAN.md) - must be preserved. Rosetta files are added/updated alongside them. - """ - (workspace_dir / "docs").mkdir() - (workspace_dir / "docs" / "OLD.md").write_text("old content") - - rosetta = workspace_dir / "rosetta" - docs = rosetta / "docs" - docs.mkdir(parents=True) - (docs / "NEW.md").write_text("new content") - - manager.unpack_rosetta_artifacts(workspace) - - assert (workspace_dir / "docs" / "NEW.md").read_text() == "new content" - # OLD.md (e.g. a planning file written before unpack) must survive. - assert (workspace_dir / "docs" / "OLD.md").read_text() == "old content" - - def test_custom_rosetta_dir_name( - self, - manager: WorkspaceManager, - workspace: WorkspaceSettings, - workspace_dir: Path, - ) -> None: - """Scenario: custom rosetta directory name -> uses that name.""" - custom = workspace_dir / "my_kb" - custom.mkdir() - (custom / "CLAUDE.md").write_text("custom") - - manager.unpack_rosetta_artifacts(workspace, rosetta_dir="my_kb") - - assert (workspace_dir / "CLAUDE.md").read_text() == "custom" - - def test_rosetta_dir_preserved_after_unpack( - self, - manager: WorkspaceManager, - workspace: WorkspaceSettings, - workspace_dir: Path, - ) -> None: - """Scenario: unpack copies content but original rosetta/ stays intact.""" - _populate_rosetta(workspace_dir) - - manager.unpack_rosetta_artifacts(workspace) - - assert (workspace_dir / "rosetta" / "CLAUDE.md").exists() - assert (workspace_dir / "CLAUDE.md").exists() - - -class TestPrepareParallelWorkspacesRosetta: - """Tests for rosetta/ sync + unpack integration in prepare_parallel_workspaces. - - Scenario: KB init agent produced rosetta/ on primary workspace. When - prepare_parallel_workspaces runs it should: - 1. Unpack rosetta/ on the primary workspace - 2. Sync rosetta/ directory to each extra workspace - 3. Unpack rosetta/ on each extra workspace - Result: every workspace has CLAUDE.md, .claude/agents/, docs/ at root. - """ - - @pytest.fixture - def root(self, tmp_path: Path) -> Path: - return tmp_path - - @pytest.fixture - def manager(self) -> WorkspaceManager: - return WorkspaceManager(_mock_settings()) - - def _make_workspace( - self, root: Path, name: str, *, populate: bool = False, - ) -> WorkspaceSettings: - ws_path = root / name - ws_path.mkdir(exist_ok=True) - if populate: - (ws_path / "specifications").mkdir() - (ws_path / "specifications" / "spec.md").write_text("spec") - (ws_path / "specflow").mkdir() - (ws_path / "specflow" / "output.md").write_text("output") - return WorkspaceSettings( - name=name, - workspace_path=str(ws_path), - provider="anthropic", - model="claude-sonnet-4-0", - ) - - def test_rosetta_synced_and_unpacked_on_all_workspaces( - self, root: Path, manager: WorkspaceManager, - ) -> None: - """Scenario: primary has rosetta/ -> all workspaces get SDK artifacts.""" - primary_ws = self._make_workspace(root, "primary", populate=True) - extra1_ws = self._make_workspace(root, "extra1") - extra2_ws = self._make_workspace(root, "extra2") - - _populate_rosetta(root / "primary") - - manager.prepare_parallel_workspaces( - primary_workspace=primary_ws, - extra_workspaces=[extra1_ws, extra2_ws], - spec_path="specifications", - outputs_dir="specflow", - ) - - for ws_name in ["primary", "extra1", "extra2"]: - ws = root / ws_name - assert (ws / "CLAUDE.md").exists(), f"{ws_name}: CLAUDE.md missing" - assert (ws / "CLAUDE.md").read_text() == "# Project Instructions" - assert (ws / ".claude" / "agents" / "backend-agent.md").exists(), ( - f"{ws_name}: backend-agent.md missing" - ) - assert (ws / ".claude" / "agents" / "frontend-agent.md").exists(), ( - f"{ws_name}: frontend-agent.md missing" - ) - assert (ws / "docs" / "CONTEXT.md").exists(), ( - f"{ws_name}: docs/CONTEXT.md missing" - ) - assert (ws / "docs" / "ARCHITECTURE.md").exists(), ( - f"{ws_name}: docs/ARCHITECTURE.md missing" - ) - assert (ws / "rosetta" / "CLAUDE.md").exists(), ( - f"{ws_name}: rosetta/ source should be preserved" - ) - - def test_no_rosetta_no_unpack( - self, root: Path, manager: WorkspaceManager, - ) -> None: - """Scenario: KB init skipped, no rosetta/ -> no crash, no SDK artifacts.""" - primary_ws = self._make_workspace(root, "primary", populate=True) - extra_ws = self._make_workspace(root, "extra1") - - manager.prepare_parallel_workspaces( - primary_workspace=primary_ws, - extra_workspaces=[extra_ws], - spec_path="specifications", - outputs_dir="specflow", - ) - - for ws_name in ["primary", "extra1"]: - ws = root / ws_name - assert not (ws / "CLAUDE.md").exists() - assert not (ws / ".claude").exists() - - assert (root / "extra1" / "specifications" / "spec.md").exists() - assert (root / "extra1" / "specflow" / "output.md").exists() - - def test_rosetta_does_not_clobber_existing_specs( - self, root: Path, manager: WorkspaceManager, - ) -> None: - """Scenario: rosetta/docs/ should not destroy existing spec or specflow dirs.""" - primary_ws = self._make_workspace(root, "primary", populate=True) - extra_ws = self._make_workspace(root, "extra1") - - _populate_rosetta(root / "primary") - - manager.prepare_parallel_workspaces( - primary_workspace=primary_ws, - extra_workspaces=[extra_ws], - spec_path="specifications", - outputs_dir="specflow", - ) - - assert (root / "extra1" / "specifications" / "spec.md").exists() - assert (root / "extra1" / "specflow" / "output.md").exists() - assert (root / "extra1" / "CLAUDE.md").exists() - - -class TestSyncSpecsToWorkspacesRosettaIntegration: - """End-to-end tests for the full KB-init → sync pipeline. - - Exercises sync_specs_to_workspaces() — the actual workflow step function — - against real filesystem directories (tmp_path). No mocking of workspace - manager or filesystem operations. - - What's verified: - 1. Artifacts written by KB init agent (rosetta/) are synced from the - primary workspace to every extra workspace. - 2. Artifacts are unpacked to workspace root on every workspace, including - primary, so agents can discover CLAUDE.md and .claude/agents/ via SDK. - 3. Specs and outputs directories are also synced (rosetta is additive). - 4. If rosetta/ is absent (KB init skipped) the step still completes - cleanly and specs are still synced. - """ - - def _make_ws( - self, - root: Path, - name: str, - *, - specs: bool = False, - ) -> WorkspaceSettings: - ws_path = root / name - ws_path.mkdir(exist_ok=True) - if specs: - (ws_path / "specifications").mkdir() - (ws_path / "specifications" / "spec.md").write_text("spec content") - (ws_path / "specflow").mkdir() - (ws_path / "specflow" / "output.md").write_text("output content") - return WorkspaceSettings( - name=name, - workspace_path=str(ws_path), - provider="anthropic", - model="claude-sonnet-4-0", - ) - - def _make_ctx( - self, - tmp_path: Path, - primary_ws: WorkspaceSettings, - extra_workspaces: list, - *, - standards_source: str | None = None, - ) -> WorkflowContext: - settings = _mock_settings() - settings.AGENT_BASE_PATH = str(tmp_path) - settings.WORKSPACE_BASE_PATH = str(tmp_path) - settings.STANDARDS_SOURCE_PATH = standards_source or "" - manager = WorkspaceManager(settings) - request = Mock() - request.spec_path = "specifications" - request.outputs_dir = "specflow" - request.src_dir = "src" - return WorkflowContext( - request=request, - settings=settings, - logger=Mock(), - workspace_ids=[primary_ws.name] + [w.name for w in extra_workspaces], - workspaces=[primary_ws] + extra_workspaces, - workspace_manager=manager, - primary_workspace=primary_ws, - extra_workspaces=extra_workspaces, - ) - - @pytest.mark.asyncio - async def test_rosetta_artifacts_reach_all_workspaces_after_sync( - self, tmp_path: Path, - ) -> None: - """Scenario: KB init populated rosetta/ on primary. After sync_specs_to_workspaces, - every workspace (primary and all extras) has the unpacked SDK artifacts.""" - primary = self._make_ws(tmp_path, "primary", specs=True) - extra1 = self._make_ws(tmp_path, "extra1") - extra2 = self._make_ws(tmp_path, "extra2") - - # Simulate KB init agent output on primary workspace - _populate_rosetta(tmp_path / "primary") - - ctx = self._make_ctx(tmp_path, primary, [extra1, extra2]) - await sync_specs_to_workspaces(ctx) - - for ws_name in ["primary", "extra1", "extra2"]: - ws = tmp_path / ws_name - assert (ws / "CLAUDE.md").exists(), f"{ws_name}: CLAUDE.md not unpacked" - assert (ws / "CLAUDE.md").read_text() == "# Project Instructions" - assert (ws / ".claude" / "agents" / "backend-agent.md").exists(), ( - f"{ws_name}: backend-agent.md not unpacked" - ) - assert (ws / ".claude" / "agents" / "frontend-agent.md").exists(), ( - f"{ws_name}: frontend-agent.md not unpacked" - ) - assert (ws / "docs" / "CONTEXT.md").exists(), ( - f"{ws_name}: docs/CONTEXT.md not unpacked" - ) - - @pytest.mark.asyncio - async def test_artifact_content_is_identical_across_workspaces( - self, tmp_path: Path, - ) -> None: - """Scenario: every workspace must receive byte-for-byte identical content - from rosetta/ so agents work from the same KB snapshot.""" - primary = self._make_ws(tmp_path, "primary", specs=True) - extra1 = self._make_ws(tmp_path, "extra1") - extra2 = self._make_ws(tmp_path, "extra2") - - _populate_rosetta(tmp_path / "primary") - - ctx = self._make_ctx(tmp_path, primary, [extra1, extra2]) - await sync_specs_to_workspaces(ctx) - - for ws_name in ["extra1", "extra2"]: - ws = tmp_path / ws_name - assert (ws / "CLAUDE.md").read_text() == (tmp_path / "primary" / "CLAUDE.md").read_text() - assert (ws / ".claude" / "agents" / "backend-agent.md").read_text() == ( - tmp_path / "primary" / ".claude" / "agents" / "backend-agent.md" - ).read_text() - # Verify rosetta/agents/ source files are not exposed at workspace root - assert not (ws / "agents").exists() - - @pytest.mark.asyncio - async def test_no_rosetta_sync_completes_cleanly( - self, tmp_path: Path, - ) -> None: - """Scenario: KB init was skipped (no rosetta/ dir). sync step must not - crash and specs must still be synced to extra workspaces.""" - primary = self._make_ws(tmp_path, "primary", specs=True) - extra1 = self._make_ws(tmp_path, "extra1") - - # No rosetta/ directory — KB init was skipped - ctx = self._make_ctx(tmp_path, primary, [extra1]) - await sync_specs_to_workspaces(ctx) # must not raise - - # SDK artifacts absent (KB was skipped) - assert not (tmp_path / "extra1" / "CLAUDE.md").exists() - # Specs still synced - assert (tmp_path / "extra1" / "specifications" / "spec.md").exists() - - @pytest.mark.asyncio - async def test_rosetta_dir_name_comes_from_settings( - self, tmp_path: Path, - ) -> None: - """Scenario: ROSETTA_OUTPUT_DIR setting controls which directory is - treated as KB artifacts source — not a hardcoded 'rosetta' string.""" - primary = self._make_ws(tmp_path, "primary", specs=True) - extra1 = self._make_ws(tmp_path, "extra1") - - # Use a custom rosetta dir name via settings - custom_rosetta = tmp_path / "primary" / "kb_artifacts" - custom_rosetta.mkdir() - (custom_rosetta / "CLAUDE.md").write_text("# Custom KB") - - ctx = self._make_ctx(tmp_path, primary, [extra1]) - ctx.settings.ROSETTA_OUTPUT_DIR = "kb_artifacts" - - await sync_specs_to_workspaces(ctx) - - assert (tmp_path / "primary" / "CLAUDE.md").read_text() == "# Custom KB" - assert (tmp_path / "extra1" / "CLAUDE.md").read_text() == "# Custom KB" - - def _make_plugin(plugin_root: Path) -> None: """Build a representative bundled Rosetta plugin tree at plugin_root.""" (plugin_root / ".claude-plugin").mkdir(parents=True) @@ -561,9 +76,8 @@ def plugin_root(self, tmp_path: Path) -> Path: _make_plugin(root) return root - def _manager(self, plugin_path, mcp_enabled: bool = False) -> WorkspaceManager: + def _manager(self, plugin_path) -> WorkspaceManager: settings = _mock_settings() - settings.ROSETTA_MCP_ENABLED = mcp_enabled settings.ROSETTA_PLUGIN_PATH = str(plugin_path) if plugin_path else None return WorkspaceManager(settings) @@ -642,14 +156,6 @@ def test_noop_when_plugin_path_unset( assert self._manager(None).provision_rosetta_plugin(workspace) is False assert not (workspace_dir / ".claude").exists() - def test_noop_when_mcp_enabled( - self, workspace: WorkspaceSettings, workspace_dir: Path, plugin_root: Path, - ) -> None: - """Scenario: live MCP enabled -> plugin is NOT provisioned (MCP supplies the KB).""" - manager = self._manager(plugin_root, mcp_enabled=True) - assert manager.provision_rosetta_plugin(workspace) is False - assert not (workspace_dir / ".claude").exists() - def test_noop_when_plugin_path_missing_on_disk( self, workspace: WorkspaceSettings, workspace_dir: Path, tmp_path: Path, ) -> None: @@ -710,40 +216,6 @@ def test_overlapping_manifest_dirs_do_not_drop_a_tree( # agents are unaffected. assert (claude / "agents" / "engineer.md").exists() - def test_unpack_is_noop_when_plugin_mode_active( - self, workspace: WorkspaceSettings, workspace_dir: Path, plugin_root: Path, - ) -> None: - """Scenario: plugin mode -> unpack_rosetta_artifacts skips (plugin provisions .claude/). - - A staged rosetta/ tree must NOT be remapped in plugin mode; the plugin handles .claude/ - and the agent writes docs to their final locations directly. - """ - # Stage a rosetta/ tree as the MCP-mode agent would. - rosetta = workspace_dir / "rosetta" - (rosetta / "agents").mkdir(parents=True) - (rosetta / "agents" / "stale.md").write_text("---\nname: stale\n---") - (rosetta / "CLAUDE.md").write_text("# staged") - - self._manager(plugin_root).unpack_rosetta_artifacts(workspace, "rosetta") - - # Nothing remapped: no .claude/agents from rosetta/, no root CLAUDE.md from staging. - assert not (workspace_dir / ".claude" / "agents" / "stale.md").exists() - assert not (workspace_dir / "CLAUDE.md").exists() - - def test_unpack_runs_in_mcp_mode( - self, workspace: WorkspaceSettings, workspace_dir: Path, plugin_root: Path, - ) -> None: - """Scenario: MCP mode -> unpack remaps the staged rosetta/ tree into .claude/ and root.""" - rosetta = workspace_dir / "rosetta" - (rosetta / "agents").mkdir(parents=True) - (rosetta / "agents" / "engineer.md").write_text("---\nname: engineer\n---") - (rosetta / "CLAUDE.md").write_text("# project") - - self._manager(plugin_root, mcp_enabled=True).unpack_rosetta_artifacts(workspace, "rosetta") - - assert (workspace_dir / ".claude" / "agents" / "engineer.md").exists() - assert (workspace_dir / "CLAUDE.md").read_text() == "# project" - def test_malformed_hook_entries_are_non_fatal( self, workspace: WorkspaceSettings, workspace_dir: Path, plugin_root: Path, ) -> None: @@ -761,3 +233,82 @@ def test_malformed_hook_entries_are_non_fatal( (workspace_dir / ".claude" / "settings.json").read_text() ) assert "PreToolUse" in settings_data["hooks"] + + +class TestPrepareParallelWorkspacesPlugin: + """prepare_parallel_workspaces: provision the plugin on every workspace and propagate the + KB agent's outputs (root CLAUDE.md + outputs_dir docs) plus specs from primary to extras. + """ + + def _make_workspace(self, root: Path, name: str, *, populate: bool = False) -> WorkspaceSettings: + ws_path = root / name + ws_path.mkdir(exist_ok=True) + if populate: + (ws_path / "specifications").mkdir() + (ws_path / "specifications" / "spec.md").write_text("spec") + (ws_path / "specflow").mkdir() + (ws_path / "specflow" / "output.md").write_text("output") + return WorkspaceSettings( + name=name, + workspace_path=str(ws_path), + provider="anthropic", + model="claude-sonnet-4-0", + ) + + def test_provisions_plugin_and_syncs_kb_output_to_all_workspaces(self, tmp_path: Path) -> None: + plugin_root = tmp_path / "opt" / "rosetta-plugin" + _make_plugin(plugin_root) + settings = _mock_settings() + settings.ROSETTA_PLUGIN_PATH = str(plugin_root) + manager = WorkspaceManager(settings) + + root = tmp_path / "workspaces" + root.mkdir() + primary_ws = self._make_workspace(root, "primary", populate=True) + extra1_ws = self._make_workspace(root, "extra1") + extra2_ws = self._make_workspace(root, "extra2") + + # Simulate KB init agent output on primary: CLAUDE.md at root + a doc in outputs_dir. + (root / "primary" / "CLAUDE.md").write_text("# Project Instructions") + (root / "primary" / "specflow" / "CONTEXT.md").write_text("# Context") + + manager.prepare_parallel_workspaces( + primary_workspace=primary_ws, + extra_workspaces=[extra1_ws, extra2_ws], + spec_path="specifications", + outputs_dir="specflow", + ) + + for ws_name in ["primary", "extra1", "extra2"]: + ws = root / ws_name + # Plugin discovery trees provisioned into every workspace. + assert (ws / ".claude" / "agents" / "engineer.md").exists(), f"{ws_name}: agents missing" + assert (ws / ".claude" / "skills" / "init-workspace-flow" / "SKILL.md").exists() + + # KB output + specs propagated from primary to extras. + for ws_name in ["extra1", "extra2"]: + ws = root / ws_name + assert (ws / "CLAUDE.md").read_text() == "# Project Instructions" + assert (ws / "specifications" / "spec.md").exists() + assert (ws / "specflow" / "CONTEXT.md").exists() + + def test_no_kb_output_still_syncs_specs(self, tmp_path: Path) -> None: + """Scenario: KB init skipped (no CLAUDE.md) -> no crash; specs still reach extras.""" + settings = _mock_settings() # no plugin path -> provisioning is a no-op + manager = WorkspaceManager(settings) + + root = tmp_path / "workspaces" + root.mkdir() + primary_ws = self._make_workspace(root, "primary", populate=True) + extra1_ws = self._make_workspace(root, "extra1") + + manager.prepare_parallel_workspaces( + primary_workspace=primary_ws, + extra_workspaces=[extra1_ws], + spec_path="specifications", + outputs_dir="specflow", + ) + + assert not (root / "extra1" / "CLAUDE.md").exists() + assert not (root / "extra1" / ".claude").exists() + assert (root / "extra1" / "specifications" / "spec.md").exists() diff --git a/docker-compose.yml b/docker-compose.yml index 87ad896..2034d12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,16 +55,7 @@ services: - POSTHOG_API_KEY=${POSTHOG_API_KEY} - POSTHOG_HOST=${POSTHOG_HOST:-https://eu.i.posthog.com} - FIGMA_ACCESS_TOKEN=${FIGMA_ACCESS_TOKEN} - # Rosetta KB. The live ims-mcp server is OFF by default — KB init runs against the Rosetta - # plugin baked into the image (generic KB, no service/key needed). Set ROSETTA_MCP_ENABLED=true - # (+ ROSETTA_API_KEY / ROSETTA_SERVER_URL) to opt into the live, project-tailored KB; the live - # MCP then takes precedence over the bundled plugin. Passed through explicitly so the value - # tracks your .env rather than the image default. - - ROSETTA_MCP_ENABLED=${ROSETTA_MCP_ENABLED:-false} - - ROSETTA_SERVER_URL=${ROSETTA_SERVER_URL} - - ROSETTA_API_KEY=${ROSETTA_API_KEY} - - ROSETTA_USER_EMAIL=${ROSETTA_USER_EMAIL} - - ROSETTA_IMS_VERSION=${ROSETTA_IMS_VERSION} + # Rosetta KB runs against the Rosetta plugin baked into the image (no service/key needed). - LANGFUSE_PUBLIC_KEY=${LANGFUSE_PUBLIC_KEY} - LANGFUSE_SECRET_KEY=${LANGFUSE_SECRET_KEY} - LANGFUSE_BASE_URL=${LANGFUSE_BASE_URL} diff --git a/docs/mcp/API_REFERENCE.md b/docs/mcp/API_REFERENCE.md index 3814715..b9a992e 100644 --- a/docs/mcp/API_REFERENCE.md +++ b/docs/mcp/API_REFERENCE.md @@ -37,7 +37,7 @@ Set in the MCP client config (e.g. `mcp.json`): | `MCP_SERVERS_ENABLED` | Comma-separated optional **agent** MCPs (`playwright`, `figma`); keyword-pruned after upload from spec index. | | `LOG_LEVEL` | MCP process logging. | -Figma tokens and Rosetta/KB MCP are configured on the **backend**, not in the SpecFlow MCP `env` block. See repository `README.md` and `mcp_server/services/server_instructions.py` for policy text. +Figma tokens are configured on the **backend**, not in the SpecFlow MCP `env` block. The Rosetta knowledge base ships as a plugin baked into the backend image — it needs no config or credentials. See repository `README.md` and `mcp_server/services/server_instructions.py` for policy text. **Local self-host (keyless).** When the backend runs in `AUTH_MODE=local`, omit `SPECFLOW_API_KEY` entirely — the backend authorises requests with a fixed internal diff --git a/mcp_server/services/skills/specflow-compare-variants/SKILL.md b/mcp_server/services/skills/specflow-compare-variants/SKILL.md index ac9b646..4a40624 100644 --- a/mcp_server/services/skills/specflow-compare-variants/SKILL.md +++ b/mcp_server/services/skills/specflow-compare-variants/SKILL.md @@ -190,7 +190,7 @@ Launch **1 subagent** (`subagent_type: "Plan"`, `model: "opus"`). > > ### Files to exclude > [List of files/dirs from the base workspace that should NOT be in the production repo] -> Examples: CLAUDE.md, rosetta/, agents/, specs/ (or move to docs/), .specflow-compare-variants/ +> Examples: CLAUDE.md, agents/, specs/ (or move to docs/), .specflow-compare-variants/ > > ### Config changes needed after assembly > [List of files that need edits: env URLs, secret names, service names, repo-specific values] diff --git a/specflow-init.sh b/specflow-init.sh index 28ba697..44707eb 100755 --- a/specflow-init.sh +++ b/specflow-init.sh @@ -117,7 +117,6 @@ _REDACT_PATTERNS=( "P10Y_API_KEY" "OPENROUTER_API_KEY" "ANTHROPIC_API_KEY" - "ROSETTA_API_KEY" ) log() {