feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
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
This commit is contained in:
84
.agents/skills/suno-agent-band-manager/scripts/check-memory-health.py
Executable file
84
.agents/skills/suno-agent-band-manager/scripts/check-memory-health.py
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Checks memory file sizes and recommends maintenance.
|
||||
|
||||
Usage:
|
||||
python3 scripts/check-memory-health.py <sidecar-path> [-o OUTPUT]
|
||||
python3 scripts/check-memory-health.py --help
|
||||
|
||||
Arguments:
|
||||
sidecar-path Path to the sidecar memory directory
|
||||
|
||||
Options:
|
||||
-o, --output Write JSON output to file instead of stdout
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Thresholds in characters
|
||||
THRESHOLDS = {
|
||||
"index.md": 3000,
|
||||
"patterns.md": 5000,
|
||||
"chronology.md": 8000,
|
||||
}
|
||||
|
||||
|
||||
def check_health(sidecar_path: Path) -> dict:
|
||||
"""Check memory file sizes and flag maintenance needs."""
|
||||
files = {}
|
||||
needs_pruning = []
|
||||
|
||||
for name, threshold in THRESHOLDS.items():
|
||||
file_path = sidecar_path / name
|
||||
if file_path.exists():
|
||||
size = len(file_path.read_text())
|
||||
files[name] = {"size_chars": size, "threshold": threshold, "over_threshold": size > threshold}
|
||||
if size > threshold:
|
||||
needs_pruning.append(name)
|
||||
else:
|
||||
files[name] = {"exists": False}
|
||||
|
||||
return {
|
||||
"sidecar_path": str(sidecar_path),
|
||||
"files": files,
|
||||
"needs_pruning": needs_pruning,
|
||||
"maintenance_recommended": len(needs_pruning) > 0,
|
||||
"recommendation": (
|
||||
f"Files exceeding size thresholds: {', '.join(needs_pruning)}. "
|
||||
"Consider condensing verbose entries and archiving old content."
|
||||
if needs_pruning
|
||||
else "Memory files are within healthy size limits."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Check memory file health")
|
||||
parser.add_argument("sidecar_path", help="Path to sidecar memory directory")
|
||||
parser.add_argument("-o", "--output", help="Output file path")
|
||||
args = parser.parse_args()
|
||||
|
||||
sidecar = Path(args.sidecar_path)
|
||||
if not sidecar.exists():
|
||||
result = {"error": True, "message": f"Sidecar directory not found: {sidecar}"}
|
||||
else:
|
||||
result = check_health(sidecar)
|
||||
|
||||
output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
print(f"Results written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.exit(0)
|
||||
173
.agents/skills/suno-agent-band-manager/scripts/pipeline-guard.py
Normal file
173
.agents/skills/suno-agent-band-manager/scripts/pipeline-guard.py
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/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()
|
||||
273
.agents/skills/suno-agent-band-manager/scripts/pre-activate.py
Executable file
273
.agents/skills/suno-agent-band-manager/scripts/pre-activate.py
Executable file
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Pre-activation script for Band Manager agent.
|
||||
|
||||
Checks first-run status, scaffolds sidecar directory if needed, and
|
||||
renders the capability menu from module-help.csv.
|
||||
|
||||
Usage:
|
||||
python3 scripts/pre-activate.py <project-root> [--scaffold] [-o OUTPUT]
|
||||
python3 scripts/pre-activate.py --help
|
||||
|
||||
Arguments:
|
||||
project-root Project root directory path
|
||||
|
||||
Options:
|
||||
--scaffold Create sidecar directory and static files if missing
|
||||
-o, --output Write JSON output to file instead of stdout
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
AGENT_SKILL_NAME = "suno-agent-band-manager"
|
||||
SETUP_SKILL_NAME = "suno-setup"
|
||||
MODULE_CODE = "Suno Band Manager"
|
||||
VOICE_FILE_PREFIX = "voice-context-"
|
||||
VOICE_FILE_SUFFIX = ".md"
|
||||
|
||||
|
||||
def normalize_username(name: str) -> str:
|
||||
"""Normalize a user name for use in filenames: lowercase, spaces to hyphens."""
|
||||
return name.strip().lower().replace(" ", "-")
|
||||
|
||||
|
||||
def detect_voice_files(project_root: Path, user_name: str | None) -> dict:
|
||||
"""Detect voice/context files in the docs/ directory.
|
||||
|
||||
Scans for files matching voice-context-*.md and checks if one matches
|
||||
the current user_name from config.
|
||||
|
||||
Returns:
|
||||
Dict with voice_files (list of relative paths), matched_file
|
||||
(relative path or None), and normalized user_name.
|
||||
"""
|
||||
docs_dir = project_root / "docs"
|
||||
result: dict = {
|
||||
"voice_files": [],
|
||||
"matched_file": None,
|
||||
"expected_filename": None,
|
||||
}
|
||||
|
||||
if user_name:
|
||||
normalized = normalize_username(user_name)
|
||||
result["expected_filename"] = f"{VOICE_FILE_PREFIX}{normalized}{VOICE_FILE_SUFFIX}"
|
||||
|
||||
if not docs_dir.is_dir():
|
||||
return result
|
||||
|
||||
for path in sorted(docs_dir.glob(f"{VOICE_FILE_PREFIX}*{VOICE_FILE_SUFFIX}")):
|
||||
rel_path = str(path.relative_to(project_root))
|
||||
result["voice_files"].append(rel_path)
|
||||
if result["expected_filename"] and path.name == result["expected_filename"]:
|
||||
result["matched_file"] = rel_path
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def detect_sync_package(project_root: Path) -> dict:
|
||||
"""Check for a portable-sync archive to unpack.
|
||||
|
||||
Checks docs/ first (canonical location), then project root (backward compat).
|
||||
|
||||
Returns:
|
||||
Dict with found (bool) and path (relative path or None).
|
||||
"""
|
||||
for rel_path in ("docs/portable-sync.tar.gz", "portable-sync.tar.gz"):
|
||||
if (project_root / rel_path).is_file():
|
||||
return {"found": True, "path": rel_path}
|
||||
return {"found": False, "path": None}
|
||||
|
||||
|
||||
def check_first_run(project_root: Path) -> bool:
|
||||
"""Check if sidecar memory directory exists."""
|
||||
sidecar = project_root / "_bmad" / "_memory" / "band-manager-sidecar"
|
||||
return not sidecar.exists()
|
||||
|
||||
|
||||
def scaffold_sidecar(project_root: Path) -> dict:
|
||||
"""Create sidecar directory and static files."""
|
||||
sidecar = project_root / "_bmad" / "_memory" / "band-manager-sidecar"
|
||||
sidecar.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
created = []
|
||||
|
||||
# access-boundaries.md - static template.
|
||||
# Paths are all relative to project root — validate-path.py resolves them
|
||||
# against project-root at parse time. Bare relative paths keep the file
|
||||
# portable across machines (no user-specific absolute paths embedded).
|
||||
ab_path = sidecar / "access-boundaries.md"
|
||||
if not ab_path.exists():
|
||||
ab_path.write_text(
|
||||
"# Access Boundaries for Mac\n\n"
|
||||
"All paths below are relative to the project root.\n\n"
|
||||
"## Read Access\n"
|
||||
"- docs/band-profiles/\n"
|
||||
"- docs/voice-context-*.md\n"
|
||||
"- _bmad/_memory/band-manager-sidecar/\n\n"
|
||||
"## Write Access\n"
|
||||
"- _bmad/_memory/band-manager-sidecar/\n"
|
||||
"- docs/voice-context-{user}.md (current user's file only)\n\n"
|
||||
"## Deny Zones\n"
|
||||
"- All other directories\n"
|
||||
)
|
||||
created.append("access-boundaries.md")
|
||||
|
||||
# patterns.md - empty
|
||||
pat_path = sidecar / "patterns.md"
|
||||
if not pat_path.exists():
|
||||
pat_path.write_text("# Musical Patterns\n\nLearned preferences will appear here over time.\n")
|
||||
created.append("patterns.md")
|
||||
|
||||
# chronology.md - empty
|
||||
chron_path = sidecar / "chronology.md"
|
||||
if not chron_path.exists():
|
||||
chron_path.write_text("# Session Chronology\n\nSession summaries will appear here.\n")
|
||||
created.append("chronology.md")
|
||||
|
||||
return {"scaffolded": True, "files_created": created, "sidecar_path": str(sidecar)}
|
||||
|
||||
|
||||
def find_module_csv(project_root: Path, skill_dir: Path) -> Path | None:
|
||||
"""Find module-help.csv — installed location first, then setup skill assets.
|
||||
|
||||
Search order:
|
||||
1. BMad installed location (_bmad/module-help.csv)
|
||||
2. Setup skill assets (sibling of this skill in the discovery directory)
|
||||
3. Setup skill assets (in src/skills/ — standalone/source installs)
|
||||
"""
|
||||
# 1. BMad installed location
|
||||
installed = project_root / "_bmad" / "module-help.csv"
|
||||
if installed.is_file():
|
||||
return installed
|
||||
|
||||
# 2. Setup skill assets (sibling directory — works for symlinked and copied skills)
|
||||
skills_dir = skill_dir.parent
|
||||
setup_csv = skills_dir / SETUP_SKILL_NAME / "assets" / "module-help.csv"
|
||||
if setup_csv.is_file():
|
||||
return setup_csv
|
||||
|
||||
# 3. Source directory fallback (standalone install without BMad)
|
||||
source_csv = project_root / "src" / "skills" / SETUP_SKILL_NAME / "assets" / "module-help.csv"
|
||||
if source_csv.is_file():
|
||||
return source_csv
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_csv(csv_path: Path, include_modules: list[str] | None = None) -> list[dict]:
|
||||
"""Parse module-help.csv and return rows filtered by module (excluding setup).
|
||||
|
||||
Args:
|
||||
csv_path: Path to module-help.csv
|
||||
include_modules: If provided, only include rows whose 'module' column
|
||||
matches one of these values. If None, include all rows.
|
||||
"""
|
||||
with open(csv_path, encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
rows = []
|
||||
for row in reader:
|
||||
# Skip the setup skill's own entry
|
||||
if row.get("skill", "").strip() == SETUP_SKILL_NAME:
|
||||
continue
|
||||
# Filter by module if specified
|
||||
if include_modules is not None:
|
||||
module = row.get("module", "").strip()
|
||||
if module not in include_modules:
|
||||
continue
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def render_menu(csv_path: Path, include_modules: list[str] | None = None) -> str:
|
||||
"""Render capability menu from module-help.csv."""
|
||||
rows = parse_csv(csv_path, include_modules)
|
||||
|
||||
lines = ["What would you like to do today?\n"]
|
||||
for i, row in enumerate(rows, 1):
|
||||
code = row.get("menu-code", "??").strip()
|
||||
display = row.get("display-name", "").strip()
|
||||
desc = row.get("description", "No description").strip()
|
||||
lines.append(f"{i}. [{code}] {display} — {desc}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_routing_table(csv_path: Path, include_modules: list[str] | None = None) -> dict:
|
||||
"""Build menu-code to capability routing table."""
|
||||
rows = parse_csv(csv_path, include_modules)
|
||||
|
||||
table = {}
|
||||
for i, row in enumerate(rows, 1):
|
||||
code = row.get("menu-code", "").strip()
|
||||
skill = row.get("skill", "").strip()
|
||||
action = row.get("action", "").strip()
|
||||
|
||||
entry = {"name": action}
|
||||
if skill == AGENT_SKILL_NAME:
|
||||
# Agent's own capabilities — load reference prompt
|
||||
entry["type"] = "prompt"
|
||||
entry["target"] = f"./references/{action}.md"
|
||||
else:
|
||||
# External skill capabilities
|
||||
entry["type"] = "skill"
|
||||
entry["target"] = skill
|
||||
|
||||
table[code] = entry
|
||||
table[str(i)] = entry
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Band Manager pre-activation checks")
|
||||
parser.add_argument("project_root", help="Project root directory")
|
||||
parser.add_argument("--scaffold", action="store_true", help="Create sidecar if missing")
|
||||
parser.add_argument("--user-name", help="Current user name (for voice file matching)")
|
||||
parser.add_argument("-o", "--output", help="Output file path")
|
||||
args = parser.parse_args()
|
||||
|
||||
project_root = Path(args.project_root)
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
|
||||
csv_path = find_module_csv(project_root, skill_dir)
|
||||
if csv_path is None:
|
||||
print(json.dumps({
|
||||
"error": True,
|
||||
"message": "module-help.csv not found. Run the setup skill first.",
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
# Only show this module's own capabilities in the menu.
|
||||
menu_modules = [MODULE_CODE]
|
||||
|
||||
result = {
|
||||
"first_run": check_first_run(project_root),
|
||||
"sync_package": detect_sync_package(project_root),
|
||||
"menu": render_menu(csv_path, menu_modules),
|
||||
"routing_table": build_routing_table(csv_path, menu_modules),
|
||||
"voice_context": detect_voice_files(project_root, args.user_name),
|
||||
}
|
||||
|
||||
if args.scaffold and result["first_run"]:
|
||||
result["scaffold"] = scaffold_sidecar(project_root)
|
||||
|
||||
output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
print(f"Results written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.exit(0)
|
||||
244
.agents/skills/suno-agent-band-manager/scripts/reconcile-sidecar.py
Executable file
244
.agents/skills/suno-agent-band-manager/scripts/reconcile-sidecar.py
Executable file
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Post-unpack reconciliation helper for the Mac sidecar.
|
||||
|
||||
After `unpack-portable.sh/.ps1` extracts a sync archive on a receiving
|
||||
machine, the sidecar index.md still reflects the receiving machine's prior
|
||||
local state — even though the freshly-arrived files (WIPs, songbook entries,
|
||||
band profiles, playlist docs, session-context) may contain updates the
|
||||
sidecar narrative should integrate.
|
||||
|
||||
This script produces a punch list for the agent to walk through:
|
||||
|
||||
1. **Files modified more recently than index.md** — candidates for
|
||||
narrative integration (session history, current work, pending threads).
|
||||
2. **Validator findings** — calls `validate-sidecar.py` so drift between
|
||||
the sidecar narrative and the unpacked file state surfaces immediately.
|
||||
|
||||
The script does not edit files. The agent is responsible for reading each
|
||||
candidate and deciding whether the sidecar narrative should integrate its
|
||||
content, surfacing the decision to the user via the usual handoff
|
||||
checkpoint.
|
||||
|
||||
Usage:
|
||||
python3 scripts/reconcile-sidecar.py [project_root]
|
||||
python3 scripts/reconcile-sidecar.py --format json
|
||||
|
||||
Exit codes:
|
||||
0 — sidecar and files are in sync (or sidecar absent — nothing to check)
|
||||
1 — candidates found or validator reported errors (agent should reconcile)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _format_mtime(mtime: float) -> str:
|
||||
return datetime.fromtimestamp(mtime, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S UTC"
|
||||
)
|
||||
|
||||
|
||||
def find_newer_docs(project_root: Path, index_mtime: float) -> list[dict[str, Any]]:
|
||||
"""Return docs/*.md files whose mtime is newer than the sidecar index.md.
|
||||
|
||||
These are the most likely candidates for sidecar narrative integration —
|
||||
a freshly unpacked WIP update, session-context edit, or songbook
|
||||
addition that hasn't yet shown up in the sidecar's story.
|
||||
"""
|
||||
docs_root = project_root / "docs"
|
||||
if not docs_root.is_dir():
|
||||
return []
|
||||
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for path in sorted(docs_root.rglob("*.md")):
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if mtime <= index_mtime:
|
||||
continue
|
||||
rel = str(path.relative_to(project_root))
|
||||
candidates.append(
|
||||
{
|
||||
"path": rel,
|
||||
"mtime": _format_mtime(mtime),
|
||||
"delta_seconds": int(mtime - index_mtime),
|
||||
}
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def run_validator(project_root: Path) -> dict[str, Any]:
|
||||
"""Invoke validate-sidecar.py and return its JSON payload.
|
||||
|
||||
Soft-fail if the validator isn't present — older installs or partial
|
||||
checkouts shouldn't break the reconcile flow.
|
||||
"""
|
||||
validator = Path(__file__).parent / "validate-sidecar.py"
|
||||
if not validator.is_file():
|
||||
return {"status": "skipped", "reason": "validate-sidecar.py not found"}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(validator),
|
||||
str(project_root),
|
||||
"--format",
|
||||
"json",
|
||||
"--warn-only",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
return {"status": "error", "reason": f"could not invoke validator: {exc}"}
|
||||
|
||||
if result.returncode not in (0, 1):
|
||||
return {
|
||||
"status": "error",
|
||||
"reason": f"validator exited {result.returncode}",
|
||||
"stderr": result.stderr.strip(),
|
||||
}
|
||||
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
return {"status": "error", "reason": f"validator output unparseable: {exc}"}
|
||||
|
||||
|
||||
def format_text(payload: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
"Sidecar Reconciliation Report",
|
||||
"=" * 29,
|
||||
"",
|
||||
]
|
||||
|
||||
status = payload.get("status", "unknown")
|
||||
lines.append(f"Status: {status}")
|
||||
lines.append(f"Sidecar index.md: {payload.get('index_path', 'unknown')}")
|
||||
if payload.get("index_mtime"):
|
||||
lines.append(f"Index last updated: {payload['index_mtime']}")
|
||||
lines.append("")
|
||||
|
||||
candidates = payload.get("newer_files", [])
|
||||
lines.append(
|
||||
f"Files modified more recently than the sidecar: {len(candidates)}"
|
||||
)
|
||||
if candidates:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"These are candidates for narrative integration. Review each and "
|
||||
"decide whether the sidecar's session history, current work, or "
|
||||
"catalog status should be updated before continuing:"
|
||||
)
|
||||
lines.append("")
|
||||
for item in candidates:
|
||||
lines.append(f" - {item['path']} (modified {item['mtime']})")
|
||||
lines.append("")
|
||||
|
||||
validator = payload.get("validator", {})
|
||||
v_status = validator.get("status", "unknown")
|
||||
lines.append(f"Validator: {v_status}")
|
||||
findings = validator.get("findings", []) or []
|
||||
if findings:
|
||||
by_category: dict[str, list[dict[str, Any]]] = {}
|
||||
for f in findings:
|
||||
by_category.setdefault(f.get("category", "other"), []).append(f)
|
||||
for category, items in sorted(by_category.items()):
|
||||
lines.append(f" [{category.upper()}] ({len(items)})")
|
||||
for f in items:
|
||||
lines.append(
|
||||
f" ({f.get('severity', 'warning')}) "
|
||||
f"{f.get('path', '')} — {f.get('message', '')}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if payload.get("needs_reconciliation"):
|
||||
lines.append(
|
||||
"ACTION NEEDED: walk the punch list above with the user and "
|
||||
"integrate changes into the sidecar narrative before packing "
|
||||
"a return sync."
|
||||
)
|
||||
else:
|
||||
lines.append("CLEAN: sidecar is in sync with unpacked file state.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_report(project_root: Path) -> dict[str, Any]:
|
||||
index_path = (
|
||||
project_root / "_bmad" / "_memory" / "band-manager-sidecar" / "index.md"
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"index_path": str(
|
||||
index_path.relative_to(project_root)
|
||||
if index_path.is_relative_to(project_root)
|
||||
else index_path
|
||||
),
|
||||
}
|
||||
|
||||
if not index_path.is_file():
|
||||
payload["status"] = "no_sidecar"
|
||||
payload["newer_files"] = []
|
||||
payload["validator"] = {"status": "skipped", "reason": "no sidecar index.md"}
|
||||
payload["needs_reconciliation"] = False
|
||||
return payload
|
||||
|
||||
index_mtime = index_path.stat().st_mtime
|
||||
payload["index_mtime"] = _format_mtime(index_mtime)
|
||||
payload["newer_files"] = find_newer_docs(project_root, index_mtime)
|
||||
payload["validator"] = run_validator(project_root)
|
||||
|
||||
validator_findings = payload["validator"].get("findings", []) or []
|
||||
has_errors = any(f.get("severity") == "error" for f in validator_findings)
|
||||
payload["needs_reconciliation"] = bool(payload["newer_files"]) or has_errors
|
||||
payload["status"] = (
|
||||
"needs_reconciliation" if payload["needs_reconciliation"] else "clean"
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Post-unpack reconciliation helper for the Mac sidecar."
|
||||
)
|
||||
parser.add_argument(
|
||||
"project_root",
|
||||
nargs="?",
|
||||
default=".",
|
||||
help="Project root directory (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["text", "json"],
|
||||
default="text",
|
||||
help="Output format (default: text)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
project_root = Path(args.project_root).resolve()
|
||||
if not project_root.is_dir():
|
||||
print(f"ERROR: project root not found: {project_root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
payload = build_report(project_root)
|
||||
|
||||
if args.format == "json":
|
||||
print(json.dumps(payload, indent=2))
|
||||
else:
|
||||
print(format_text(payload))
|
||||
|
||||
return 1 if payload.get("needs_reconciliation") else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate the derivable sections of the Mac sidecar index.md.
|
||||
|
||||
Replaces the Recently Published and Catalog Status sections in
|
||||
_bmad/_memory/band-manager-sidecar/index.md with content derived from
|
||||
songbook frontmatter + body Status markers + playlist YAMLs.
|
||||
|
||||
The narrative sections (Current Work, Pending / Parked Work, Session History)
|
||||
are preserved unchanged — only the derivable sections are rewritten.
|
||||
|
||||
Section boundaries are HTML comment markers:
|
||||
<!-- derived:recently-published:start -->
|
||||
...auto-generated content...
|
||||
<!-- derived:recently-published:end -->
|
||||
|
||||
If the markers are missing from index.md, the script reports what to add and
|
||||
exits non-zero without modifying the file. Pass --migrate to wrap existing
|
||||
"## Recently Published" and "## Catalog Status" sections with the markers
|
||||
in-place, then continue with regeneration.
|
||||
|
||||
Cross-platform: pure Python stdlib + PyYAML.
|
||||
|
||||
Usage:
|
||||
python3 scripts/regenerate-index-sections.py [project_root]
|
||||
python3 scripts/regenerate-index-sections.py --dry-run # print diff only
|
||||
python3 scripts/regenerate-index-sections.py --migrate # add missing markers
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("ERROR: PyYAML required. Install with: pip install pyyaml", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
|
||||
STATUS_MARKER_RE = re.compile(
|
||||
r"\*\*Status:\s*(LOCKED|PUBLISHED|WIP)"
|
||||
r"(?:\s*[—-]\s*(?:v\d+\s+)?Published\s+(\d{4}-\d{2}-\d{2}))?"
|
||||
r"(?:\s*\((\d{4}-\d{2}-\d{2})\))?"
|
||||
r"\.?\s*(.*?)\*\*",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# How many entries to include in Recently Published
|
||||
RECENT_LIMIT = 7
|
||||
|
||||
# Display name lookups are derived dynamically from band profile YAMLs at
|
||||
# runtime (see `band_display_map()` below) so this script works for any
|
||||
# project's bands, not just one specific project's hardcoded list.
|
||||
|
||||
|
||||
def parse_song(path: Path) -> dict | None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
fm_match = FRONTMATTER_RE.match(text)
|
||||
if not fm_match:
|
||||
return None
|
||||
try:
|
||||
frontmatter = yaml.safe_load(fm_match.group(1)) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
# Surface parse failures instead of silently dropping the song.
|
||||
# Common cause: flow-sequence values containing inner brackets
|
||||
# (e.g., transformations_applied: [... [Spoken] ...]) — use a quoted
|
||||
# string or a flat list without brackets inside items. See issue #29.
|
||||
print(
|
||||
f"WARNING: YAML parse error in {path} — {exc}. "
|
||||
"Song will be skipped; derived sections may be incomplete.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
body = text[fm_match.end() :]
|
||||
body_status = body_date = body_desc = None
|
||||
for m in STATUS_MARKER_RE.finditer(body):
|
||||
body_status = m.group(1)
|
||||
body_date = m.group(2) or m.group(3)
|
||||
body_desc = (m.group(4) or "").strip()
|
||||
|
||||
# Truncate body_desc at the "Audio at" marker to get the short description.
|
||||
# Preserve the trailing period — the description ends on a natural sentence boundary,
|
||||
# and the caller appends " Songbook: ..." which needs the period for readability.
|
||||
if body_desc:
|
||||
audio_cut = re.search(r"\s*Audio at\b", body_desc)
|
||||
if audio_cut:
|
||||
body_desc = body_desc[: audio_cut.start()].rstrip()
|
||||
if body_desc and not body_desc.endswith((".", "!", "?")):
|
||||
body_desc += "."
|
||||
|
||||
return {
|
||||
"path": path,
|
||||
"title": frontmatter.get("title", path.stem),
|
||||
"band": frontmatter.get("band_profile", ""),
|
||||
"frontmatter_status": frontmatter.get("status"),
|
||||
"frontmatter_date": str(frontmatter.get("date"))
|
||||
if frontmatter.get("date")
|
||||
else None,
|
||||
"body_status": body_status,
|
||||
"body_date": body_date,
|
||||
"body_desc": body_desc,
|
||||
}
|
||||
|
||||
|
||||
def band_display_map(project_root: Path) -> dict[str, str]:
|
||||
"""Build {slug: display_name} from band profile YAMLs.
|
||||
|
||||
Falls back to a Title-Cased version of the slug when a profile is missing
|
||||
or doesn't carry a `name:` field. Generic across projects — does not
|
||||
hardcode any specific band names.
|
||||
"""
|
||||
out: dict[str, str] = {}
|
||||
profiles_dir = project_root / "docs" / "band-profiles"
|
||||
if not profiles_dir.is_dir():
|
||||
return out
|
||||
for profile_path in sorted(profiles_dir.glob("*.yaml")):
|
||||
slug = profile_path.stem
|
||||
try:
|
||||
profile = yaml.safe_load(profile_path.read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError:
|
||||
profile = None
|
||||
display = ""
|
||||
if isinstance(profile, dict):
|
||||
display = (profile.get("name") or "").strip()
|
||||
if not display:
|
||||
display = " ".join(w.capitalize() for w in slug.replace("_", "-").split("-") if w)
|
||||
out[slug] = display
|
||||
return out
|
||||
|
||||
|
||||
def known_band_slugs(project_root: Path) -> set[str]:
|
||||
"""Band profile YAML filenames (without extension) define valid band slugs."""
|
||||
profiles_dir = project_root / "docs" / "band-profiles"
|
||||
if not profiles_dir.is_dir():
|
||||
return set()
|
||||
return {p.stem for p in profiles_dir.glob("*.yaml")}
|
||||
|
||||
|
||||
def load_all_songs(project_root: Path) -> list[dict]:
|
||||
songbook_root = project_root / "docs" / "songbook"
|
||||
songs = []
|
||||
if not songbook_root.is_dir():
|
||||
return songs
|
||||
valid_bands = known_band_slugs(project_root)
|
||||
for path in sorted(songbook_root.rglob("*.md")):
|
||||
song = parse_song(path)
|
||||
if song is None:
|
||||
continue
|
||||
# Songs whose band_profile doesn't match a known band profile YAML are
|
||||
# likely legacy / personal-project entries with custom metadata — they
|
||||
# shouldn't surface in catalog status or recently-published output.
|
||||
if valid_bands and song["band"] not in valid_bands:
|
||||
continue
|
||||
songs.append(song)
|
||||
return songs
|
||||
|
||||
|
||||
def is_published(song: dict) -> bool:
|
||||
return song["frontmatter_status"] == "published" and song["body_status"] in (
|
||||
"LOCKED",
|
||||
"PUBLISHED",
|
||||
)
|
||||
|
||||
|
||||
def publish_date(song: dict) -> str:
|
||||
"""Authoritative publish date: body marker wins, frontmatter is fallback."""
|
||||
return song["body_date"] or song["frontmatter_date"] or ""
|
||||
|
||||
|
||||
def generate_recently_published(songs: list[dict], project_root: Path) -> str:
|
||||
band_display = band_display_map(project_root)
|
||||
published = [s for s in songs if is_published(s)]
|
||||
published.sort(key=publish_date, reverse=True)
|
||||
published = published[:RECENT_LIMIT]
|
||||
|
||||
lines = []
|
||||
for s in published:
|
||||
title = s["title"]
|
||||
date = publish_date(s)
|
||||
band_display_name = band_display.get(s["band"], s["band"])
|
||||
desc = s["body_desc"] or f"{band_display_name}."
|
||||
path_display = s["path"].relative_to(s["path"].parents[3])
|
||||
lines.append(
|
||||
f"- **{title}** ({date}, PUBLISHED) — {desc} Songbook: "
|
||||
f"`{path_display.as_posix()}`."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_catalog_status(songs: list[dict], project_root: Path) -> str:
|
||||
band_display = band_display_map(project_root)
|
||||
# Per-band published counts
|
||||
per_band: dict[str, list[dict]] = {}
|
||||
for s in songs:
|
||||
per_band.setdefault(s["band"], []).append(s)
|
||||
|
||||
lines = []
|
||||
for band_slug in sorted(per_band.keys()):
|
||||
band_display_name = band_display.get(band_slug, band_slug)
|
||||
band_songs = per_band[band_slug]
|
||||
published = [s for s in band_songs if is_published(s)]
|
||||
published.sort(key=publish_date, reverse=True)
|
||||
|
||||
# Check for a playlist YAML for this band
|
||||
playlist_path = project_root / "docs" / f"{band_slug}-playlist.yaml"
|
||||
playlist_count = None
|
||||
if playlist_path.exists():
|
||||
try:
|
||||
playlist = yaml.safe_load(playlist_path.read_text(encoding="utf-8"))
|
||||
if isinstance(playlist, dict):
|
||||
playlist_count = len(playlist.get("tracks", []) or [])
|
||||
except yaml.YAMLError:
|
||||
pass
|
||||
|
||||
# Line format depends on whether there's a playlist
|
||||
if playlist_count is not None and playlist_count > len(published):
|
||||
# Catalog with a full-album playlist that's longer than the published list
|
||||
lines.append(
|
||||
f"- **{band_display_name}:** {playlist_count}-track playlist "
|
||||
f"(songbook: {len(band_songs)} entries, {len(published)} with "
|
||||
f"complete LOCKED markers). See playlist YAML at "
|
||||
f"`docs/{band_slug}-playlist.yaml`."
|
||||
)
|
||||
else:
|
||||
# Catalog is the published list (no extended playlist beyond it)
|
||||
titles = ", ".join(s["title"] for s in published)
|
||||
lines.append(
|
||||
f"- **{band_display_name}:** **{len(published)} published tracks** — {titles}."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def replace_section(
|
||||
text: str, marker_name: str, new_content: str
|
||||
) -> tuple[str, bool]:
|
||||
"""Replace content between <!-- derived:NAME:start --> and :end markers.
|
||||
|
||||
Returns (new_text, replaced). If markers aren't found, returns (text, False)
|
||||
so the caller can report what to add.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
rf"(<!--\s*derived:{re.escape(marker_name)}:start\s*-->)(.*?)"
|
||||
rf"(<!--\s*derived:{re.escape(marker_name)}:end\s*-->)",
|
||||
re.DOTALL,
|
||||
)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return text, False
|
||||
replacement = f"{match.group(1)}\n\n{new_content}\n\n{match.group(3)}"
|
||||
return text[: match.start()] + replacement + text[match.end() :], True
|
||||
|
||||
|
||||
def migrate_section(text: str, heading: str, marker_name: str) -> tuple[str, bool]:
|
||||
"""Wrap an existing "## Heading" section's body with derived-section markers.
|
||||
|
||||
Finds a line like "## Recently Published", locates the end of the section
|
||||
(next "## " heading at the same level, or EOF), and wraps the body content
|
||||
with <!-- derived:NAME:start --> / <!-- derived:NAME:end --> markers.
|
||||
|
||||
Returns (new_text, migrated). migrated=False means the markers already
|
||||
existed or the heading wasn't found.
|
||||
"""
|
||||
existing_marker = re.compile(
|
||||
rf"<!--\s*derived:{re.escape(marker_name)}:start\s*-->"
|
||||
)
|
||||
if existing_marker.search(text):
|
||||
return text, False
|
||||
|
||||
heading_pattern = re.compile(rf"^{re.escape(heading)}\s*$", re.MULTILINE)
|
||||
heading_match = heading_pattern.search(text)
|
||||
if not heading_match:
|
||||
return text, False
|
||||
|
||||
body_start = heading_match.end()
|
||||
next_heading = re.compile(r"^##\s+", re.MULTILINE)
|
||||
next_match = next_heading.search(text, pos=body_start)
|
||||
body_end = next_match.start() if next_match else len(text)
|
||||
|
||||
body = text[body_start:body_end].strip("\n")
|
||||
wrapped = (
|
||||
f"\n\n<!-- derived:{marker_name}:start -->\n\n"
|
||||
f"{body}\n\n"
|
||||
f"<!-- derived:{marker_name}:end -->\n\n"
|
||||
)
|
||||
return text[:body_start] + wrapped + text[body_end:], True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Regenerate derivable sections of Mac sidecar index.md."
|
||||
)
|
||||
parser.add_argument(
|
||||
"project_root",
|
||||
nargs="?",
|
||||
default=".",
|
||||
help="Project root directory (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the regenerated sections without writing",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--migrate",
|
||||
action="store_true",
|
||||
help=(
|
||||
"If index.md is missing derived-section markers, wrap the existing "
|
||||
"## Recently Published and ## Catalog Status sections with them "
|
||||
"before regenerating. One-shot migration for pre-v1.6.5 sidecars."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
project_root = Path(args.project_root).resolve()
|
||||
if not project_root.is_dir():
|
||||
print(f"ERROR: project root not found: {project_root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
index_path = (
|
||||
project_root / "_bmad" / "_memory" / "band-manager-sidecar" / "index.md"
|
||||
)
|
||||
if not index_path.exists():
|
||||
print(f"ERROR: sidecar index not found at {index_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
songs = load_all_songs(project_root)
|
||||
recently_published = generate_recently_published(songs, project_root)
|
||||
catalog_status = generate_catalog_status(songs, project_root)
|
||||
|
||||
if args.dry_run:
|
||||
print("=== Recently Published ===\n")
|
||||
print(recently_published)
|
||||
print("\n=== Catalog Status ===\n")
|
||||
print(catalog_status)
|
||||
return 0
|
||||
|
||||
text = index_path.read_text(encoding="utf-8")
|
||||
|
||||
if args.migrate:
|
||||
migrated_text = text
|
||||
migrated_any = False
|
||||
could_not_migrate = []
|
||||
for heading, marker in (
|
||||
("## Recently Published", "recently-published"),
|
||||
("## Catalog Status", "catalog-status"),
|
||||
):
|
||||
migrated_text, migrated = migrate_section(
|
||||
migrated_text, heading, marker
|
||||
)
|
||||
if migrated:
|
||||
migrated_any = True
|
||||
elif not re.search(
|
||||
rf"<!--\s*derived:{re.escape(marker)}:start\s*-->", migrated_text
|
||||
):
|
||||
could_not_migrate.append((heading, marker))
|
||||
|
||||
if could_not_migrate:
|
||||
print(
|
||||
"ERROR: --migrate could not locate these sections to wrap:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for heading, marker in could_not_migrate:
|
||||
print(
|
||||
f" '{heading}' heading not found — expected marker pair "
|
||||
f"<!-- derived:{marker}:start --> ... "
|
||||
f"<!-- derived:{marker}:end -->",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"\nAdd the heading and rerun, or hand-edit the markers in. "
|
||||
"See the 'Migration' block in CHANGELOG.md under the 1.6.5 "
|
||||
"release for the exact template.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if migrated_any:
|
||||
text = migrated_text
|
||||
if not args.dry_run:
|
||||
index_path.write_text(text, encoding="utf-8")
|
||||
print(
|
||||
f"Migrated: wrapped existing sections with derived-section "
|
||||
f"markers in {index_path.relative_to(project_root)}"
|
||||
)
|
||||
|
||||
new_text = text
|
||||
missing_markers = []
|
||||
|
||||
new_text, ok = replace_section(
|
||||
new_text, "recently-published", recently_published
|
||||
)
|
||||
if not ok:
|
||||
missing_markers.append("recently-published")
|
||||
|
||||
new_text, ok = replace_section(new_text, "catalog-status", catalog_status)
|
||||
if not ok:
|
||||
missing_markers.append("catalog-status")
|
||||
|
||||
if missing_markers:
|
||||
print(
|
||||
"ERROR: index.md is missing required section markers:", file=sys.stderr
|
||||
)
|
||||
for m in missing_markers:
|
||||
print(
|
||||
f" <!-- derived:{m}:start --> ... <!-- derived:{m}:end -->",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"\nTo fix automatically, rerun with --migrate — this wraps the "
|
||||
"existing '## Recently Published' and '## Catalog Status' sections "
|
||||
"with the required markers in-place. The exact marker template is "
|
||||
"documented in CHANGELOG.md under the 1.6.5 release (see the "
|
||||
"'Migration (one-time, per project)' block).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if new_text == text:
|
||||
print("No changes needed — derivable sections already up to date.")
|
||||
return 0
|
||||
|
||||
index_path.write_text(new_text, encoding="utf-8")
|
||||
print(f"Regenerated derivable sections in {index_path.relative_to(project_root)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for check-memory-health.py"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
|
||||
spec = spec_from_file_location(
|
||||
"check_memory_health",
|
||||
Path(__file__).parent.parent / "check-memory-health.py",
|
||||
)
|
||||
mod = module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
|
||||
def test_healthy_files(tmp_path):
|
||||
"""All files under threshold."""
|
||||
(tmp_path / "index.md").write_text("x" * 100)
|
||||
(tmp_path / "patterns.md").write_text("x" * 100)
|
||||
(tmp_path / "chronology.md").write_text("x" * 100)
|
||||
|
||||
result = mod.check_health(tmp_path)
|
||||
assert result["maintenance_recommended"] is False
|
||||
assert result["needs_pruning"] == []
|
||||
|
||||
|
||||
def test_over_threshold(tmp_path):
|
||||
"""File over threshold flagged."""
|
||||
(tmp_path / "index.md").write_text("x" * 5000)
|
||||
(tmp_path / "patterns.md").write_text("x" * 100)
|
||||
(tmp_path / "chronology.md").write_text("x" * 100)
|
||||
|
||||
result = mod.check_health(tmp_path)
|
||||
assert result["maintenance_recommended"] is True
|
||||
assert "index.md" in result["needs_pruning"]
|
||||
|
||||
|
||||
def test_missing_files(tmp_path):
|
||||
"""Missing files reported correctly."""
|
||||
result = mod.check_health(tmp_path)
|
||||
assert result["files"]["index.md"]["exists"] is False
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for pre-activate.py"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
|
||||
# Load module
|
||||
spec = spec_from_file_location(
|
||||
"pre_activate",
|
||||
Path(__file__).parent.parent / "pre-activate.py",
|
||||
)
|
||||
mod = module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
SAMPLE_CSV = (
|
||||
"module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\n"
|
||||
'Suno Band Manager,suno-setup,Setup Suno Module,SU,"Install or update config.",configure,,anytime,,,false,,\n'
|
||||
'Suno Band Manager,suno-agent-band-manager,Create Song,CS,"Create a song package.",create-song,,anytime,,,false,,song package\n'
|
||||
'Suno Band Manager,suno-agent-band-manager,Refine Song,RS,"Refine a song.",refine-song,,anytime,,,false,,\n'
|
||||
'Suno Band Manager,suno-band-profile-manager,Manage Bands,MB,"Manage band profiles.",manage-profiles,,anytime,,,false,,\n'
|
||||
)
|
||||
|
||||
|
||||
def test_check_first_run_true(tmp_path):
|
||||
"""First run when sidecar doesn't exist."""
|
||||
assert mod.check_first_run(tmp_path) is True
|
||||
|
||||
|
||||
def test_check_first_run_false(tmp_path):
|
||||
"""Not first run when sidecar exists."""
|
||||
sidecar = tmp_path / "_bmad" / "_memory" / "band-manager-sidecar"
|
||||
sidecar.mkdir(parents=True)
|
||||
assert mod.check_first_run(tmp_path) is False
|
||||
|
||||
|
||||
def test_scaffold_sidecar(tmp_path):
|
||||
"""Scaffold creates all expected files."""
|
||||
result = mod.scaffold_sidecar(tmp_path)
|
||||
assert result["scaffolded"] is True
|
||||
assert "access-boundaries.md" in result["files_created"]
|
||||
assert "patterns.md" in result["files_created"]
|
||||
assert "chronology.md" in result["files_created"]
|
||||
|
||||
sidecar = tmp_path / "_bmad" / "_memory" / "band-manager-sidecar"
|
||||
assert (sidecar / "access-boundaries.md").exists()
|
||||
assert (sidecar / "patterns.md").exists()
|
||||
assert (sidecar / "chronology.md").exists()
|
||||
|
||||
|
||||
def test_scaffold_idempotent(tmp_path):
|
||||
"""Scaffold doesn't overwrite existing files."""
|
||||
mod.scaffold_sidecar(tmp_path)
|
||||
sidecar = tmp_path / "_bmad" / "_memory" / "band-manager-sidecar"
|
||||
|
||||
# Write custom content
|
||||
(sidecar / "patterns.md").write_text("custom content")
|
||||
|
||||
result = mod.scaffold_sidecar(tmp_path)
|
||||
assert "patterns.md" not in result["files_created"]
|
||||
assert (sidecar / "patterns.md").read_text() == "custom content"
|
||||
|
||||
|
||||
def _write_csv(tmp_path, content=SAMPLE_CSV):
|
||||
"""Helper to write a test CSV file."""
|
||||
csv_path = tmp_path / "module-help.csv"
|
||||
csv_path.write_text(content)
|
||||
return csv_path
|
||||
|
||||
|
||||
def test_render_menu(tmp_path):
|
||||
"""Menu renders correctly from module-help.csv."""
|
||||
csv_path = _write_csv(tmp_path)
|
||||
|
||||
menu = mod.render_menu(csv_path)
|
||||
# Setup skill entry should be excluded
|
||||
assert "Setup" not in menu
|
||||
# Agent and external skill entries should appear
|
||||
assert "[CS]" in menu
|
||||
assert "[RS]" in menu
|
||||
assert "[MB]" in menu
|
||||
assert "Create Song" in menu
|
||||
|
||||
|
||||
def test_render_menu_excludes_setup(tmp_path):
|
||||
"""Menu does not include the setup skill entry."""
|
||||
csv_path = _write_csv(tmp_path)
|
||||
menu = mod.render_menu(csv_path)
|
||||
assert "[SU]" not in menu
|
||||
|
||||
|
||||
def test_build_routing_table_agent_capabilities(tmp_path):
|
||||
"""Agent's own capabilities route to prompt references."""
|
||||
csv_path = _write_csv(tmp_path)
|
||||
|
||||
table = mod.build_routing_table(csv_path)
|
||||
assert table["CS"]["type"] == "prompt"
|
||||
assert table["CS"]["target"] == "./references/create-song.md"
|
||||
assert table["RS"]["type"] == "prompt"
|
||||
assert table["RS"]["target"] == "./references/refine-song.md"
|
||||
|
||||
|
||||
def test_build_routing_table_external_skills(tmp_path):
|
||||
"""External skill capabilities route to skill invocation."""
|
||||
csv_path = _write_csv(tmp_path)
|
||||
|
||||
table = mod.build_routing_table(csv_path)
|
||||
assert table["MB"]["type"] == "skill"
|
||||
assert table["MB"]["target"] == "suno-band-profile-manager"
|
||||
|
||||
|
||||
def test_build_routing_table_numeric_keys(tmp_path):
|
||||
"""Routing table includes numeric keys for positional access."""
|
||||
csv_path = _write_csv(tmp_path)
|
||||
|
||||
table = mod.build_routing_table(csv_path)
|
||||
# First non-setup entry is CS at position 1
|
||||
assert table["1"]["name"] == "create-song"
|
||||
assert table["2"]["name"] == "refine-song"
|
||||
assert table["3"]["name"] == "manage-profiles"
|
||||
|
||||
|
||||
def test_find_module_csv_installed(tmp_path):
|
||||
"""Finds CSV at installed location."""
|
||||
bmad_dir = tmp_path / "_bmad"
|
||||
bmad_dir.mkdir()
|
||||
csv_file = bmad_dir / "module-help.csv"
|
||||
csv_file.write_text(SAMPLE_CSV)
|
||||
|
||||
skill_dir = tmp_path / "skills" / "suno-agent-band-manager"
|
||||
skill_dir.mkdir(parents=True)
|
||||
|
||||
result = mod.find_module_csv(tmp_path, skill_dir)
|
||||
assert result == csv_file
|
||||
|
||||
|
||||
def test_find_module_csv_setup_assets(tmp_path):
|
||||
"""Falls back to setup skill assets when not installed."""
|
||||
skills_dir = tmp_path / "skills"
|
||||
setup_assets = skills_dir / "suno-setup" / "assets"
|
||||
setup_assets.mkdir(parents=True)
|
||||
csv_file = setup_assets / "module-help.csv"
|
||||
csv_file.write_text(SAMPLE_CSV)
|
||||
|
||||
skill_dir = skills_dir / "suno-agent-band-manager"
|
||||
skill_dir.mkdir(parents=True)
|
||||
|
||||
result = mod.find_module_csv(tmp_path, skill_dir)
|
||||
assert result == csv_file
|
||||
|
||||
|
||||
def test_find_module_csv_not_found(tmp_path):
|
||||
"""Returns None when CSV is not found."""
|
||||
skill_dir = tmp_path / "skills" / "suno-agent-band-manager"
|
||||
skill_dir.mkdir(parents=True)
|
||||
|
||||
result = mod.find_module_csv(tmp_path, skill_dir)
|
||||
assert result is None
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for validate-path.py"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
|
||||
spec = spec_from_file_location(
|
||||
"validate_path",
|
||||
Path(__file__).parent.parent / "validate-path.py",
|
||||
)
|
||||
mod = module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
|
||||
def test_parse_boundaries(tmp_path):
|
||||
"""Parse access-boundaries.md correctly."""
|
||||
boundaries_file = tmp_path / "access-boundaries.md"
|
||||
boundaries_file.write_text(
|
||||
"# Access Boundaries\n\n"
|
||||
"## Read Access\n"
|
||||
"- docs/band-profiles/\n"
|
||||
"- {project-root}/_bmad/_memory/band-manager-sidecar/\n\n"
|
||||
"## Write Access\n"
|
||||
"- {project-root}/_bmad/_memory/band-manager-sidecar/\n\n"
|
||||
"## Deny Zones\n"
|
||||
"- All other directories\n"
|
||||
)
|
||||
|
||||
boundaries = mod.parse_boundaries(boundaries_file)
|
||||
assert "docs/band-profiles/" in boundaries["read"]
|
||||
assert "_bmad/_memory/band-manager-sidecar/" in boundaries["read"]
|
||||
assert "_bmad/_memory/band-manager-sidecar/" in boundaries["write"]
|
||||
|
||||
|
||||
def test_validate_read_allowed(tmp_path):
|
||||
boundaries = {"read": ["docs/band-profiles/"], "write": []}
|
||||
result = mod.validate_path("docs/band-profiles/midnight-orchid.yaml", "read", boundaries)
|
||||
assert result["allowed"] is True
|
||||
|
||||
|
||||
def test_validate_read_denied(tmp_path):
|
||||
boundaries = {"read": ["docs/band-profiles/"], "write": []}
|
||||
result = mod.validate_path("src/secret.py", "read", boundaries)
|
||||
assert result["allowed"] is False
|
||||
|
||||
|
||||
def test_validate_write_allowed(tmp_path):
|
||||
boundaries = {"read": [], "write": ["_bmad/_memory/band-manager-sidecar/"]}
|
||||
result = mod.validate_path("_bmad/_memory/band-manager-sidecar/index.md", "write", boundaries)
|
||||
assert result["allowed"] is True
|
||||
|
||||
|
||||
def test_validate_write_denied(tmp_path):
|
||||
boundaries = {"read": [], "write": ["_bmad/_memory/band-manager-sidecar/"]}
|
||||
result = mod.validate_path("docs/band-profiles/test.yaml", "write", boundaries)
|
||||
assert result["allowed"] is False
|
||||
96
.agents/skills/suno-agent-band-manager/scripts/validate-path.py
Executable file
96
.agents/skills/suno-agent-band-manager/scripts/validate-path.py
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Validates file paths against access boundaries.
|
||||
|
||||
Usage:
|
||||
python3 scripts/validate-path.py <path> <operation> [--boundaries BOUNDARIES_FILE]
|
||||
python3 scripts/validate-path.py --help
|
||||
|
||||
Arguments:
|
||||
path File path to validate
|
||||
operation Operation type: read or write
|
||||
|
||||
Options:
|
||||
--boundaries Path to access-boundaries.md (default: auto-detect from sidecar)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_boundaries(boundaries_path: Path) -> dict:
|
||||
"""Parse access-boundaries.md into read/write/deny lists."""
|
||||
content = boundaries_path.read_text()
|
||||
boundaries = {"read": [], "write": [], "deny": []}
|
||||
current_section = None
|
||||
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
if "Read Access" in line:
|
||||
current_section = "read"
|
||||
elif "Write Access" in line:
|
||||
current_section = "write"
|
||||
elif "Deny" in line:
|
||||
current_section = "deny"
|
||||
elif line.startswith("- ") and current_section and current_section != "deny":
|
||||
path_pattern = line[2:].strip()
|
||||
# Normalize: remove {project-root}/ prefix for comparison
|
||||
path_pattern = re.sub(r"\{project-root\}/?" , "", path_pattern)
|
||||
boundaries[current_section].append(path_pattern)
|
||||
|
||||
return boundaries
|
||||
|
||||
|
||||
def validate_path(file_path: str, operation: str, boundaries: dict) -> dict:
|
||||
"""Check if a path is allowed for the given operation."""
|
||||
# Normalize the path
|
||||
normalized = re.sub(r"\{project-root\}/?", "", file_path)
|
||||
|
||||
allowed_paths = boundaries.get(operation, [])
|
||||
for allowed in allowed_paths:
|
||||
if normalized.startswith(allowed):
|
||||
return {"allowed": True, "path": file_path, "operation": operation, "matched_rule": allowed}
|
||||
|
||||
return {
|
||||
"allowed": False,
|
||||
"path": file_path,
|
||||
"operation": operation,
|
||||
"reason": f"Path not in {operation} allowlist",
|
||||
"allowed_paths": allowed_paths,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Validate paths against access boundaries")
|
||||
parser.add_argument("path", help="File path to validate")
|
||||
parser.add_argument("operation", choices=["read", "write"], help="Operation type")
|
||||
parser.add_argument("--boundaries", help="Path to access-boundaries.md")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.boundaries:
|
||||
boundaries_path = Path(args.boundaries)
|
||||
else:
|
||||
print(json.dumps({"error": True, "message": "No --boundaries file specified"}))
|
||||
sys.exit(1)
|
||||
|
||||
if not boundaries_path.exists():
|
||||
print(json.dumps({"error": True, "message": f"Boundaries file not found: {boundaries_path}"}))
|
||||
sys.exit(1)
|
||||
|
||||
boundaries = parse_boundaries(boundaries_path)
|
||||
result = validate_path(args.path, args.operation, boundaries)
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
if not result.get("allowed", False):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.exit(0)
|
||||
761
.agents/skills/suno-agent-band-manager/scripts/validate-sidecar.py
Executable file
761
.agents/skills/suno-agent-band-manager/scripts/validate-sidecar.py
Executable file
@@ -0,0 +1,761 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the Mac sidecar index against songbook + band-profile ground truth.
|
||||
|
||||
Reads every songbook entry and band profile, derives the ground-truth catalog
|
||||
state, and compares it against the claims in the sidecar index.md. Reports
|
||||
drift as structured findings. Exits 0 on clean, 1 on drift (CI-friendly).
|
||||
|
||||
Cross-platform: pure Python stdlib + PyYAML (already a module dependency).
|
||||
|
||||
Usage:
|
||||
python3 scripts/validate-sidecar.py [project_root]
|
||||
python3 scripts/validate-sidecar.py --format json
|
||||
python3 scripts/validate-sidecar.py --warn-only # exit 0 even with findings
|
||||
|
||||
Checks performed:
|
||||
1. Songbook internal consistency — frontmatter status/date vs. body status marker
|
||||
2. Audio file existence for published songs
|
||||
3. Sidecar Recently Published list matches songbook ground truth
|
||||
4. Sidecar Catalog Status counts match actual songbook counts
|
||||
5. Playlist YAML track count matches songbook count for that band
|
||||
6. Markdown cross-references in docs/ resolve to existing files
|
||||
|
||||
Called by:
|
||||
- pack-portable.{sh,ps1} before packing (gates sync)
|
||||
- save-memory workflow after index.md writes (validates derivation)
|
||||
- Standalone by user any time for a consistency check
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "PyYAML required. Install with: pip install pyyaml",
|
||||
}
|
||||
)
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Song:
|
||||
path: Path
|
||||
band: str
|
||||
title: str
|
||||
frontmatter_status: str | None
|
||||
frontmatter_date: str | None
|
||||
body_status: str | None # "LOCKED", "PUBLISHED", "WIP", or None
|
||||
body_date: str | None
|
||||
body_description: str | None
|
||||
audio_references: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_published(self) -> bool:
|
||||
"""Single source of truth: requires frontmatter + body to agree on published."""
|
||||
frontmatter_published = self.frontmatter_status == "published"
|
||||
body_published = self.body_status in ("LOCKED", "PUBLISHED")
|
||||
return frontmatter_published and body_published
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
category: str # "songbook_drift" | "audio_missing" | "index_drift" | "playlist_drift" | "cross_reference_missing"
|
||||
severity: str # "error" | "warning"
|
||||
path: str
|
||||
message: str
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"category": self.category,
|
||||
"severity": self.severity,
|
||||
"path": self.path,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
|
||||
STATUS_MARKER_RE = re.compile(
|
||||
r"\*\*Status:\s*(LOCKED|PUBLISHED|WIP)"
|
||||
r"(?:\s*[—-]\s*(?:v\d+\s+)?Published\s+(\d{4}-\d{2}-\d{2}))?"
|
||||
r"(?:\s*\((\d{4}-\d{2}-\d{2})\))?"
|
||||
r"\.?\s*(.*?)\*\*",
|
||||
re.DOTALL,
|
||||
)
|
||||
AUDIO_REF_RE = re.compile(r"`(docs/audio/[^`]+\.(?:mp3|wav|flac|m4a))`")
|
||||
|
||||
|
||||
def parse_song(path: Path, project_root: Path) -> tuple[Song | None, str | None]:
|
||||
"""Parse a songbook markdown file.
|
||||
|
||||
Returns a (song, error) pair:
|
||||
- (Song, None) when parsing succeeds
|
||||
- (None, None) when the file has no frontmatter (likely not a song)
|
||||
- (None, error_msg) when YAML frontmatter fails to parse
|
||||
"""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
fm_match = FRONTMATTER_RE.match(text)
|
||||
if not fm_match:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
frontmatter = yaml.safe_load(fm_match.group(1)) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
return None, f"YAML frontmatter parse error: {exc}"
|
||||
|
||||
body = text[fm_match.end() :]
|
||||
|
||||
# Body status marker: walk matches and pick the last one (body markers
|
||||
# appear after Generation Log notes that may reference earlier WIP states).
|
||||
body_status = body_date = body_description = None
|
||||
for m in STATUS_MARKER_RE.finditer(body):
|
||||
body_status = m.group(1)
|
||||
body_date = m.group(2) or m.group(3)
|
||||
body_description = (m.group(4) or "").strip()
|
||||
|
||||
audio_refs = AUDIO_REF_RE.findall(body)
|
||||
|
||||
band = frontmatter.get("band_profile", "")
|
||||
title = frontmatter.get("title", path.stem)
|
||||
|
||||
return (
|
||||
Song(
|
||||
path=path.relative_to(project_root),
|
||||
band=band,
|
||||
title=str(title),
|
||||
frontmatter_status=frontmatter.get("status"),
|
||||
frontmatter_date=str(frontmatter.get("date")) if frontmatter.get("date") else None,
|
||||
body_status=body_status,
|
||||
body_date=body_date,
|
||||
body_description=body_description,
|
||||
audio_references=audio_refs,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def load_all_songs(project_root: Path) -> tuple[list[Song], list[Finding]]:
|
||||
"""Load every songbook entry plus any parse-failure findings.
|
||||
|
||||
Songs whose YAML frontmatter fails to parse used to be silently dropped,
|
||||
which hid songs from derived sections without surfacing any error (issue #29).
|
||||
Each parse failure now becomes a songbook_drift error so sync can't pass
|
||||
while a song is invisible to the index generator.
|
||||
"""
|
||||
songbook_root = project_root / "docs" / "songbook"
|
||||
if not songbook_root.is_dir():
|
||||
return [], []
|
||||
songs: list[Song] = []
|
||||
parse_findings: list[Finding] = []
|
||||
for path in sorted(songbook_root.rglob("*.md")):
|
||||
song, error = parse_song(path, project_root)
|
||||
if song is not None:
|
||||
songs.append(song)
|
||||
elif error is not None:
|
||||
parse_findings.append(
|
||||
Finding(
|
||||
category="songbook_drift",
|
||||
severity="error",
|
||||
path=str(path.relative_to(project_root)),
|
||||
message=(
|
||||
f"{error} — song will be skipped by derived-section "
|
||||
"generators. Fix by quoting values containing "
|
||||
"special YAML characters (e.g. inner brackets)."
|
||||
),
|
||||
)
|
||||
)
|
||||
return songs, parse_findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def check_songbook_consistency(song: Song) -> list[Finding]:
|
||||
"""Frontmatter and body must agree on status + date."""
|
||||
findings: list[Finding] = []
|
||||
path = str(song.path)
|
||||
|
||||
frontmatter_published = song.frontmatter_status == "published"
|
||||
body_published = song.body_status in ("LOCKED", "PUBLISHED")
|
||||
|
||||
if song.body_status is None and frontmatter_published:
|
||||
# Missing marker is data incompleteness, not contradiction.
|
||||
# Warning keeps pre-existing songbook gaps from blocking sync.
|
||||
findings.append(
|
||||
Finding(
|
||||
category="songbook_drift",
|
||||
severity="warning",
|
||||
path=path,
|
||||
message="frontmatter status=published but no body Status marker found",
|
||||
)
|
||||
)
|
||||
elif frontmatter_published != body_published and song.body_status is not None:
|
||||
findings.append(
|
||||
Finding(
|
||||
category="songbook_drift",
|
||||
severity="error",
|
||||
path=path,
|
||||
message=(
|
||||
f"frontmatter status={song.frontmatter_status!r} disagrees with "
|
||||
f"body Status: {song.body_status}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
frontmatter_published
|
||||
and body_published
|
||||
and song.frontmatter_date
|
||||
and song.body_date
|
||||
and song.frontmatter_date != song.body_date
|
||||
):
|
||||
findings.append(
|
||||
Finding(
|
||||
category="songbook_drift",
|
||||
severity="error",
|
||||
path=path,
|
||||
message=(
|
||||
f"frontmatter date={song.frontmatter_date} disagrees with "
|
||||
f"body Published {song.body_date}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_audio_exists(song: Song, project_root: Path) -> list[Finding]:
|
||||
"""Every audio reference in a published song must exist on disk."""
|
||||
if not song.is_published:
|
||||
return []
|
||||
findings: list[Finding] = []
|
||||
for rel in song.audio_references:
|
||||
audio_path = project_root / rel
|
||||
if not audio_path.exists():
|
||||
findings.append(
|
||||
Finding(
|
||||
category="audio_missing",
|
||||
severity="warning",
|
||||
path=str(song.path),
|
||||
message=f"referenced audio file not found: {rel}",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def check_index_recently_published(
|
||||
index_text: str, songs: list[Song]
|
||||
) -> list[Finding]:
|
||||
"""Every song listed in Recently Published must match songbook ground truth."""
|
||||
findings: list[Finding] = []
|
||||
index_path = "_bmad/_memory/band-manager-sidecar/index.md"
|
||||
|
||||
# Extract the Recently Published block (from that heading until the next ## heading)
|
||||
recent_match = re.search(
|
||||
r"^##\s+Recently Published\s*\n(.*?)(?=\n##\s)",
|
||||
index_text,
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if not recent_match:
|
||||
return []
|
||||
|
||||
block = recent_match.group(1)
|
||||
|
||||
# Each entry looks like: - **Title** (YYYY-MM-DD, STATUS) — ...
|
||||
entry_re = re.compile(
|
||||
r"-\s+\*\*(?P<title>[^*]+?)\*\*\s*"
|
||||
r"\((?P<date>\d{4}-\d{2}-\d{2}),\s*(?P<status>[A-Za-z]+)",
|
||||
)
|
||||
|
||||
for match in entry_re.finditer(block):
|
||||
title = match.group("title").strip()
|
||||
claimed_date = match.group("date")
|
||||
claimed_status = match.group("status").upper()
|
||||
|
||||
# Match title allowing for minor suffix (e.g., "Observation v2" matches "Observation").
|
||||
# Multiple songs can share a title across bands (same poem, different interpretations),
|
||||
# so disambiguate by date: prefer the song whose body or frontmatter date matches
|
||||
# what the index claims.
|
||||
candidates = [
|
||||
s for s in songs if s.title == title or title.startswith(s.title)
|
||||
]
|
||||
matched = None
|
||||
for c in candidates:
|
||||
if c.body_date == claimed_date or c.frontmatter_date == claimed_date:
|
||||
matched = c
|
||||
break
|
||||
if matched is None and candidates:
|
||||
matched = candidates[0]
|
||||
if matched is None:
|
||||
findings.append(
|
||||
Finding(
|
||||
category="index_drift",
|
||||
severity="error",
|
||||
path=index_path,
|
||||
message=(
|
||||
f"Recently Published lists {title!r} but no songbook entry "
|
||||
f"has that title"
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Status must agree — index claims vs. songbook ground truth
|
||||
song_published = matched.is_published
|
||||
index_claims_published = claimed_status in ("PUBLISHED", "LOCKED")
|
||||
if song_published != index_claims_published:
|
||||
findings.append(
|
||||
Finding(
|
||||
category="index_drift",
|
||||
severity="error",
|
||||
path=index_path,
|
||||
message=(
|
||||
f"{title!r} listed as {claimed_status} but songbook shows "
|
||||
f"frontmatter={matched.frontmatter_status!r} "
|
||||
f"body_marker={matched.body_status!r}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Date must agree with body_date (authoritative) if published
|
||||
if song_published and matched.body_date and claimed_date != matched.body_date:
|
||||
findings.append(
|
||||
Finding(
|
||||
category="index_drift",
|
||||
severity="error",
|
||||
path=index_path,
|
||||
message=(
|
||||
f"{title!r} listed with date {claimed_date} but "
|
||||
f"songbook Status marker says Published {matched.body_date}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_index_catalog_counts(
|
||||
index_text: str, songs: list[Song], project_root: Path
|
||||
) -> list[Finding]:
|
||||
"""Catalog Status counts must match actual songbook + playlist ground truth."""
|
||||
findings: list[Finding] = []
|
||||
index_path = "_bmad/_memory/band-manager-sidecar/index.md"
|
||||
|
||||
# Extract the Catalog Status block
|
||||
catalog_match = re.search(
|
||||
r"^##\s+Catalog Status\s*\n(.*?)(?=\n##\s)",
|
||||
index_text,
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if not catalog_match:
|
||||
return findings
|
||||
|
||||
block = catalog_match.group(1)
|
||||
|
||||
# Check claims of the form: "**Band Name:** **N published tracks**" or "**Band:** N-track playlist"
|
||||
per_band_claims = re.finditer(
|
||||
r"\*\*(?P<band>[^:*]+):\*\*\s*"
|
||||
r"(?:\*\*)?(?P<count>\d+)[-\s](?:published\s+tracks|track\s+playlist)",
|
||||
block,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Build ground-truth counts per band (from songbook status + playlist files)
|
||||
published_per_band: dict[str, int] = {}
|
||||
all_per_band: dict[str, int] = {}
|
||||
for song in songs:
|
||||
all_per_band[song.band] = all_per_band.get(song.band, 0) + 1
|
||||
if song.is_published:
|
||||
published_per_band[song.band] = published_per_band.get(song.band, 0) + 1
|
||||
|
||||
# Band name in index → band slug mapping. Derived dynamically from
|
||||
# band profile YAMLs at runtime so this works for any project's bands,
|
||||
# not just one specific project's hardcoded list.
|
||||
band_slugs: dict[str, str] = {}
|
||||
profiles_dir = project_root / "docs" / "band-profiles"
|
||||
if profiles_dir.is_dir():
|
||||
for profile_path in sorted(profiles_dir.glob("*.yaml")):
|
||||
try:
|
||||
profile = yaml.safe_load(profile_path.read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError:
|
||||
continue
|
||||
if isinstance(profile, dict):
|
||||
display_name = (profile.get("name") or "").strip()
|
||||
if display_name:
|
||||
band_slugs[display_name] = profile_path.stem
|
||||
|
||||
for match in per_band_claims:
|
||||
band_display = match.group("band").strip()
|
||||
claimed = int(match.group("count"))
|
||||
slug = band_slugs.get(band_display)
|
||||
if slug is None:
|
||||
continue
|
||||
|
||||
# Figure out whether this is a "published tracks" claim or "playlist" claim
|
||||
is_playlist_claim = "playlist" in match.group(0).lower()
|
||||
|
||||
if is_playlist_claim:
|
||||
# Cross-check against the playlist YAML if it exists
|
||||
playlist_path = project_root / "docs" / f"{slug}-playlist.yaml"
|
||||
if playlist_path.exists():
|
||||
try:
|
||||
playlist = yaml.safe_load(playlist_path.read_text(encoding="utf-8"))
|
||||
actual_tracks = len(playlist.get("tracks", []) or [])
|
||||
if actual_tracks != claimed:
|
||||
findings.append(
|
||||
Finding(
|
||||
category="index_drift",
|
||||
severity="warning",
|
||||
path=index_path,
|
||||
message=(
|
||||
f"{band_display!r} claimed {claimed}-track playlist "
|
||||
f"but {playlist_path.name} has {actual_tracks} tracks"
|
||||
),
|
||||
)
|
||||
)
|
||||
except yaml.YAMLError:
|
||||
pass
|
||||
else:
|
||||
actual_published = published_per_band.get(slug, 0)
|
||||
if actual_published != claimed:
|
||||
findings.append(
|
||||
Finding(
|
||||
category="index_drift",
|
||||
severity="error",
|
||||
path=index_path,
|
||||
message=(
|
||||
f"{band_display!r} claimed {claimed} published tracks "
|
||||
f"but songbook has {actual_published} with status=published + body marker"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_playlist_songbook_parity(
|
||||
songs: list[Song], project_root: Path
|
||||
) -> list[Finding]:
|
||||
"""Playlist YAMLs should reference songs that exist in the songbook."""
|
||||
findings: list[Finding] = []
|
||||
playlist_dir = project_root / "docs"
|
||||
if not playlist_dir.is_dir():
|
||||
return findings
|
||||
|
||||
for playlist_path in sorted(playlist_dir.glob("*-playlist.yaml")):
|
||||
slug = playlist_path.name.replace("-playlist.yaml", "")
|
||||
try:
|
||||
playlist = yaml.safe_load(playlist_path.read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError:
|
||||
continue
|
||||
if not isinstance(playlist, dict):
|
||||
continue
|
||||
track_count = len(playlist.get("tracks", []) or [])
|
||||
songbook_count = sum(1 for s in songs if s.band == slug)
|
||||
if track_count != songbook_count:
|
||||
findings.append(
|
||||
Finding(
|
||||
category="playlist_drift",
|
||||
severity="warning",
|
||||
path=str(playlist_path.relative_to(project_root)),
|
||||
message=(
|
||||
f"{track_count} tracks in playlist YAML but "
|
||||
f"{songbook_count} songbook entries for band {slug!r}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-reference check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Inline-code reference: `path/to/file.md` or `path/to/file.md#anchor`
|
||||
# We require at least one slash or dot-segment so bare `README.md` in running
|
||||
# prose still matches but single-word code spans like `status` don't.
|
||||
INLINE_CODE_REF_RE = re.compile(r"`([^`\s]+\.md(?:#[^`]*)?)`")
|
||||
|
||||
# Markdown link reference: [text](path.md) or [text](path.md#anchor)
|
||||
# Negative lookbehind on ! avoids matching image syntax .
|
||||
MARKDOWN_LINK_REF_RE = re.compile(
|
||||
r"(?<!!)\[[^\]]*\]\(([^)\s]+?\.md(?:#[^)\s]*)?)\)"
|
||||
)
|
||||
|
||||
|
||||
def _is_external_or_anchor(ref: str) -> bool:
|
||||
"""Skip external URLs, mail links, and bare anchor references."""
|
||||
lowered = ref.strip().lower()
|
||||
if lowered.startswith(("http://", "https://", "mailto:", "ftp://", "//")):
|
||||
return True
|
||||
if lowered.startswith("#"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _strip_code_fences(text: str) -> str:
|
||||
"""Remove fenced code blocks so references inside them are not checked.
|
||||
|
||||
References inside inline backticks (single backtick spans) are still checked,
|
||||
since those are the canonical form for pointing at a file in prose. But
|
||||
multi-line ``` fences often contain examples, templates, or diffs that
|
||||
shouldn't be validated against the real filesystem.
|
||||
"""
|
||||
return re.sub(r"```.*?```", "", text, flags=re.DOTALL)
|
||||
|
||||
|
||||
def check_markdown_cross_references(project_root: Path) -> list[Finding]:
|
||||
"""Scan every markdown file under docs/ for broken cross-references.
|
||||
|
||||
Catches forward-intent references (`docs/X.md` mentioned declaratively but
|
||||
never actually created) and stale references that slipped past the delete
|
||||
reconciliation protocol.
|
||||
|
||||
Scope: `docs/` only — module source references (`src/skills/...`) are out
|
||||
of scope because they follow different drift semantics (tracked in git, not
|
||||
synced machine-to-machine).
|
||||
|
||||
Matches:
|
||||
- Inline code: `path/to/file.md` (single backtick spans)
|
||||
- Markdown links: [text](path/to/file.md) including relative `../` paths
|
||||
|
||||
Skips:
|
||||
- External URLs (http/https/mailto/ftp)
|
||||
- Anchor-only refs (#section)
|
||||
- Self-references
|
||||
- Anything inside fenced code blocks (``` ... ```)
|
||||
"""
|
||||
findings: list[Finding] = []
|
||||
docs_root = project_root / "docs"
|
||||
if not docs_root.is_dir():
|
||||
return findings
|
||||
|
||||
for md_path in sorted(docs_root.rglob("*.md")):
|
||||
try:
|
||||
text = md_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
scannable = _strip_code_fences(text)
|
||||
rel_referrer = str(md_path.relative_to(project_root))
|
||||
seen: set[str] = set()
|
||||
|
||||
for pattern in (INLINE_CODE_REF_RE, MARKDOWN_LINK_REF_RE):
|
||||
for match in pattern.finditer(scannable):
|
||||
raw_ref = match.group(1).strip()
|
||||
if _is_external_or_anchor(raw_ref):
|
||||
continue
|
||||
|
||||
# Strip URL-style anchor suffix for file existence check
|
||||
ref_path_part = raw_ref.split("#", 1)[0]
|
||||
if not ref_path_part:
|
||||
continue
|
||||
|
||||
# Deduplicate per-file so one broken reference reported once
|
||||
if ref_path_part in seen:
|
||||
continue
|
||||
seen.add(ref_path_part)
|
||||
|
||||
# Absolute-ish refs (starting with /) are machine paths — skip.
|
||||
if ref_path_part.startswith("/"):
|
||||
continue
|
||||
|
||||
# Glob/wildcard patterns (e.g. `per-candidate/*.md`) describe
|
||||
# a directory of files, not a single target — skip them.
|
||||
if any(c in ref_path_part for c in "*?["):
|
||||
continue
|
||||
|
||||
# References can be either parent-relative (`../foo.md`) or
|
||||
# project-root-relative (`docs/foo.md` written from inside
|
||||
# `docs/` — the user convention in this codebase). Try both
|
||||
# anchors; if either target exists, the reference is valid.
|
||||
project_abs = project_root.resolve()
|
||||
parent_resolved = (md_path.parent / ref_path_part).resolve()
|
||||
root_resolved = (project_root / ref_path_part).resolve()
|
||||
referrer_abs = md_path.resolve()
|
||||
|
||||
# Self-reference check against either resolution
|
||||
if parent_resolved == referrer_abs or root_resolved == referrer_abs:
|
||||
continue
|
||||
|
||||
# Does either candidate exist under the project root?
|
||||
candidates = []
|
||||
for cand in (parent_resolved, root_resolved):
|
||||
try:
|
||||
cand.relative_to(project_abs)
|
||||
except ValueError:
|
||||
continue
|
||||
candidates.append(cand)
|
||||
|
||||
if not candidates:
|
||||
# Both candidates escape the project root — out of scope
|
||||
continue
|
||||
|
||||
if any(c.exists() for c in candidates):
|
||||
continue
|
||||
|
||||
# Neither exists — report using the more informative target
|
||||
# (prefer project-root-relative when the reference looked like
|
||||
# one, else the parent-relative form).
|
||||
display_target = candidates[-1] if len(candidates) > 1 else candidates[0]
|
||||
try:
|
||||
target_display = str(display_target.relative_to(project_abs))
|
||||
except ValueError:
|
||||
target_display = str(display_target)
|
||||
findings.append(
|
||||
Finding(
|
||||
category="cross_reference_missing",
|
||||
severity="warning",
|
||||
path=rel_referrer,
|
||||
message=(
|
||||
f"reference to {raw_ref!r} → target not found: "
|
||||
f"{target_display}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_checks(project_root: Path) -> tuple[list[Finding], dict[str, int]]:
|
||||
songs, parse_findings = load_all_songs(project_root)
|
||||
|
||||
findings: list[Finding] = list(parse_findings)
|
||||
for song in songs:
|
||||
findings.extend(check_songbook_consistency(song))
|
||||
findings.extend(check_audio_exists(song, project_root))
|
||||
|
||||
index_path = project_root / "_bmad" / "_memory" / "band-manager-sidecar" / "index.md"
|
||||
if index_path.exists():
|
||||
index_text = index_path.read_text(encoding="utf-8")
|
||||
findings.extend(check_index_recently_published(index_text, songs))
|
||||
findings.extend(check_index_catalog_counts(index_text, songs, project_root))
|
||||
|
||||
findings.extend(check_playlist_songbook_parity(songs, project_root))
|
||||
findings.extend(check_markdown_cross_references(project_root))
|
||||
|
||||
stats = {
|
||||
"songs_scanned": len(songs),
|
||||
"songs_published": sum(1 for s in songs if s.is_published),
|
||||
"findings_total": len(findings),
|
||||
"findings_error": sum(1 for f in findings if f.severity == "error"),
|
||||
"findings_warning": sum(1 for f in findings if f.severity == "warning"),
|
||||
}
|
||||
return findings, stats
|
||||
|
||||
|
||||
def format_text(findings: list[Finding], stats: dict[str, int]) -> str:
|
||||
lines = [
|
||||
"Sidecar Validation Report",
|
||||
"=" * 25,
|
||||
f"Songs scanned: {stats['songs_scanned']} "
|
||||
f"({stats['songs_published']} published)",
|
||||
f"Findings: {stats['findings_total']} "
|
||||
f"({stats['findings_error']} errors, {stats['findings_warning']} warnings)",
|
||||
"",
|
||||
]
|
||||
if not findings:
|
||||
lines.append("PASS — no drift detected.")
|
||||
return "\n".join(lines)
|
||||
|
||||
# Group by category for readable output
|
||||
by_category: dict[str, list[Finding]] = {}
|
||||
for f in findings:
|
||||
by_category.setdefault(f.category, []).append(f)
|
||||
|
||||
for category, items in sorted(by_category.items()):
|
||||
lines.append(f"[{category.upper()}]")
|
||||
for f in items:
|
||||
lines.append(f" ({f.severity}) {f.path}")
|
||||
lines.append(f" {f.message}")
|
||||
lines.append("")
|
||||
|
||||
if stats["findings_error"] > 0:
|
||||
lines.append(
|
||||
f"FAIL — {stats['findings_error']} error(s) block sidecar sync."
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f"PASS (with {stats['findings_warning']} warning(s)) — no blocking errors."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate Mac sidecar index against songbook ground truth."
|
||||
)
|
||||
parser.add_argument(
|
||||
"project_root",
|
||||
nargs="?",
|
||||
default=".",
|
||||
help="Project root directory (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["text", "json"],
|
||||
default="text",
|
||||
help="Output format (default: text)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--warn-only",
|
||||
action="store_true",
|
||||
help="Exit 0 even when errors are found (for advisory runs)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
project_root = Path(args.project_root).resolve()
|
||||
if not project_root.is_dir():
|
||||
print(f"ERROR: project root not found: {project_root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
findings, stats = run_checks(project_root)
|
||||
|
||||
if args.format == "json":
|
||||
payload: dict[str, Any] = {
|
||||
"status": "pass" if stats["findings_error"] == 0 else "fail",
|
||||
"stats": stats,
|
||||
"findings": [f.to_dict() for f in findings],
|
||||
}
|
||||
print(json.dumps(payload, indent=2))
|
||||
else:
|
||||
print(format_text(findings, stats))
|
||||
|
||||
if args.warn_only:
|
||||
return 0
|
||||
return 0 if stats["findings_error"] == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user