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
4 changes: 0 additions & 4 deletions .bootstrap/config/core.toml
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,6 @@ examples = "feature_example"
template = "prd_template"
examples = "prd_example"

[kits.sdlc.resources.skill]
path = "config/kits/sdlc/SKILL.md"
kind = "skill"

[kits.sdlc.resources.agents]
path = "config/kits/sdlc/AGENTS.md"
kind = "rule"
Expand Down
129 changes: 82 additions & 47 deletions skills/studio/scripts/studio/commands/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -1707,15 +1707,6 @@
"skills": {
"custom_content": "",
"outputs": shared_skills + [
{
"path": ".github/copilot-instructions.md",
"template": [
"# Constructor Studio",
_GENERATED_MARKER,
"",
"{custom_content}",
],
},
{
"path": ".github/prompts/cf.prompt.md",
"template": [
Expand Down Expand Up @@ -2471,17 +2462,8 @@
if _file_has_studio_follow_link(project_root / ".cursor" / "rules" / "studio.mdc"):
return True

legacy_ci = project_root / ".github" / "copilot-instructions.md"
if legacy_ci.is_file():
try:
ci_text = legacy_ci.read_text(encoding="utf-8")
if ci_text.startswith("# Constructor Studio") or ci_text.startswith("# Studio"):
return True
except (OSError, UnicodeDecodeError):
pass

return (
(project_root / ".github" / "prompts" / "cf.prompt.md").is_file()

Check failure on line 2466 in skills/studio/scripts/studio/commands/agents.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal ".github" 5 times.

See more on https://sonarcloud.io/project/issues?id=constructorfabric_studio&issues=AZ7ay1RSZ_CmmH4fTwDh&open=AZ7ay1RSZ_CmmH4fTwDh&pullRequest=49
or (project_root / ".github" / "prompts" / "studio.prompt.md").is_file()
)
# @cpt-end:cpt-studio-algo-agent-integration-discover-agents:p1:inst-non-openai-install-signal
Expand Down Expand Up @@ -2597,18 +2579,8 @@
return True

# ── Legacy Copilot fallback ───────────────────────────────────────────
# A Constructor Studio-managed copilot-instructions.md (starts with
# "# Constructor Studio" or legacy "# Studio") is a valid signal from
# pre-marker installs. Also detect via prompts file.
# Detect via prompts files and legacy install markers only.
if agent == "copilot":
legacy_ci = project_root / ".github" / "copilot-instructions.md"
if legacy_ci.is_file():
try:
ci_text = legacy_ci.read_text(encoding="utf-8")
if ci_text.startswith("# Constructor Studio") or ci_text.startswith("# Studio"):
return True
except (OSError, UnicodeDecodeError):
pass
for prompt_name in ("cf.prompt.md", "studio.prompt.md", "cypilot.prompt.md"):
if (project_root / ".github" / "prompts" / prompt_name).is_file():
return True
Expand Down Expand Up @@ -3381,21 +3353,6 @@
continue
out_path, rel_path, content = rendered

# Guard: skip overwriting user-authored copilot-instructions.md.
# The file is only Constructor Studio-managed when it starts with "# Constructor Studio" or legacy "# Studio".
if rel_path == ".github/copilot-instructions.md" and out_path.is_file():
try:
existing = out_path.read_text(encoding="utf-8")
except OSError:
existing = ""
if not existing.startswith("# Constructor Studio") and not existing.startswith("# Studio"):
rel = _safe_relpath(out_path.resolve(), project_root)
skills_result["skipped"].append(
f"{rel} (user-authored, not overwriting)"
)
skills_result["_copilot_user_authored"] = True
continue

_write_or_skip(out_path, content, skills_result, project_root, dry_run)

return skills_result
Expand Down Expand Up @@ -3798,6 +3755,85 @@
return {"agents": public_agents, "rules": public_rules}


def _collect_managed_result_paths(

Check failure on line 3758 in skills/studio/scripts/studio/commands/agents.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 31 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=constructorfabric_studio&issues=AZ7ay1RSZ_CmmH4fTwDi&open=AZ7ay1RSZ_CmmH4fTwDi&pullRequest=49
project_root: Path,
section: Dict[str, Any],
) -> Set[str]:
"""Collect normalized project-relative output paths from a generator result."""
paths: Set[str] = set()
for key in ("created", "updated", "unchanged", "deleted"):
values = section.get(key, [])
if not isinstance(values, list):
continue
for value in values:
if isinstance(value, tuple):
candidates = [value[-1]]
else:
candidates = [value]
for candidate in candidates:
if not isinstance(candidate, str) or not candidate.strip():
continue
candidate_path = Path(candidate)
if candidate_path.is_absolute():
paths.add(_safe_relpath(candidate_path, project_root))
else:
paths.add(candidate.replace("\\", "/").strip("/"))
Comment on lines +3768 to +3780

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard against empty tuples to prevent IndexError.

If an empty tuple is passed as a value, value[-1] at line 3770 will raise an IndexError. While generators likely don't produce empty tuples, adding a guard improves robustness.

🛡️ Proposed fix
             for value in values:
-                if isinstance(value, tuple):
+                if isinstance(value, tuple) and value:
                     candidates = [value[-1]]
                 else:
                     candidates = [value]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for value in values:
if isinstance(value, tuple):
candidates = [value[-1]]
else:
candidates = [value]
for candidate in candidates:
if not isinstance(candidate, str) or not candidate.strip():
continue
candidate_path = Path(candidate)
if candidate_path.is_absolute():
paths.add(_safe_relpath(candidate_path, project_root))
else:
paths.add(candidate.replace("\\", "/").strip("/"))
for value in values:
if isinstance(value, tuple) and value:
candidates = [value[-1]]
else:
candidates = [value]
for candidate in candidates:
if not isinstance(candidate, str) or not candidate.strip():
continue
candidate_path = Path(candidate)
if candidate_path.is_absolute():
paths.add(_safe_relpath(candidate_path, project_root))
else:
paths.add(candidate.replace("\\", "/").strip("/"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/studio/scripts/studio/commands/agents.py` around lines 3768 - 3780,
The code accesses value[-1] without checking if the tuple is empty, which will
raise an IndexError if an empty tuple is passed as a value. Add a guard
condition in the isinstance(value, tuple) check to ensure the tuple is not empty
before attempting to access its last element with value[-1]. Update the
condition to verify both that value is a tuple and that it contains at least one
element before proceeding to access value[-1].

for output in section.get("outputs", []):
if not isinstance(output, dict):
continue
raw_path = output.get("path")
if not isinstance(raw_path, str) or not raw_path.strip():
continue
candidate_path = Path(raw_path)
if candidate_path.is_absolute():
paths.add(_safe_relpath(candidate_path, project_root))
else:
paths.add(raw_path.replace("\\", "/").strip("/"))
return {path for path in paths if path}


def list_managed_agent_output_paths(

Check failure on line 3795 in skills/studio/scripts/studio/commands/agents.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 25 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=constructorfabric_studio&issues=AZ7ay1RSZ_CmmH4fTwDj&open=AZ7ay1RSZ_CmmH4fTwDj&pullRequest=49
project_root: Path,
studio_root: Path,
) -> List[str]:
"""Return exact CFS-managed agent integration paths for the current install."""
cfg = _default_agents_config()
managed_paths: Set[str] = set()

for marker_path, _marker_content in _INSTALL_MARKERS.values():
managed_paths.add(marker_path)

for agent in _ALL_RECOGNIZED_AGENTS:
agent_cfg = cfg.get("agents", {}).get(agent, {})
skills_cfg = agent_cfg.get("skills", {}) if isinstance(agent_cfg, dict) else {}
outputs = skills_cfg.get("outputs") if isinstance(skills_cfg, dict) else None
if isinstance(outputs, list):
for output in outputs:
if not isinstance(output, dict):
continue
rel_path = output.get("path")
if isinstance(rel_path, str) and rel_path.strip():
managed_paths.add(rel_path.replace("\\", "/").strip("/"))
sections: List[Dict[str, Any]] = []
sections.append(_process_workflows(agent, project_root, studio_root, cfg, None, dry_run=True))
sections.append(_process_skills(agent, project_root, studio_root, cfg, None, dry_run=True))
sections.append(_process_kit_workflow_skills(agent, project_root, studio_root, cfg, None, dry_run=True))
sections.append(_process_subagents(agent, project_root, studio_root, cfg, None, dry_run=True))
public_sections = _process_kit_public_agents_and_rules(
agent,
project_root,
studio_root,
dry_run=True,
)
sections.append(public_sections.get("agents", {}))
sections.append(public_sections.get("rules", {}))
for section in sections:
if isinstance(section, dict):
managed_paths.update(_collect_managed_result_paths(project_root, section))

return sorted(path for path in managed_paths if path)


def _process_legacy_cleanup(
agent: str,
project_root: Path,
Expand Down Expand Up @@ -3924,9 +3960,8 @@
# @cpt-begin:cpt-studio-algo-agent-integration-generate-shims:p1:inst-write-install-marker
# Tools that share generic directories need a unique Constructor Studio-specific
# marker so detection/regeneration can distinguish Constructor Studio installs from
# unrelated user files. The marker is always created when the agent is
# processed — even if copilot-instructions.md was preserved as user-
# authored, the other generated outputs (prompts, shared skills, agents)
# unrelated user files. The marker is always created when the agent is
# processed because the other generated outputs (prompts, shared skills, agents)
# still need to be managed by future `cfs update` runs.
marker_info = _INSTALL_MARKERS.get(agent)
if marker_info and not dry_run:
Expand Down
58 changes: 8 additions & 50 deletions skills/studio/scripts/studio/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ..utils.artifacts_meta import create_backup, generate_default_registry, generate_slug
from ..utils import toml_utils
from ..utils.ui import ui
from .agents import list_managed_agent_output_paths

# Cache-managed install content: core directories go into .core/, root files go into the install root.
# Full directories (copied entirely)
Expand Down Expand Up @@ -360,6 +361,7 @@ def _ignored_kit_paths(core_toml_path: Path, default: str = "tracked") -> List[s


def _gitignore_patterns(
project_root: Path,
install_dir: str,
ignored_kit_paths: List[str],
runtime_tracking: str = "ignored",
Expand All @@ -373,56 +375,9 @@ def _gitignore_patterns(
f"{install_rel}/{GEN_SUBDIR}/",
])
if agent_tracking == "ignored":
patterns.extend([
".agents/skills/cf/",
".agents/skills/cf-*/",
".agents/skills/studio-*/",
".agents/skills/cypilot-*/",
".agents/skills/cf-constructor-*/",
".codex/agents/cf*.toml",
".codex/agents/studio-*.toml",
".codex/agents/cypilot-*.toml",
".codex/agents/cf-constructor-*.toml",
".codex/agents/storytelling-*.toml",
".codex/.cf-installed",
".codex/.constructor-studio-installed",
".claude/skills/cf/",
".claude/skills/cf-*/",
".claude/commands/cf*.md",
".claude/commands/studio-*.md",
".claude/commands/cypilot-*.md",
".claude/commands/cf-constructor-*.md",
".claude/agents/cf*.md",
".claude/agents/studio-*.md",
".claude/agents/cypilot-*.md",
".claude/agents/cf-constructor-*.md",
".claude/agents/storytelling-*.md",
".cursor/commands/cf*.md",
".cursor/commands/studio-*.md",
".cursor/commands/cypilot-*.md",
".cursor/commands/cf-constructor-*.md",
".cursor/agents/cf*.md",
".cursor/agents/studio-*.md",
".cursor/agents/cypilot-*.md",
".cursor/agents/cf-constructor-*.md",
".cursor/agents/storytelling-*.md",
".github/prompts/cf*.prompt.md",
".github/prompts/studio-*.prompt.md",
".github/prompts/cypilot-*.prompt.md",
".github/prompts/cf-constructor-*.prompt.md",
".github/agents/cf*.md",
".github/agents/studio-*.md",
".github/agents/cypilot-*.md",
".github/agents/cf-constructor-*.md",
".github/agents/storytelling-*.md",
".github/.cf-installed",
".github/.constructor-studio-installed",
".github/copilot-instructions.md",
".windsurf/workflows/cf*.md",
".windsurf/workflows/studio-*.md",
".windsurf/workflows/cypilot-*.md",
".windsurf/workflows/cf-constructor-*.md",
])
patterns.extend(
list_managed_agent_output_paths(project_root, project_root / install_rel)
)
for kit_path in ignored_kit_paths:
kit_rel = kit_path.strip().replace("\\", "/").strip("/")
if kit_rel:
Expand All @@ -431,6 +386,7 @@ def _gitignore_patterns(


def _compute_gitignore_block(
project_root: Path,
install_dir: str,
ignored_kit_paths: List[str],
runtime_tracking: str = "ignored",
Expand All @@ -442,6 +398,7 @@ def _compute_gitignore_block(
"# Generated Constructor Studio runtime and agent integration files.",
"# Files matched here are owned by Constructor Studio and may be overwritten.",
*_gitignore_patterns(
project_root,
install_dir,
ignored_kit_paths,
runtime_tracking=runtime_tracking,
Expand All @@ -462,6 +419,7 @@ def _write_gitignore_block(
) -> str:
gitignore_path = project_root / ".gitignore"
expected_block = _compute_gitignore_block(
project_root,
install_dir,
_ignored_kit_paths(core_toml_path, default=default_kit_tracking),
runtime_tracking=_read_install_tracking(core_toml_path, "runtime_tracking", default="ignored"),
Expand Down
50 changes: 13 additions & 37 deletions skills/studio/scripts/studio/commands/kit.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,7 @@ def _is_registered_kit_path_absolute(registered_kit_path: str) -> bool:
_is_posix_absolute_path(registered_kit_path)
or _is_windows_absolute_path(registered_kit_path)
)
# @cpt-end:cpt-studio-algo-kit-content-mgmt:p1:inst-collect-metadata-fn


# @cpt-begin:cpt-studio-algo-kit-manifest-install:p1:inst-manifest-persist-relative-only
Expand Down Expand Up @@ -916,32 +917,6 @@ def _registered_slug_for_local_kit_path(
return matches[0] if len(matches) == 1 else ""


def _collect_kit_metadata(
config_kit_dir: Optional[Path],
kit_slug: str,
registered_kit_path: Optional[str] = None,
) -> Dict[str, str]:
"""Read installed kit files and return metadata for .gen/ aggregation.

Returns dict with:
agents_content — raw content of kit's AGENTS.md for ``.gen/AGENTS.md``
"""
# @cpt-begin:cpt-studio-algo-kit-content-mgmt:p1:inst-collect-metadata
del kit_slug, registered_kit_path
result: Dict[str, str] = {"skill_nav": "", "agents_content": ""}

agents_path = config_kit_dir / _KIT_AGENTS_FILE if config_kit_dir is not None else None
if agents_path is not None and agents_path.is_file():
try:
result["agents_content"] = agents_path.read_text(encoding="utf-8")
except OSError:
pass

return result
# @cpt-end:cpt-studio-algo-kit-content-mgmt:p1:inst-collect-metadata
# @cpt-end:cpt-studio-algo-kit-content-mgmt:p1:inst-collect-metadata-fn


def _binding_is_public_metadata_resource(binding: Dict[str, Any], kind: str) -> bool:
public_value = binding.get("public")
if isinstance(public_value, bool):
Expand All @@ -951,6 +926,7 @@ def _binding_is_public_metadata_resource(binding: Dict[str, Any], kind: str) ->
return kind in {"skill", "rule"}


# @cpt-begin:cpt-studio-algo-kit-content-mgmt:p1:inst-collect-metadata
def _collect_registered_kit_metadata(
studio_dir: Path,
kit_slug: str,
Expand Down Expand Up @@ -996,12 +972,7 @@ def _collect_registered_kit_metadata(
except (OSError, ValueError):
resources = {}
if not isinstance(resources, dict) or not resources:
kit_dir, kit_rel_path = _resolve_registered_kit_metadata_target(
studio_dir,
kit_slug,
kit_entry,
)
return _collect_kit_metadata(kit_dir, kit_slug, kit_rel_path)
return {"skill_nav": "", "agents_content": ""}

result: Dict[str, str] = {"skill_nav": "", "agents_content": ""}
agents_parts: List[str] = []
Expand Down Expand Up @@ -1034,6 +1005,7 @@ def _collect_registered_kit_metadata(
pass
result["agents_content"] = "\n\n".join(part for part in agents_parts if part)
return result
# @cpt-end:cpt-studio-algo-kit-content-mgmt:p1:inst-collect-metadata


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -4240,19 +4212,23 @@ def _sync_manifest_resource_bindings(
config_dir: Path,
kit_slug: str,
) -> Optional[Dict[str, Dict[str, Any]]]:
"""Merge existing resource bindings with any new manifest resources.
"""Sync resource bindings to the current manifest declaration set.

Returns merged bindings dict, or None if there is no manifest.
Returns synced bindings dict, or None if there is no manifest.
"""
if manifest is None:
return None
existing_raw = _read_kits_from_core_toml(config_dir).get(kit_slug, {}).get("resources", {})
merged: Dict[str, Dict[str, Any]] = {}
existing: Dict[str, Dict[str, Any]] = {}
for res_id, binding in existing_raw.items():
if isinstance(binding, dict):
merged[res_id] = binding
existing[res_id] = dict(binding)
elif isinstance(binding, str):
merged[res_id] = {"path": binding}
existing[res_id] = {"path": binding}
merged: Dict[str, Dict[str, Any]] = {}
for res in getattr(manifest, "resources", []):
if getattr(res, "id", None) in existing:
merged[str(res.id)] = dict(existing[str(res.id)])
kit_root_rel = _resolve_manifest_kit_root_rel(manifest, merged, kit_slug)
for res in getattr(manifest, "resources", []):
if res.id not in merged:
Expand Down
Loading
Loading