From 44cf2a4ce84a40b7f1ae0d4b0553cded95e0a131 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 22:43:46 -0400 Subject: [PATCH] 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. --- docs/llm-workflow-runbooks.md | 19 +++ docs/workflow-skill-mount.md | 68 ++++++++++ gitea_mcp_server.py | 38 +++++- scripts/install-codex-workflow-skill.sh | 82 ++++++++++++ skills/gitea-workflow/SKILL.md | 37 ++++++ skills/llm-project-workflow/SKILL.md | 2 + tests/test_operator_guide.py | 4 + tests/test_workflow_skill.py | 87 +++++++++++++ workflow_skill.py | 160 ++++++++++++++++++++++++ 9 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 docs/workflow-skill-mount.md create mode 100755 scripts/install-codex-workflow-skill.sh create mode 100644 skills/gitea-workflow/SKILL.md create mode 100644 tests/test_workflow_skill.py create mode 100644 workflow_skill.py diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index d0e472d..7f43fa6 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -408,6 +408,25 @@ The guard blocks when the **control checkout** (repository root) is: `branches/...` directories are disposable role worktrees; the root checkout is the stable orchestration surface only. + +## Canonical workflow skill names (#551) + +Controller prompts and sessions must load the **same** workflow skill wall +regardless of runtime (Claude, Codex, Gemini): + +| Name | Role | +|------|------| +| `gitea-workflow` | Primary controller / Codex skill name | +| `llm-project-workflow` | Portable in-repo package | +| `git-pr-workflows` | Legacy alias | + +- Inventory: `mcp_list_project_skills` lists all three. +- Preflight: `mcp_check_workflow_skill_preflight` before mutations. +- Codex install: `scripts/install-codex-workflow-skill.sh` +- Full doc: [`docs/workflow-skill-mount.md`](workflow-skill-mount.md) + +If the skill is missing, stop with BLOCKED + DIAGNOSE — do not mutate. + ## Shell Spawn Hard-Stop Rule Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr. diff --git a/docs/workflow-skill-mount.md b/docs/workflow-skill-mount.md new file mode 100644 index 0000000..408ff0f --- /dev/null +++ b/docs/workflow-skill-mount.md @@ -0,0 +1,68 @@ +# Workflow skill mount across runtimes (#551) + +## Problem + +Controller prompts require **`gitea-workflow`**, but: + +- Claude may load `~/.claude/skills/gitea-workflow` +- Codex often has **no** `~/.codex/skills/gitea-workflow` +- The portable package in-repo is `skills/llm-project-workflow` +- `mcp_list_project_skills` historically listed operational guides only, not + the workflow router + +Sessions then either **block** incorrectly or **proceed without** the workflow +wall. + +## Canonical names (must resolve to the same skill) + +| Name | Use | +|------|-----| +| `gitea-workflow` | **Primary** controller / Codex skill name | +| `llm-project-workflow` | Portable in-repo package name | +| `git-pr-workflows` | Legacy alias | + +Source of truth: `skills/llm-project-workflow/SKILL.md` +In-repo alias stub: `skills/gitea-workflow/SKILL.md` + +## Codex install + +From a `branches/` worktree (or any clone of the repo): + +```bash +./scripts/install-codex-workflow-skill.sh +# optional: +./scripts/install-codex-workflow-skill.sh --dry-run +./scripts/install-codex-workflow-skill.sh --skills-dir "$HOME/.codex/skills" +``` + +This symlinks the portable package under all three names. **Restart Codex** +after install. + +## MCP discovery + +- `mcp_list_project_skills` includes `gitea-workflow`, `llm-project-workflow`, + and `git-pr-workflows`. +- `mcp_get_skill_guide("")` returns the same router steps for each. +- `mcp_check_workflow_skill_preflight` proves the in-repo skill file exists and + reports Codex mount status. + +## Preflight rule + +Before any git or Gitea mutation: + +1. Call `mcp_check_workflow_skill_preflight`. +2. If `blocked` / `workflow_skill_ready` is false → **BLOCKED + DIAGNOSE**; do + not mutate. +3. Load the skill by **any** canonical name and follow the router. + +Missing Codex mount alone does not block if the in-repo skill is present and +loaded via MCP/docs; operators should still install the Codex symlink so +prompt names resolve natively. + +## Controller prompts + +Prefer: + +> Invoke skill `gitea-workflow` (alias of `llm-project-workflow`). + +Do not require a name that is only available on one runtime. diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index ed650b5..b460d5d 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -827,6 +827,7 @@ import reconciliation_workflow # noqa: E402 import audit_reconciliation_mode # noqa: E402 import review_merge_state_machine # noqa: E402 import pr_work_lease # noqa: E402 +import workflow_skill # noqa: E402 import native_mcp_preference # noqa: E402 import worktree_cleanup_audit # noqa: E402 @@ -6933,6 +6934,9 @@ _PROJECT_SKILLS = { }, } +# Canonical workflow router skill names for Claude/Codex/Gemini parity (#551). +_PROJECT_SKILLS.update(workflow_skill.workflow_skill_registry_entries()) + @mcp.tool() def mcp_get_control_plane_guide( @@ -7046,7 +7050,7 @@ def mcp_get_control_plane_guide( @mcp.tool() def mcp_list_project_skills() -> dict: - """List the project's workflow skills and when to use them (#128). + """List the project's workflow skills and when to use them (#128, #551). Read-only; makes no API calls. Each skill reports its name, description, when-to-use guidance, required operations, status @@ -7054,6 +7058,9 @@ def mcp_list_project_skills() -> dict: 'operator-only'), and whether the current profile's operations permit it. Use mcp_get_skill_guide(name) for the step-by-step guide. + Includes the canonical workflow router under gitea-workflow, + llm-project-workflow, and git-pr-workflows (#551). + Returns: dict with 'read_only', 'count', and 'skills' (list). Never secrets. """ @@ -7078,10 +7085,39 @@ def mcp_list_project_skills() -> dict: } if meta.get("notes"): entry["notes"] = meta["notes"] + if meta.get("repo_path"): + entry["repo_path"] = meta["repo_path"] + if meta.get("canonical"): + entry["canonical"] = True skills.append(entry) return {"read_only": True, "count": len(skills), "skills": skills} +@mcp.tool() +def mcp_check_workflow_skill_preflight( + project_root: str | None = None, +) -> dict: + """Fail-closed preflight for the canonical workflow skill wall (#551). + + Verifies skills/llm-project-workflow/SKILL.md exists in the project tree + and reports whether Codex has a matching mount under ~/.codex/skills. + Call before git/Gitea mutations when the controller requires gitea-workflow. + + Args: + project_root: Optional override; defaults to this MCP process root. + + Returns: + dict with workflow_skill_ready, blocked, canonical_names, codex mount + status, and safe_next_action. Never secrets. + """ + root = (project_root or PROJECT_ROOT).strip() + result = workflow_skill.assess_workflow_skill_presence(root) + result["success"] = not result.get("blocked") + result["project_root"] = root + result["read_only"] = True + return result + + @mcp.tool() def mcp_get_skill_guide(skill_name: str) -> dict: """Step-by-step guide for one named project skill (#128). diff --git a/scripts/install-codex-workflow-skill.sh b/scripts/install-codex-workflow-skill.sh new file mode 100755 index 0000000..cfdafa9 --- /dev/null +++ b/scripts/install-codex-workflow-skill.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Install canonical Gitea workflow skill into Codex skills dir (#551). +# Symlinks the in-repo skills/llm-project-workflow package under the names +# gitea-workflow, llm-project-workflow, and git-pr-workflows. +set -euo pipefail + +usage() { + cat <<'EOF' +usage: scripts/install-codex-workflow-skill.sh [--dry-run] [--skills-dir DIR] + +Symlink the portable skills/llm-project-workflow package into the Codex +skills directory under the canonical names: + gitea-workflow + llm-project-workflow + git-pr-workflows + +Defaults: + skills dir: $CODEX_HOME/skills or ~/.codex/skills + source: /skills/llm-project-workflow +EOF +} + +dry_run=0 +skills_dir="" +while [[ "${1:-}" == --* ]]; do + case "$1" in + --dry-run) dry_run=1 ;; + --skills-dir) + shift + skills_dir="${1:-}" + ;; + --help|-h) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac + shift +done + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +source_skill="$repo_root/skills/llm-project-workflow" + +if [[ ! -f "$source_skill/SKILL.md" ]]; then + echo "Error: missing $source_skill/SKILL.md" >&2 + exit 1 +fi + +if [[ -z "$skills_dir" ]]; then + if [[ -n "${CODEX_HOME:-}" ]]; then + skills_dir="$CODEX_HOME/skills" + else + skills_dir="${HOME}/.codex/skills" + fi +fi + +names=(gitea-workflow llm-project-workflow git-pr-workflows) + +echo "source: $source_skill" +echo "codex skills dir: $skills_dir" + +if [[ "$dry_run" -eq 0 ]]; then + mkdir -p "$skills_dir" +fi + +for name in "${names[@]}"; do + target="$skills_dir/$name" + if [[ "$dry_run" -eq 1 ]]; then + echo "dry-run: ln -sfn $source_skill $target" + continue + fi + if [[ -e "$target" || -L "$target" ]]; then + if [[ -L "$target" ]]; then + rm -f "$target" + else + echo "Error: $target exists and is not a symlink (refuse to overwrite)" >&2 + exit 1 + fi + fi + ln -sfn "$source_skill" "$target" + echo "linked $target -> $source_skill" +done + +echo "done. Restart Codex so skills reload." diff --git a/skills/gitea-workflow/SKILL.md b/skills/gitea-workflow/SKILL.md new file mode 100644 index 0000000..0f39091 --- /dev/null +++ b/skills/gitea-workflow/SKILL.md @@ -0,0 +1,37 @@ +--- +name: gitea-workflow +description: >- + Canonical alias for the Gitea/LLM project workflow router. Same skill as + llm-project-workflow. Use at the start of any Gitea-Tools author, review, + merge, reconcile, or issue-filing task. Trigger terms: gitea-workflow, + llm-project-workflow, git-pr-workflows, gitea, PR review, work-issue. +--- + +# gitea-workflow (canonical alias) + +This directory is the **stable name** for controller prompts and Codex mounts +(`gitea-workflow`). The portable implementation lives at: + +**[`../llm-project-workflow/SKILL.md`](../llm-project-workflow/SKILL.md)** + +## Required action + +1. Open and follow `skills/llm-project-workflow/SKILL.md` (router). +2. Load the matching workflow under `skills/llm-project-workflow/workflows/`. +3. Do not mutate git/Gitea until the workflow is loaded. + +## Multi-runtime names (must resolve identically) + +| Name | Role | +|------|------| +| `gitea-workflow` | Canonical controller / Codex skill name | +| `llm-project-workflow` | Portable in-repo skill package | +| `git-pr-workflows` | Legacy alias | + +Install for Codex: + +```bash +./scripts/install-codex-workflow-skill.sh +``` + +Preflight via MCP: `mcp_check_workflow_skill_preflight`. diff --git a/skills/llm-project-workflow/SKILL.md b/skills/llm-project-workflow/SKILL.md index dc2f961..610a1f7 100644 --- a/skills/llm-project-workflow/SKILL.md +++ b/skills/llm-project-workflow/SKILL.md @@ -6,6 +6,8 @@ description: >- report schema. Use at the start of any implementation, review, merge, reconciliation, or issue-filing task. --- +> **Also known as:** `gitea-workflow`, `git-pr-workflows` (canonical multi-runtime names — see docs/workflow-skill-mount.md / #551). + # LLM Project Workflow Skill diff --git a/tests/test_operator_guide.py b/tests/test_operator_guide.py index a5159c6..550d8c6 100644 --- a/tests/test_operator_guide.py +++ b/tests/test_operator_guide.py @@ -49,6 +49,10 @@ EXPECTED_SKILLS = [ "jenkins-mcp", "glitchtip-mcp", "release-operator", + # Canonical workflow router (#551) — same skill, multi-runtime names + "gitea-workflow", + "llm-project-workflow", + "git-pr-workflows", ] diff --git a/tests/test_workflow_skill.py b/tests/test_workflow_skill.py new file mode 100644 index 0000000..78072a2 --- /dev/null +++ b/tests/test_workflow_skill.py @@ -0,0 +1,87 @@ +"""Tests for canonical workflow skill naming and preflight (#551).""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import workflow_skill +import mcp_server + + +class TestWorkflowSkillModule(unittest.TestCase): + def test_canonical_names_include_gitea_workflow(self): + names = workflow_skill.CANONICAL_WORKFLOW_SKILL_NAMES + self.assertIn("gitea-workflow", names) + self.assertIn("llm-project-workflow", names) + self.assertIn("git-pr-workflows", names) + + def test_repo_skill_present_in_this_checkout(self): + root = str(Path(__file__).resolve().parent.parent) + result = workflow_skill.assess_workflow_skill_presence(root) + self.assertTrue(result["repo_skill_present"]) + self.assertTrue(result["repo_alias_present"]) + self.assertTrue(result["workflow_skill_ready"]) + self.assertFalse(result["blocked"]) + + def test_missing_repo_skill_blocks(self): + with tempfile.TemporaryDirectory() as tmp: + result = workflow_skill.assess_workflow_skill_presence(tmp) + self.assertTrue(result["blocked"]) + self.assertFalse(result["workflow_skill_ready"]) + self.assertTrue(any("missing" in r for r in result["reasons"])) + + def test_codex_mount_detection(self): + root = str(Path(__file__).resolve().parent.parent) + with tempfile.TemporaryDirectory() as tmp: + skill_dir = Path(tmp) / "gitea-workflow" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# x\n", encoding="utf-8") + result = workflow_skill.assess_workflow_skill_presence( + root, codex_skills_dir=tmp + ) + self.assertTrue(result["codex_skill_mounted"]) + self.assertTrue(result["codex_skill_mounts"]["gitea-workflow"]) + + def test_registry_entries_cover_aliases(self): + entries = workflow_skill.workflow_skill_registry_entries() + for name in workflow_skill.CANONICAL_WORKFLOW_SKILL_NAMES: + self.assertIn(name, entries) + self.assertEqual(entries[name]["status"], "available") + + +class TestWorkflowSkillMcpTools(unittest.TestCase): + def test_list_includes_canonical_names(self): + with patch.dict( + os.environ, + { + "GITEA_PROFILE_NAME": "author-test", + "GITEA_ALLOWED_OPERATIONS": "gitea.read", + "GITEA_FORBIDDEN_OPERATIONS": "", + }, + clear=False, + ): + result = mcp_server.mcp_list_project_skills() + names = {s["name"] for s in result["skills"]} + for name in workflow_skill.CANONICAL_WORKFLOW_SKILL_NAMES: + self.assertIn(name, names) + + def test_get_skill_guide_gitea_workflow(self): + guide = mcp_server.mcp_get_skill_guide("gitea-workflow") + self.assertTrue(guide["success"]) + self.assertTrue(guide["steps"]) + self.assertIn("workflow", " ".join(guide["steps"]).lower()) + + def test_preflight_tool_ok_on_repo(self): + result = mcp_server.mcp_check_workflow_skill_preflight() + self.assertTrue(result["success"]) + self.assertTrue(result["workflow_skill_ready"]) + self.assertFalse(result["blocked"]) + self.assertIn("gitea-workflow", result["canonical_names"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/workflow_skill.py b/workflow_skill.py new file mode 100644 index 0000000..22e1551 --- /dev/null +++ b/workflow_skill.py @@ -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.", + }, + }