diff --git a/CHANGELOG.md b/CHANGELOG.md index 6255ea2..3c72597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to BMad Manticore are documented here. Dates are ISO (YYYY-MM-DD). +## 1.0.1 - 2026-07-07 + +### Fixed + +- 0.x migration now refreshes the creator's existing format profiles: the new `merge_profile_frontmatter.py` adds the frontmatter keys introduced in 1.0 (`beat-types`, `density`) that mc-beats requires, copying them from the shipped profiles without touching the creator's own key values, prose, or Learnings. Previously the never-overwrite rule left 0.x profiles missing keys the 1.0 stages need. +- 0.x migration offers to move a pre-1.0 brand-root series template (for example `thumbnail-template.md`) into `{brand-path}/templates/.md`, where mc-package's series contract looks for it. + ## 1.0.0 - 2026-07-07 ### Breaking changes diff --git a/skills/mc-setup/SKILL.md b/skills/mc-setup/SKILL.md index 4346cb2..503f23a 100644 --- a/skills/mc-setup/SKILL.md +++ b/skills/mc-setup/SKILL.md @@ -32,7 +32,9 @@ An existing `[modules.manticore]` that is missing any of the 1.0 tables (`[rende - If `[transcription] api-key-env` names a key the configured local provider never uses, blank it (the 1.0 default; metered keys are set only when a metered provider is chosen). - If the studio recorded interview footage against the pre-1.0 marker cue ("question from claude"), offer the step 3 marker-cue question and record the `--marker-cues` override in `{project-root}/_bmad/custom/mc-cut.toml` so cutplan keeps segmenting that footage. - If the `[assets]` lanes still carry pre-1.0 defaults pointing at a metered API the creator never opted into or verified, flag that in the summary and offer step 5 to repoint them at a registered CLI tool (or leave them empty so mc-assets asks at farming time). -- Run the delta interview: step 3b (render consent), then step 3c (the video style interview). +- Refresh the creator's format profiles surgically: for every profile in `{formats-path}` that also ships in `{skill-root}/assets/formats/`, run `uv run {skill-root}/scripts/merge_profile_frontmatter.py --shipped {skill-root}/assets/formats/.md --studio {formats-path}/.md`. It adds only the frontmatter keys new in 1.0 (`beat-types`, `density`, and any future ones) that stages like mc-beats require, never overwriting an existing key, the creator's prose, or the Learnings. Then copy any newly shipped profiles that do not exist in `{formats-path}` (the step 4 rule). +- A pre-1.0 series or thumbnail template at the brand root (for example `thumbnail-template.md`) predates the `{brand-path}/templates/.md` contract: offer to move it there, named for the series it describes, so mc-package finds it. +- Run the delta interview: step 3b (render consent), then step 3c (the video style interview), then step 3d (audio lanes). - Scaffold `{brand-path}/production-bible.md` per step 4, seeded from the brand assets that already exist (tokens.json, shipped overlays, exemplars, format-profile learnings) plus the step 3c answers, not from a blank slate. - Offer, without forcing, the other new builds: headshot collection (step 4), the guided voice bible (step 4b), `.env.example` (step 7). - Leave every other existing value untouched; those are already the creator's answers. diff --git a/skills/mc-setup/scripts/merge_profile_frontmatter.py b/skills/mc-setup/scripts/merge_profile_frontmatter.py new file mode 100644 index 0000000..69cd333 --- /dev/null +++ b/skills/mc-setup/scripts/merge_profile_frontmatter.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["pyyaml>=6"] +# /// +"""Merge missing frontmatter keys from a shipped format profile into the +creator's studio copy. + +The never-overwrite rule protects the creator's format profiles, but 0.x +copies predate frontmatter keys that 1.0 stages require (mc-beats reads +`beat-types` and `density` from the profile). This script closes that gap +surgically during the 0.x migration: + +- Only top-level frontmatter keys MISSING from the studio copy are added, + copied as their raw lines from the shipped profile (formatting preserved). +- Existing keys always win: a key present in the studio copy is never + touched, whatever its value. +- The body (prose, Templates, Learnings) is never modified, byte for byte. + +Usage: + uv run merge_profile_frontmatter.py --shipped + --studio [--dry-run] + +Prints a JSON summary {studio, added, dry_run}. --dry-run reports what +would be added and writes nothing. + +Exit codes: 0 merged or nothing to add, 1 file has no frontmatter block, +2 usage error. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +import yaml + +TOP_KEY_RE = re.compile(r"^([A-Za-z0-9_-]+):") + + +def die(msg: str, code: int = 2) -> None: + print(msg, file=sys.stderr) + sys.exit(code) + + +def split_frontmatter(text: str, path: Path) -> tuple[str, str]: + """Return (frontmatter_lines_text, body_text_including_closing_delim).""" + if not text.startswith("---\n"): + die(f"error: {path} has no frontmatter block", 1) + end = text.find("\n---", 4) + if end == -1: + die(f"error: {path} frontmatter never closes", 1) + return text[4:end + 1], text[end + 1:] + + +def top_level_blocks(fm_text: str) -> dict[str, str]: + """Map each top-level key to its raw lines (key line plus continuation).""" + blocks: dict[str, str] = {} + current = None + for line in fm_text.splitlines(keepends=True): + m = TOP_KEY_RE.match(line) + if m: + current = m.group(1) + blocks[current] = line + elif current is not None: + blocks[current] += line + return blocks + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--shipped", required=True, type=Path) + ap.add_argument("--studio", required=True, type=Path) + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + for p in (args.shipped, args.studio): + if not p.is_file(): + die(f"error: {p} not found") + + shipped_fm, _ = split_frontmatter(args.shipped.read_text(encoding="utf-8"), + args.shipped) + studio_text = args.studio.read_text(encoding="utf-8") + studio_fm, studio_body = split_frontmatter(studio_text, args.studio) + + shipped_keys = yaml.safe_load(shipped_fm) or {} + studio_keys = yaml.safe_load(studio_fm) or {} + if not isinstance(shipped_keys, dict) or not isinstance(studio_keys, dict): + die("error: frontmatter must be a YAML mapping", 1) + + missing = [k for k in shipped_keys if k not in studio_keys] + if missing: + blocks = top_level_blocks(shipped_fm) + addition = "".join(blocks[k] for k in missing) + if not addition.endswith("\n"): + addition += "\n" + new_fm = studio_fm if studio_fm.endswith("\n") else studio_fm + "\n" + # studio_body starts at the closing delimiter line; reassemble exactly. + merged = "---\n" + new_fm + addition + studio_body + if not args.dry_run: + args.studio.write_text(merged, encoding="utf-8") + + print(json.dumps({"studio": str(args.studio), "added": missing, + "dry_run": args.dry_run})) + + +if __name__ == "__main__": + main() diff --git a/skills/mc-setup/scripts/tests/test-merge_profile_frontmatter.py b/skills/mc-setup/scripts/tests/test-merge_profile_frontmatter.py new file mode 100644 index 0000000..40bc430 --- /dev/null +++ b/skills/mc-setup/scripts/tests/test-merge_profile_frontmatter.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Tests for merge_profile_frontmatter.py: missing keys merged with shipped +formatting preserved, existing keys and the body never touched, idempotent +re-runs, and the no-frontmatter error path. Runs the script via uv so its +pyyaml dependency resolves.""" +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "merge_profile_frontmatter.py" + +SHIPPED = """--- +format: talking-head +stages: [new, braindump, outline, script, record, cut, beats, graphics, assets, package, final, retro] +engine_overlays: hyperframes +generated_broll: allowed +beat-types: [popup, diagram, lower-third, stat-card, cta] +density: + high: "10-20s" + medium: "20-45s" + low: "45-90s" + note: "Seconds per graphic beat. Front-loaded." +--- + +# Format: talking-head + +Shipped prose that must never reach the studio copy. +""" + +STUDIO = """--- +format: talking-head +stages: [new, braindump, outline, script, record, cut, beats, graphics, assets, package, final, retro] +engine_overlays: remotion +generated_broll: banned +--- + +# Format: talking-head + +The creator's own prose. + +## Learnings + +- 2026-07-06: the creator's hard-won learning stays put. +""" + + +def run(shipped: Path, studio: Path, *extra: str): + return subprocess.run( + ["uv", "run", str(SCRIPT), "--shipped", str(shipped), + "--studio", str(studio), *extra], + capture_output=True, text=True) + + +class TestMerge(unittest.TestCase): + def setUp(self): + self.td = tempfile.TemporaryDirectory() + root = Path(self.td.name) + self.shipped = root / "shipped.md" + self.studio = root / "studio.md" + self.shipped.write_text(SHIPPED) + self.studio.write_text(STUDIO) + + def tearDown(self): + self.td.cleanup() + + def test_missing_keys_merged_existing_and_body_untouched(self): + r = run(self.shipped, self.studio) + self.assertEqual(r.returncode, 0, r.stderr) + info = json.loads(r.stdout) + self.assertEqual(info["added"], ["beat-types", "density"]) + merged = self.studio.read_text() + self.assertIn("beat-types: [popup, diagram, lower-third, stat-card, cta]", merged) + self.assertIn('medium: "20-45s"', merged) + self.assertIn("engine_overlays: remotion", merged) # studio value wins + self.assertIn("generated_broll: banned", merged) # studio value wins + self.assertNotIn("Shipped prose", merged) + self.assertIn("the creator's hard-won learning stays put", merged) + body = merged.split("\n---\n", 1)[1] + self.assertEqual(body, STUDIO.split("\n---\n", 1)[1]) # body byte-identical + + def test_second_run_adds_nothing(self): + run(self.shipped, self.studio) + first = self.studio.read_text() + r = run(self.shipped, self.studio) + self.assertEqual(json.loads(r.stdout)["added"], []) + self.assertEqual(self.studio.read_text(), first) + + def test_dry_run_writes_nothing(self): + r = run(self.shipped, self.studio, "--dry-run") + info = json.loads(r.stdout) + self.assertEqual(info["added"], ["beat-types", "density"]) + self.assertTrue(info["dry_run"]) + self.assertEqual(self.studio.read_text(), STUDIO) + + def test_no_frontmatter_errors(self): + self.studio.write_text("# Just a body\n") + r = run(self.shipped, self.studio) + self.assertEqual(r.returncode, 1) + self.assertIn("no frontmatter", r.stderr) + + +if __name__ == "__main__": + unittest.main()