Files
Momento/.agents/skills/suno-agent-band-manager/scripts/regenerate-index-sections.py
Antigravity bd495be965
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
- 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
2026-05-16 12:59:30 +00:00

434 lines
16 KiB
Python

#!/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())