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:
2026-07-08 22:43:46 -04:00
parent 9a2e585a9e
commit 44cf2a4ce8
9 changed files with 496 additions and 1 deletions
+19
View File
@@ -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.
+68
View File
@@ -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("<name>")` 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.
+37 -1
View File
@@ -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).
+82
View File
@@ -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: <repo>/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."
+37
View File
@@ -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`.
+2
View File
@@ -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
+4
View File
@@ -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",
]
+87
View File
@@ -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()
+160
View File
@@ -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.",
},
}