feat: mount canonical gitea-workflow skill for Codex and MCP inventory (Closes #551)
Expose gitea-workflow / llm-project-workflow / git-pr-workflows as one workflow router in mcp_list_project_skills, add preflight + Codex install script, and document multi-runtime skill name parity.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"""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.",
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user