Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ The following renames are applied across all architecture docs, source code, and
| Cache directory | `~/.cf-constructor/cache/` | `~/.cf-studio/cache/` |
| Default GitHub repo | `cyberfabric/cyber-constructor` | `constructorfabric/studio` |
| Default kit-sdlc repo | `cyberfabric/cyber-pilot-kit-sdlc` / `cyberfabric/cyber-constructor-kit-sdlc` | `constructorfabric/studio-kit-sdlc` |
| Workspace marker file | `.cypilot-workspace.toml` | `.studio-workspace.toml` |
| Workspace marker file | `.cypilot-workspace.toml` | `.cf-workspace.toml` (canonical; `.studio-workspace.toml` is legacy fallback) |
| VS Code workspace file | `Cypilot.code-workspace` | `Studio.code-workspace` |
| Skill name (canonical) | `cf-constructor` | `cf` (alias `cf-studio`) |
| Skill directory | `skills/cypilot/` | `skills/studio/` |
Expand Down Expand Up @@ -137,7 +137,7 @@ Confirmed when:
- `cfs generate-agents` produces host integration files under agent-specific locations such as `.agents/skills/cf/` and `.claude/skills/cf/`; canonical source files remain under `skills/studio/`
- `cfs mirror override github.com/constructorfabric/studio github.com/myorg/studio` writes to XDG path on fresh install
- `cfs mirror list` shows merged set with correct source path for each entry
- Migrating a cypilot 3.9.0 project writes `.studio-workspace.toml` directly (no intermediate Cyber Constructor form)
- Migrating a cypilot 3.9.0 project writes canonical `.cf-workspace.toml` directly (no intermediate Cyber Constructor form); `.studio-workspace.toml` remains a legacy discovery fallback
- All `cpt-cypilot-*` ID references in migrated projects are rewritten to `cpt-studio-*`

## Pros and Cons of the Options
Expand Down
4 changes: 2 additions & 2 deletions guides/MIGRATING-FROM-CYPILOT.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The orchestrator loads the shared Studio runtime first, then dispatches scanner,
**Step 6. E2 — Planner (read-only).** Approve the planner dispatch when prompted. `cf-migrate-planner` groups findings into three categories:
- **A** — auto-fixable (unambiguous string substitutions);
- **B** — needs-review (context-sensitive);
- **C** — cascade (rename to `.studio-workspace.toml`, cascade migration into workspace member repos, regenerate IDE integrations via `cfs generate-agents`).
- **C** — cascade (review legacy workspace markers and, when desired, rename them to canonical `.cf-workspace.toml`; cascade migration into workspace member repos, regenerate IDE integrations via `cfs generate-agents`). Legacy `.studio-workspace.toml` remains a discovery fallback.

**Step 7. E3 — Migrator (write).** The orchestrator prints the full plan, then offers a menu:
- `1` — apply category A only;
Expand All @@ -50,7 +50,7 @@ The orchestrator loads the shared Studio runtime first, then dispatches scanner,
- `4` — pick specific items;
- `N` — skip.

A safe starting choice is `1`. The migrator writes using Constructor Studio target identifiers: `.studio-workspace.toml`, `skills/studio/`, CLI `cfs`, cache `~/.cf-studio/cache/`, registry `constructorfabric/studio`.
A safe starting choice is `1`. The migrator writes using Constructor Studio target identifiers: canonical `.cf-workspace.toml`, `skills/studio/`, CLI `cfs`, cache `~/.cf-studio/cache/`, registry `constructorfabric/studio`. Legacy `.studio-workspace.toml` remains a discovery fallback.

Before the write-capable migrator runs, Studio resolves the session git write policy. Choose `commit`, `stage`, or `none`. The migration can still inspect git state in `none` mode, but it must not stage or commit unless the selected policy permits it and the workflow asks for that action.

Expand Down
4 changes: 2 additions & 2 deletions schemas/workspace.schema.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/constructor-studio/workspace.schema.json",
"title": "Constructor Studio Workspace Configuration (.studio-workspace.toml)",
"description": "Federation layer for multi-repo workspace support. Maps named sources (repos) to local paths with roles. Standalone file uses TOML format.",
"title": "Constructor Studio Workspace Configuration (.cf-workspace.toml)",
"description": "Federation layer for multi-repo workspace support. Maps named sources (repos) to local paths with roles. The canonical standalone filename is .cf-workspace.toml; .studio-workspace.toml remains supported as a legacy automatic discovery fallback and when supplied explicitly through workspace = \"<path>\" in config/core.toml.",
"type": "object",
"additionalProperties": false,
"required": ["version", "sources"],
Expand Down
17 changes: 17 additions & 0 deletions skills/studio/scripts/studio/commands/workspace_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,26 @@
from typing import List, Optional

from ..utils.git_utils import _redact_url, peek_git_source_path
from ..utils.stderr_logging import emit_stderr_message
from ..utils.ui import ui
from ..utils.workspace import ResolveConfig, WorkspaceConfig


_LEGACY_WORKSPACE_WARNING = (
"Legacy .studio-workspace.toml was discovered automatically; rename it to "
".cf-workspace.toml or configure workspace = \"<path>\" in config/core.toml."
)


def _warn_legacy_workspace_fallback(ws_cfg: WorkspaceConfig) -> None:
"""Emit the migration warning only for automatic legacy-file discovery."""
if ws_cfg.is_legacy_fallback:
emit_stderr_message(
f"WARNING: {_LEGACY_WORKSPACE_WARNING}\n",
logger_name=f"{__name__}.stderr",
)


def _source_warning_for_result(info: dict) -> Optional[str]:
"""Return a normalized top-level warning string for a source info record."""
warning = info.get("warning")
Expand Down Expand Up @@ -215,6 +231,7 @@ def cmd_workspace_info(argv: List[str]) -> int:
project_root, ws_cfg = _load_workspace_info_context()
if project_root is None or ws_cfg is None:
return 1
_warn_legacy_workspace_fallback(ws_cfg)

# @cpt-begin:cpt-studio-flow-workspace-info:p1:inst-info-foreach-source
sources_info = [_build_source_info(ws_cfg, name) for name in ws_cfg.sources]
Expand Down
111 changes: 97 additions & 14 deletions skills/studio/scripts/studio/commands/workspace_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,32 @@
import os
import re
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING

from ..utils.stderr_logging import emit_stderr_message
from ..utils.ui import ui

if TYPE_CHECKING:
from ..utils.workspace import WorkspaceConfig

logger = logging.getLogger(__name__)


_LEGACY_WORKSPACE_WARNING = (
"Legacy .studio-workspace.toml was discovered automatically; rename it to "
".cf-workspace.toml or configure workspace = \"<path>\" in config/core.toml."
)


def _warn_workspace_init(message: str) -> None:
logger.warning("workspace-init: %s", message)


def _emit_stderr(message: str) -> None:
"""Emit a line through a handler bound to the current stderr."""
emit_stderr_message(message, logger_name=f"{__name__}.stderr")


def _is_project_dir(entry: Path) -> bool:
"""Check if a directory looks like a project (has .git or AGENTS.md with marker)."""
if (entry / ".git").exists():
Expand Down Expand Up @@ -275,34 +290,91 @@ def _resolve_output_dir(args: argparse.Namespace, scan_root: Path, project_root:
return scan_root


def _check_existing_workspace(project_root: Path, *, inline: bool, force: bool) -> Optional[str]:
"""Check for existing workspace config conflicts. Returns error message or None."""
def _check_existing_workspace(
project_root: Path, *, inline: bool, force: bool
) -> Tuple[Optional["WorkspaceConfig"], Optional[str]]:
"""Check for existing workspace config conflicts. Returns (workspace_config, error)."""
from ..utils.workspace import find_workspace_config as _find_ws

existing_ws, ws_err = _find_ws(project_root)
if existing_ws is None:
if ws_err:
return f"Existing workspace config is broken: {ws_err}. Fix or remove it before reinitializing."
return None
return None, (
f"Existing workspace config is broken: {ws_err}. "
"Fix or remove it before reinitializing."
)
return None, None

if existing_ws.is_legacy_fallback:
_emit_stderr(f"WARNING: {_LEGACY_WORKSPACE_WARNING}\n")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if inline and not existing_ws.is_inline:
return (
return existing_ws, (
f"Standalone workspace already exists at {existing_ws.workspace_file}. "
"Cannot create inline workspace — this would create parallel configs. "
"Delete the standalone file first, then retry with --inline."
)
if not inline and existing_ws.is_inline:
return (
return existing_ws, (
"Inline workspace already exists in core.toml. "
"Cannot create standalone workspace — this would create parallel configs. "
"Remove the [workspace] section from core.toml first, then retry."
)
if not force:
config_loc = "core.toml" if existing_ws.is_inline else str(existing_ws.workspace_file)
return (
return existing_ws, (
f"Workspace already exists ({config_loc}). "
"Use --force to reinitialize (this will overwrite existing workspace config)."
)
return existing_ws, None


def _workspace_hint_path(project_root: Path, output_path: Path) -> str:
"""Format a workspace path for the config/core.toml workspace hint."""
rel = output_path.relative_to(project_root) if output_path.is_relative_to(project_root) else output_path
return rel.as_posix()


def _replace_legacy_workspace_marker(
canonical_path: Path,
workspace_data: dict,
existing_ws: Optional["WorkspaceConfig"],
canonical_filename: str,
) -> Optional[dict]:
"""Replace automatic legacy fallback marker when writing its canonical sibling."""
if (
existing_ws is None
or not existing_ws.is_legacy_fallback
or existing_ws.workspace_file is None
):
return None
legacy_path = existing_ws.workspace_file.resolve()
if canonical_path != legacy_path.with_name(canonical_filename):
return None
try:
legacy_path.unlink()
except OSError as exc:
rollback_exc = None
try:
canonical_path.unlink()
except OSError as rollback_error:
rollback_exc = rollback_error
rollback_suffix = (
f" Rollback also failed for {canonical_path}: {rollback_exc}."
if rollback_exc is not None
else f" The new canonical marker at {canonical_path} was rolled back."
)
return {
"status": "ERROR",
"message": (
f"Failed to replace legacy automatic fallback marker {legacy_path}: {exc}."
f"{rollback_suffix} Resolve the marker collision before retrying."
),
"config_path": str(canonical_path),
"workspace": workspace_data,
"sources_count": len(workspace_data.get("sources", {})),
"sources": list(workspace_data.get("sources", {}).keys()),
}
return None

# @cpt-flow:cpt-studio-flow-workspace-init:p1
Expand Down Expand Up @@ -337,6 +409,7 @@ def _write_workspace_config(
project_root: Path,
scan_root: Path,
workspace_data: dict,
existing_ws: Optional["WorkspaceConfig"] = None,
) -> tuple:
"""Execute workspace write (inline or standalone). Returns (exit_code, data)."""
from ..constants import WORKSPACE_CONFIG_FILENAME
Expand All @@ -353,11 +426,21 @@ def _write_workspace_config(
# @cpt-begin:cpt-studio-state-workspace-config-lifecycle:p1:inst-config-reinit-standalone
output_path = Path(output_arg).resolve() if output_arg else (scan_root / WORKSPACE_CONFIG_FILENAME)
exit_code, data = _write_standalone(output_path, workspace_data)
if output_arg and not exit_code:
rel = output_path.relative_to(project_root) if output_path.is_relative_to(project_root) else output_path
canonical_path = output_path.resolve()
if not exit_code:
replace_error = _replace_legacy_workspace_marker(
canonical_path,
workspace_data,
existing_ws,
WORKSPACE_CONFIG_FILENAME,
)
if replace_error is not None:
return 1, replace_error
project_root_canonical_path = (project_root / WORKSPACE_CONFIG_FILENAME).resolve()
if output_arg and not exit_code and canonical_path != project_root_canonical_path:
data["hint"] = (
f"Custom output path used. Other commands will not discover this file automatically. "
f'Add \'workspace = "{rel}"\' to config/core.toml to enable discovery.'
"Custom output path used. Automatic discovery requires "
f'workspace = "{_workspace_hint_path(project_root, output_path)}" in config/core.toml.'
)
# @cpt-end:cpt-studio-state-workspace-config-lifecycle:p1:inst-config-reinit-standalone
# @cpt-end:cpt-studio-flow-workspace-init:p1:inst-else-standalone
Expand Down Expand Up @@ -460,14 +543,14 @@ def _positive_int(value: str) -> int:

# @cpt-begin:cpt-studio-flow-workspace-init:p1:inst-if-existing-ws
# Check for existing workspace — prevent parallel configs and accidental overwrites
conflict_err = _check_existing_workspace(project_root, inline=args.inline, force=args.force)
existing_ws, conflict_err = _check_existing_workspace(project_root, inline=args.inline, force=args.force)
if conflict_err:
ui.result({"status": "ERROR", "message": conflict_err})
return 1
# @cpt-end:cpt-studio-flow-workspace-init:p1:inst-if-existing-ws

exit_code, data = _write_workspace_config(
args.inline, args.output, project_root, scan_root, workspace_data,
args.inline, args.output, project_root, scan_root, workspace_data, existing_ws,
)

# @cpt-begin:cpt-studio-flow-workspace-init:p1:inst-return-init-ok
Expand Down
25 changes: 18 additions & 7 deletions skills/studio/scripts/studio/utils/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ class WorkspaceConfig:
workspace_file: Optional[Path] = None # Absolute path to the workspace file
is_inline: bool = False # True if loaded from core.toml inline workspace
resolution_base: Optional[Path] = None # Override for source path resolution base directory
is_legacy_fallback: bool = False # True only for automatic legacy-file discovery

@classmethod
def from_dict(
Expand Down Expand Up @@ -544,7 +545,8 @@ def find_workspace_config(project_root: Path) -> Tuple[Optional[WorkspaceConfig]
1. 'workspace' key in project config (core.toml via AGENTS.md):
- If string: treat as path to external workspace file
- If dict: treat as inline workspace definition
2. Standalone .cf-workspace.toml at project_root
2. Standalone .cf-workspace.toml at project_root, with
.studio-workspace.toml as a compatibility fallback

Args:
project_root: The project root directory.
Expand Down Expand Up @@ -603,20 +605,29 @@ def _find_standalone_workspace(
) -> Tuple[Optional[WorkspaceConfig], Optional[str]]:
"""Fallback: discover standalone workspace TOML at project root.

Checks in order:
1. .cf-workspace.toml (canonical name)
2. .studio-workspace.toml (legacy / test-fixture name)
Checks both filenames before loading either:
1. .cf-workspace.toml (canonical name)
2. .studio-workspace.toml (legacy compatibility fallback)
"""
candidate = (project_root / WORKSPACE_CONFIG_FILENAME).resolve()
if candidate.is_file():
legacy = (project_root / _LEGACY_WORKSPACE_FILENAME).resolve()
has_canonical = candidate.is_file()
has_legacy = legacy.is_file()
if has_canonical and has_legacy:
return None, (
"Found both standalone workspace markers: "
f"{candidate} and {legacy}. Remove one or configure workspace explicitly in core.toml."
)
if has_canonical:
# @cpt-begin:cpt-studio-algo-workspace-find-config:p1:inst-find-return-standalone
ws_cfg, ws_err = WorkspaceConfig.load(candidate)
return ws_cfg, ws_err
# @cpt-end:cpt-studio-algo-workspace-find-config:p1:inst-find-return-standalone
legacy = (project_root / _LEGACY_WORKSPACE_FILENAME).resolve()
if legacy.is_file():
if has_legacy:
# @cpt-begin:cpt-studio-algo-workspace-find-config:p1:inst-find-return-standalone
ws_cfg, ws_err = WorkspaceConfig.load(legacy)
if ws_cfg is not None:
ws_cfg.is_legacy_fallback = True
return ws_cfg, ws_err
# @cpt-end:cpt-studio-algo-workspace-find-config:p1:inst-find-return-standalone
return None, None
Expand Down
Loading
Loading