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

- 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:
Antigravity
2026-05-16 12:59:30 +00:00
parent 1fcea6ed7d
commit bd495be965
2284 changed files with 395285 additions and 2327 deletions

View 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)

View 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()

View 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)

View 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())

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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)

View 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 ![alt](...).
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())