"""Canonical workflow skill naming and preflight for multi-runtime agents (#551). Controller prompts and Claude historically require ``gitea-workflow``. The portable in-repo skill is ``skills/llm-project-workflow``. Codex must resolve the same canonical names so sessions do not proceed without the workflow wall. """ from __future__ import annotations import os from pathlib import Path from typing import Any # Canonical names that all runtimes (Claude, Codex, Gemini, MCP inventory) # must treat as the same workflow router skill. CANONICAL_WORKFLOW_SKILL_NAMES = ( "gitea-workflow", "llm-project-workflow", "git-pr-workflows", ) # Primary checked-in skill path (portable source of truth). REPO_SKILL_REL_PATH = "skills/llm-project-workflow/SKILL.md" # In-repo alias directory so scanners that look for skills/gitea-workflow work. REPO_ALIAS_SKILL_REL_PATH = "skills/gitea-workflow/SKILL.md" CODEX_SKILLS_ENV = "CODEX_HOME" DEFAULT_CODEX_SKILLS = os.path.expanduser("~/.codex/skills") def default_codex_skills_dir() -> str: home = (os.environ.get(CODEX_SKILLS_ENV) or "").strip() if home: return os.path.join(os.path.expanduser(home), "skills") return DEFAULT_CODEX_SKILLS def resolve_repo_skill_path(project_root: str) -> Path: return Path(project_root) / REPO_SKILL_REL_PATH def resolve_repo_alias_skill_path(project_root: str) -> Path: return Path(project_root) / REPO_ALIAS_SKILL_REL_PATH def normalize_skill_name(name: str | None) -> str: return (name or "").strip().lower().replace("_", "-") def is_canonical_workflow_skill_name(name: str | None) -> bool: return normalize_skill_name(name) in CANONICAL_WORKFLOW_SKILL_NAMES def assess_workflow_skill_presence( project_root: str, *, codex_skills_dir: str | None = None, ) -> dict[str, Any]: """Return presence proof for the canonical workflow skill. Fail-closed when the in-repo skill file is missing. Codex mount is advisory in the report (operators install via script) but ``codex_skill_mounted`` is explicit so preflight can BLOCK when required. """ root = (project_root or "").strip() reasons: list[str] = [] repo_path = resolve_repo_skill_path(root) if root else None alias_path = resolve_repo_alias_skill_path(root) if root else None repo_present = bool(repo_path and repo_path.is_file()) alias_present = bool(alias_path and alias_path.is_file()) if not root: reasons.append("project_root not provided (fail closed)") elif not repo_present: reasons.append( f"canonical workflow skill missing at {REPO_SKILL_REL_PATH} " "(fail closed — do not mutate without workflow wall)" ) codex_dir = Path(codex_skills_dir or default_codex_skills_dir()) codex_mounts: dict[str, bool] = {} for name in CANONICAL_WORKFLOW_SKILL_NAMES: skill_md = codex_dir / name / "SKILL.md" codex_mounts[name] = skill_md.is_file() codex_any = any(codex_mounts.values()) blocked = bool(reasons) return { "canonical_names": list(CANONICAL_WORKFLOW_SKILL_NAMES), "primary_name": "gitea-workflow", "portable_name": "llm-project-workflow", "repo_skill_rel_path": REPO_SKILL_REL_PATH, "repo_skill_present": repo_present, "repo_alias_rel_path": REPO_ALIAS_SKILL_REL_PATH, "repo_alias_present": alias_present, "codex_skills_dir": str(codex_dir), "codex_skill_mounts": codex_mounts, "codex_skill_mounted": codex_any, "workflow_skill_ready": repo_present, "blocked": blocked, "block_reason": reasons[0] if reasons else None, "reasons": reasons, "safe_next_action": ( "Install or repair skills/llm-project-workflow/SKILL.md in the " "repo; run scripts/install-codex-workflow-skill.sh for Codex; " "call mcp_list_project_skills and load gitea-workflow / " "llm-project-workflow before any git/Gitea mutation." if blocked or not codex_any else "Load gitea-workflow or llm-project-workflow, then proceed." ), } def workflow_skill_registry_entries() -> dict[str, dict[str, Any]]: """Shared skill metadata for mcp_list_project_skills / guides.""" shared_steps = [ "Call mcp_list_project_skills and confirm gitea-workflow (or " "llm-project-workflow) is listed.", "Call mcp_check_workflow_skill_preflight and stop if blocked.", "Load skills/llm-project-workflow/SKILL.md (or the gitea-workflow " "alias) and route to the matching workflow file for the task mode.", "Do not perform git or Gitea mutations until the workflow is loaded.", "Codex: ensure ~/.codex/skills/gitea-workflow -> repo skill via " "scripts/install-codex-workflow-skill.sh.", ] notes = ( f"Canonical names: {', '.join(CANONICAL_WORKFLOW_SKILL_NAMES)}. " f"Portable source: {REPO_SKILL_REL_PATH}. " "Controller prompts may say gitea-workflow; Codex and MCP inventory " "must resolve the same skill." ) base = { "description": ( "Canonical Gitea/LLM project workflow router: pick task mode, " "load the matching workflow, enforce isolation, emit the final " "report schema." ), "when_to_use": ( "At the start of every author, review, merge, reconcile, or " "issue-filing session — before any mutation." ), "required_operations": ["gitea.read"], "status": "available", "notes": notes, "steps": shared_steps, "repo_path": REPO_SKILL_REL_PATH, "canonical": True, } return { "gitea-workflow": dict(base), "llm-project-workflow": dict(base), "git-pr-workflows": { **dict(base), "description": ( "Legacy alias for the canonical Gitea/LLM project workflow " "router (same as gitea-workflow / llm-project-workflow)." ), "notes": notes + " Prefer gitea-workflow in new prompts.", }, }