169 lines
7.2 KiB
Python
169 lines
7.2 KiB
Python
"""Build the native OMP package from the canonical dev-workflows checkout."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
|
|
OWNER_MARKER = ".dev-workflows-omp-package"
|
|
def _owner_value(src: Path) -> str:
|
|
return f"dev-workflows\nsource_root={src}\n"
|
|
|
|
|
|
def _content_digest(package: Path) -> str:
|
|
h = hashlib.sha256()
|
|
for path in sorted(package.rglob("*")):
|
|
if path.is_symlink():
|
|
raise FileExistsError(f"symlink em pacote gerado: {path}")
|
|
if not path.is_file() or path.name == OWNER_MARKER:
|
|
continue
|
|
h.update(path.relative_to(package).as_posix().encode("utf-8"))
|
|
h.update(b"\0")
|
|
h.update(str(path.stat().st_mode & 0o777).encode("ascii"))
|
|
with path.open("rb") as src:
|
|
for chunk in iter(lambda: src.read(1024 * 1024), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
AGENT_NAMES = (
|
|
"explorer", "implementer", "planner", "reviewer",
|
|
"security-auditor", "ui-critic", "verifier",
|
|
)
|
|
MODEL_ID = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?::(?:none|minimal|low|medium|high|xhigh|max))?$")
|
|
TOOL_NAMES = {
|
|
"Read": "read", "Edit": "edit", "Write": "write", "Bash": "bash",
|
|
"Grep": "grep", "Glob": "glob", "WebFetch": "web_search",
|
|
}
|
|
|
|
|
|
def _agent(src: Path, name: str, models: dict[str, str], package_root: Path) -> str:
|
|
text = (src / "agents" / f"{name}.md").read_text(encoding="utf-8")
|
|
match = re.match(r"\A---\s*\n(.*?)\n---\s*\n(.*)\Z", text, re.S)
|
|
if not match:
|
|
raise ValueError(f"frontmatter inválido em agents/{name}.md")
|
|
raw, prompt = match.groups()
|
|
meta: dict[str, str] = {}
|
|
for line in raw.splitlines():
|
|
key, sep, value = line.partition(":")
|
|
if sep:
|
|
meta[key.strip()] = value.strip()
|
|
if meta.get("name") != name or not meta.get("description"):
|
|
raise ValueError(f"metadados incompletos em agents/{name}.md")
|
|
|
|
source_tools = [item.strip() for item in meta.get("tools", "").split(",") if item.strip()]
|
|
# An explicit OMP allowlist prevents read-only agents from inheriting write tools.
|
|
tools = list(dict.fromkeys(TOOL_NAMES[item] for item in source_tools if item in TOOL_NAMES))
|
|
if not tools:
|
|
raise ValueError(f"nenhuma ferramenta OMP reconhecida em agents/{name}.md")
|
|
lines = ["---", f"name: {name}", f"description: {meta['description']}", "tools: " + ", ".join(tools)]
|
|
skills = re.search(r"^skills:\s*\[(.*?)\]\s*$", raw, re.M)
|
|
if skills:
|
|
values = [value.strip().strip("'\"") for value in skills.group(1).split(",") if value.strip()]
|
|
if values:
|
|
lines.append("autoloadSkills:")
|
|
lines.extend(f" - {value}" for value in values)
|
|
model = models.get(name)
|
|
if model is not None:
|
|
if not isinstance(model, str) or not MODEL_ID.fullmatch(model):
|
|
raise ValueError(f"ID de modelo inválido para {name}: {model!r}")
|
|
lines.append(f"model: {model}")
|
|
body = prompt.replace("${CLAUDE_PLUGIN_ROOT}", str(package_root))
|
|
return "\n".join(lines + ["---", "", body])
|
|
|
|
|
|
def build_package(src_root, dest, models=None):
|
|
"""Atomically build an owned OMP plugin package at *dest*.
|
|
|
|
``models`` maps canonical agent names (roles) to verified concrete
|
|
``provider/model[:thinking]`` selectors. Missing roles intentionally omit
|
|
``model`` so OMP inherits the parent model.
|
|
"""
|
|
src = Path(src_root).resolve()
|
|
target = Path(dest).expanduser().absolute()
|
|
roles = {} if models is None else dict(models)
|
|
unknown = set(roles) - set(AGENT_NAMES)
|
|
if unknown:
|
|
raise ValueError("papéis desconhecidos: " + ", ".join(sorted(unknown)))
|
|
if not (src / "agents").is_dir() or not (src / "skills").is_dir():
|
|
raise ValueError(f"checkout canônico inválido: {src}")
|
|
if target.is_symlink():
|
|
raise FileExistsError(f"destino é symlink; recusando substituir: {target}")
|
|
if target.exists():
|
|
marker = target / OWNER_MARKER
|
|
if not target.is_dir() or not marker.is_file():
|
|
raise FileExistsError(f"destino existente não pertence a dev-workflows: {target}")
|
|
if marker.read_text(encoding="utf-8") != _owner_value(src) + f"sha256={_content_digest(target)}\n":
|
|
raise FileExistsError(f"pacote existente tem alterações manuais ou origem diferente: {target}")
|
|
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
stage = Path(tempfile.mkdtemp(prefix=f".{target.name}.stage-", dir=target.parent))
|
|
backup = None
|
|
try:
|
|
skills_src = src / "skills"
|
|
skills_dest = stage / "skills"
|
|
shutil.copytree(skills_src, skills_dest, symlinks=False,
|
|
ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"))
|
|
skill_count = sum(1 for path in skills_dest.glob("*/SKILL.md") if path.is_file())
|
|
for path in skills_dest.rglob("*"):
|
|
if not path.is_file():
|
|
continue
|
|
try:
|
|
content = path.read_text(encoding="utf-8")
|
|
except UnicodeError:
|
|
continue
|
|
if "${CLAUDE_PLUGIN_ROOT}" in content:
|
|
path.write_text(content.replace("${CLAUDE_PLUGIN_ROOT}", str(target)), encoding="utf-8")
|
|
if skill_count != 21:
|
|
raise ValueError(f"esperadas 21 skills canônicas; encontradas {skill_count}")
|
|
|
|
agents_dest = stage / "agents"
|
|
agents_dest.mkdir()
|
|
for name in AGENT_NAMES:
|
|
if not (src / "agents" / f"{name}.md").is_file():
|
|
raise ValueError(f"agente canônico ausente: {name}")
|
|
(agents_dest / f"{name}.md").write_text(_agent(src, name, roles, target), encoding="utf-8")
|
|
|
|
# The quality-gate executable is a package asset; Claude hooks are deliberately
|
|
# excluded. The loop/controller remains responsible for enforcing its verdict.
|
|
gate = src / "scripts" / "quality-gate"
|
|
if gate.is_file():
|
|
(stage / "scripts").mkdir()
|
|
shutil.copy2(gate, stage / "scripts" / "quality-gate")
|
|
manifest = {
|
|
"name": "dev-workflows",
|
|
"version": "1.0.0",
|
|
"description": "Skills e agentes nativos para oh-my-pi; gate imposto pelo controlador externo.",
|
|
"omp": {"extensions": []},
|
|
}
|
|
(stage / "package.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
(stage / OWNER_MARKER).write_text(_owner_value(src) + f"sha256={_content_digest(stage)}\n", encoding="utf-8")
|
|
|
|
if target.exists():
|
|
backup = Path(tempfile.mkdtemp(prefix=f".{target.name}.backup-", dir=target.parent))
|
|
backup.rmdir()
|
|
os.replace(target, backup)
|
|
try:
|
|
os.replace(stage, target)
|
|
except BaseException:
|
|
if backup is not None:
|
|
os.replace(backup, target)
|
|
backup = None
|
|
raise
|
|
if backup is not None:
|
|
shutil.rmtree(backup)
|
|
backup = None
|
|
return target
|
|
finally:
|
|
if stage.exists():
|
|
shutil.rmtree(stage)
|
|
if backup is not None and backup.exists():
|
|
if not target.exists():
|
|
os.replace(backup, target)
|
|
else:
|
|
shutil.rmtree(backup)
|