All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
174 lines
5.8 KiB
Python
174 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = []
|
|
# ///
|
|
"""Stop hook guard: blocks Suno package output if required skills weren't invoked.
|
|
|
|
This script runs as a Claude Code Stop hook. It checks whether the assistant's
|
|
response contains a Suno-ready package (style prompt + lyrics + settings) and
|
|
verifies that suno-style-prompt-builder and suno-lyric-transformer were invoked
|
|
via the Skill tool during the conversation.
|
|
|
|
If a package is detected without prior skill invocation, the response is blocked
|
|
and Claude is instructed to invoke the missing skills.
|
|
|
|
Usage: Configure as a Stop hook in .claude/settings.local.json:
|
|
{
|
|
"hooks": {
|
|
"Stop": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "python3 path/to/pipeline-guard.py",
|
|
"timeout": 10
|
|
}]
|
|
}]
|
|
}
|
|
}
|
|
|
|
The script reads JSON from stdin (Claude Code hook input) and outputs
|
|
a JSON decision to stdout.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
|
|
|
|
def detect_suno_package(message: str) -> bool:
|
|
"""Check if the message contains a Suno-ready package."""
|
|
patterns = [
|
|
r"##\s*Style Prompt.*v\d",
|
|
r"###\s*Copy-Ready:\s*Style Prompt",
|
|
r"##\s*Copy-Ready Lyrics",
|
|
r"##\s*Your Suno Package",
|
|
r"###\s*Copy-Ready:\s*Exclude Styles",
|
|
r"\|\s*Setting\s*\|\s*Value\s*\|.*\n.*Weirdness:",
|
|
r"paste into Suno",
|
|
]
|
|
return any(re.search(p, message, re.IGNORECASE | re.MULTILINE) for p in patterns)
|
|
|
|
|
|
def _extract_tool_uses(entry: dict) -> list[dict]:
|
|
"""Walk the transcript entry structure to find all tool_use items.
|
|
|
|
Claude Code transcripts nest tool_use items inside
|
|
entry.message.content[] for assistant messages. Older structures
|
|
may place them at the top level. This helper handles both.
|
|
"""
|
|
tool_uses = []
|
|
# Top-level shapes (defensive)
|
|
if entry.get("type") == "tool_use":
|
|
tool_uses.append(entry)
|
|
if "tool_name" in entry and entry.get("tool_name"):
|
|
# Legacy/flattened shape: tool_name + tool_input
|
|
tool_uses.append({
|
|
"name": entry.get("tool_name"),
|
|
"input": entry.get("tool_input", {}),
|
|
})
|
|
# Nested shape: entry.message.content[] with items of type "tool_use"
|
|
message = entry.get("message", {})
|
|
if isinstance(message, dict):
|
|
content = message.get("content", [])
|
|
if isinstance(content, list):
|
|
for item in content:
|
|
if isinstance(item, dict) and item.get("type") == "tool_use":
|
|
tool_uses.append(item)
|
|
return tool_uses
|
|
|
|
|
|
def check_skill_invocations(transcript_path: str) -> set[str]:
|
|
"""Read the transcript and find which skills were invoked.
|
|
|
|
Checks both direct Skill tool invocations AND Agent subagent
|
|
invocations that reference skill names (for parallel execution
|
|
via the Refine Song workflow).
|
|
"""
|
|
skills = set()
|
|
skill_names_to_detect = {
|
|
"suno-style-prompt-builder",
|
|
"suno-lyric-transformer",
|
|
"suno-feedback-elicitor",
|
|
"suno-band-profile-manager",
|
|
}
|
|
if not transcript_path:
|
|
return skills
|
|
try:
|
|
with open(transcript_path, encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
for tool_use in _extract_tool_uses(entry):
|
|
name = tool_use.get("name", "")
|
|
tool_input = tool_use.get("input", {}) or {}
|
|
if name == "Skill":
|
|
skill_name = tool_input.get("skill", "")
|
|
if skill_name:
|
|
skills.add(skill_name)
|
|
elif name == "Agent":
|
|
# Agent subagent invocations that reference skill
|
|
# names (parallel skill execution pattern)
|
|
prompt = tool_input.get("prompt", "")
|
|
for sn in skill_names_to_detect:
|
|
if sn in prompt:
|
|
skills.add(sn)
|
|
return skills
|
|
except (OSError, PermissionError):
|
|
return skills
|
|
|
|
|
|
def main():
|
|
try:
|
|
input_data = json.load(sys.stdin)
|
|
except json.JSONDecodeError:
|
|
sys.exit(0)
|
|
|
|
# Prevent infinite loops
|
|
if input_data.get("stop_hook_active", False):
|
|
sys.exit(0)
|
|
|
|
message = input_data.get("last_assistant_message", "")
|
|
if not message:
|
|
sys.exit(0)
|
|
|
|
# Only check if there's a Suno package in the output
|
|
if not detect_suno_package(message):
|
|
sys.exit(0)
|
|
|
|
# Check which skills were invoked
|
|
transcript_path = input_data.get("transcript_path", "")
|
|
skills_invoked = check_skill_invocations(transcript_path)
|
|
|
|
missing = []
|
|
if "suno-style-prompt-builder" not in skills_invoked:
|
|
missing.append("suno-style-prompt-builder")
|
|
|
|
# Only require lyric transformer if lyrics are present (not instrumental)
|
|
is_instrumental = bool(re.search(r"Instrumental \(no vocals\)", message))
|
|
if "suno-lyric-transformer" not in skills_invoked and not is_instrumental:
|
|
missing.append("suno-lyric-transformer")
|
|
|
|
if missing:
|
|
output = {
|
|
"decision": "block",
|
|
"reason": (
|
|
f"PIPELINE VIOLATION: You are presenting a Suno package without "
|
|
f"invoking the required skills: {', '.join(missing)}. "
|
|
f"The formal pipeline is mandatory per Mac's creed. "
|
|
f"Invoke the missing skill(s) via the Skill tool now, "
|
|
f"then re-present the package with their validated output."
|
|
),
|
|
}
|
|
print(json.dumps(output))
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|