feat: package as a Claude Code plugin with a deterministic quality gate
Workflows move to skills/<name>/SKILL.md so the repo installs as a plugin. scripts/quality-gate runs only the checks of the requested tier on the diff and reports missing tools as unverified instead of passing them. Hooks block stopping on a failed or stale gate, guard destructive shell commands and format edited files with the project's own formatter. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
18c2304575
commit
768b5f6d83
12 files changed
+535
No files matched your search
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PreToolUse guard for shell commands: denies gate bypasses, asks before destructive git/db commands.
|
||||
|
||||
Claude Code: reads the hook JSON on stdin and prints a permissionDecision.
|
||||
opencode: `bash_guard.py --check "<command>"` prints the reason and exits 2 when the command must not run.
|
||||
"""
|
||||
import json, re, sys
|
||||
|
||||
DENY = [
|
||||
(r"(^|\s)--no-verify(\s|$)", "--no-verify pula os hooks do git. Corrija o que o hook reclamou em vez de pular."),
|
||||
(r"\bgit\s+push\b(?=.*\s(--force|-f)(\s|$))(?!.*--force-with-lease)", "push --force reescreve histórico remoto. Use --force-with-lease e só com pedido explícito do usuário."),
|
||||
(r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)[a-zA-Z]*\s+(/|~|\$HOME|/\*|\.\.)(\s|/?$)", "rm -rf na raiz, no home ou no diretório pai não roda."),
|
||||
]
|
||||
ASK = [
|
||||
(r"\bgit\s+reset\s+--hard\b", "git reset --hard descarta trabalho não commitado."),
|
||||
(r"\bgit\s+clean\s+-[a-zA-Z]*f", "git clean -f apaga arquivos não rastreados."),
|
||||
(r"\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)", "descarta todas as mudanças locais."),
|
||||
(r"\b(DROP\s+(DATABASE|TABLE|SCHEMA)|TRUNCATE\s+TABLE)\b", "comando destrutivo de banco de dados."),
|
||||
]
|
||||
|
||||
|
||||
def decide(command):
|
||||
for pattern, why in DENY:
|
||||
if re.search(pattern, command, re.IGNORECASE):
|
||||
return "deny", why
|
||||
for pattern, why in ASK:
|
||||
if re.search(pattern, command, re.IGNORECASE):
|
||||
return "ask", why
|
||||
return None, None
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 2 and sys.argv[1] == "--check":
|
||||
decision, why = decide(sys.argv[2])
|
||||
if decision: # opencode has no "ask": both block, the agent must ask the user
|
||||
print(f"[dev-workflows] {why} Peça confirmação ao usuário.")
|
||||
return 2
|
||||
return 0
|
||||
event = json.load(sys.stdin)
|
||||
decision, why = decide(event.get("tool_input", {}).get("command", ""))
|
||||
if decision:
|
||||
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": decision,
|
||||
"permissionDecisionReason": f"[dev-workflows] {why}"}}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PostToolUse: formats the edited file with the formatter the project already uses. Never fails the edit.
|
||||
|
||||
Claude Code: reads tool_input.file_path from the hook JSON on stdin.
|
||||
opencode: `post_edit.py --file <path>`.
|
||||
"""
|
||||
import json, os, shutil, subprocess, sys
|
||||
|
||||
PRETTIER_EXT = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".css", ".scss", ".html", ".vue", ".svelte", ".md", ".yaml", ".yml"}
|
||||
|
||||
|
||||
def project_root(path):
|
||||
d = os.path.dirname(os.path.abspath(path))
|
||||
while d != os.path.dirname(d):
|
||||
if any(os.path.exists(os.path.join(d, m)) for m in ("package.json", "pyproject.toml", "go.mod", "Cargo.toml", ".git")):
|
||||
return d
|
||||
d = os.path.dirname(d)
|
||||
return None
|
||||
|
||||
|
||||
def uses_ruff(root):
|
||||
if os.path.exists(os.path.join(root, "ruff.toml")) or os.path.exists(os.path.join(root, ".ruff.toml")):
|
||||
return True
|
||||
pyproject = os.path.join(root, "pyproject.toml")
|
||||
return os.path.exists(pyproject) and "[tool.ruff" in open(pyproject).read()
|
||||
|
||||
|
||||
def formatter(path, root):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
prettier = os.path.join(root, "node_modules", ".bin", "prettier")
|
||||
if ext in PRETTIER_EXT and os.path.exists(prettier):
|
||||
return [prettier, "--write", "--log-level", "warn", path]
|
||||
if ext == ".py" and shutil.which("ruff") and uses_ruff(root):
|
||||
return ["ruff", "format", "--quiet", path]
|
||||
if ext == ".go" and shutil.which("gofmt"):
|
||||
return ["gofmt", "-w", path]
|
||||
if ext == ".rs" and shutil.which("rustfmt"):
|
||||
return ["rustfmt", path]
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 2 and sys.argv[1] == "--file":
|
||||
path = sys.argv[2]
|
||||
else:
|
||||
path = json.load(sys.stdin).get("tool_input", {}).get("file_path", "")
|
||||
root = project_root(path) if path and os.path.isfile(path) else None
|
||||
cmd = formatter(path, root) if root else None
|
||||
if cmd:
|
||||
try:
|
||||
subprocess.run(cmd, cwd=root, capture_output=True, timeout=30)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stop hook: blocks "done" while uncommitted code changes lack a fresh, non-failing quality-gate report.
|
||||
|
||||
Claude Code: reads the hook JSON on stdin, prints {"decision": "block", ...} to keep the agent working.
|
||||
opencode: `stop_gate.py --check <dir>` prints the reason and exits 2 when it would block.
|
||||
Disable with DW_GATE=off.
|
||||
"""
|
||||
import json, os, subprocess, sys
|
||||
|
||||
DOC_EXT = {".md", ".mdx", ".txt", ".rst", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
|
||||
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT") or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
GATE = os.path.join(PLUGIN_ROOT, "scripts", "quality-gate")
|
||||
|
||||
|
||||
def pending_code_files(cwd):
|
||||
p = subprocess.run(["git", "status", "--porcelain", "--untracked-files=all"], cwd=cwd, capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
return None
|
||||
files = []
|
||||
for line in p.stdout.splitlines():
|
||||
path = line[3:].split(" -> ")[-1].strip('"')
|
||||
if line[:2].strip() == "D" or path.startswith(".dev-workflows/"):
|
||||
continue
|
||||
if os.path.splitext(path)[1].lower() not in DOC_EXT and os.path.isfile(os.path.join(cwd, path)):
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def reason_to_block(cwd):
|
||||
if os.environ.get("DW_GATE") == "off":
|
||||
return None
|
||||
top = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=cwd, capture_output=True, text=True)
|
||||
if top.returncode != 0:
|
||||
return None
|
||||
root = top.stdout.strip()
|
||||
files = pending_code_files(root)
|
||||
if not files:
|
||||
return None
|
||||
run = f'"{GATE}" --tier <nível do dev-router>'
|
||||
report = os.path.join(root, ".dev-workflows", "gate.json")
|
||||
if not os.path.isfile(report):
|
||||
return f"Há {len(files)} arquivo(s) de código alterados sem gate de qualidade. Rode {run} e trate o resultado antes de encerrar."
|
||||
newest = max(os.path.getmtime(os.path.join(root, f)) for f in files)
|
||||
if os.path.getmtime(report) < newest:
|
||||
return f"O código mudou depois do último gate. Rode de novo {run} antes de encerrar."
|
||||
data = json.load(open(report))
|
||||
if data.get("verdict") == "fail":
|
||||
failed = ", ".join(c["name"] for c in data.get("checks", []) if c.get("status") == "fail")
|
||||
return f"O gate falhou em: {failed}. Corrija (veja .dev-workflows/gate.json) ou explique ao usuário por que não dá para corrigir."
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 2 and sys.argv[1] == "--check":
|
||||
why = reason_to_block(sys.argv[2])
|
||||
if why:
|
||||
print(why)
|
||||
return 2
|
||||
return 0
|
||||
event = json.load(sys.stdin)
|
||||
if event.get("stop_hook_active"):
|
||||
return 0
|
||||
why = reason_to_block(event.get("cwd") or os.getcwd())
|
||||
if why:
|
||||
print(json.dumps({"decision": "block", "reason": why}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user