Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<series>.md`, where mc-package's series contract looks for it.

## 1.0.0 - 2026-07-07

### Breaking changes
Expand Down
4 changes: 3 additions & 1 deletion skills/mc-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.md --studio {formats-path}/<name>.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/<series>.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.
Expand Down
110 changes: 110 additions & 0 deletions skills/mc-setup/scripts/merge_profile_frontmatter.py
Original file line number Diff line number Diff line change
@@ -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 <packaged profile.md>
--studio <creator's profile.md> [--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()
108 changes: 108 additions & 0 deletions skills/mc-setup/scripts/tests/test-merge_profile_frontmatter.py
Original file line number Diff line number Diff line change
@@ -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()
Loading