scripts/bootstrap_pipeline.py
scripts/bootstrap_pipeline.pyBrowse 12 files
4,884 tokens
18,671 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""3Bootstrap a video production kanban from a structured plan JSON.4 5Reads a plan.json describing the team + brief, expands templates from6../assets/, and writes a setup.sh that creates Hermes profiles and fires the7initial kanban task.8 9Profile-config patching, SOUL.md-per-profile, TEAM.md task-graph convention,10and the `hermes kanban create --workspace dir:` initial-task pattern are11adapted from alt-glitch's NousResearch/kanban-video-pipeline.12 13Usage:14 bootstrap_pipeline.py plan.json [--out setup.sh]15 16The plan.json schema is documented inline below — see the `validate_plan`17function. A minimal example:18 19 {20 "title": "Q3 Product Teaser",21 "slug": "q3-product-teaser",22 "tenant": "q3-product-teaser",23 "duration_s": 30,24 "aspect": "1:1",25 "resolution": "1080x1080",26 "fps": 30,27 "team": [28 {29 "profile": "director",30 "role": "director",31 "toolsets": ["kanban", "terminal", "file"],32 "skills": [],33 "responsibilities": "...",34 "inputs": "brief.md, TEAM.md, taste/",35 "outputs": "kanban tasks for the team"36 },37 ...38 ],39 "scenes": [40 {"n": 1, "time": "0:00-0:08", "content": "...", "tool": "renderer-ascii"},41 ...42 ],43 "audio": {"approach": "voiceover + music bed", "vo": "ElevenLabs Lily",44 "music": "license-free", "sfx": "n/a"},45 "deliverables": [46 {"format": "mp4", "resolution": "1080x1080", "notes": "primary"}47 ],48 "api_keys_required": ["ELEVENLABS_API_KEY", "OPENROUTER_API_KEY"],49 "brief_extra": {50 "concept_one_liner": "...",51 "emotional_north_star": "...",52 "visual_refs": "...",53 "tone": "...",54 "brand_constraints": "..."55 }56 }57"""58from __future__ import annotations59 60import argparse61import json62import os63import re64import sys65from pathlib import Path66 67ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"68 69 70def load_template(name: str) -> str:71 return (ASSETS_DIR / name).read_text(encoding="utf-8")72 73 74PROFILE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")75SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]+$")76 77 78def validate_plan(plan: dict) -> list[str]:79 """Return a list of validation error strings; empty list = valid."""80 errors = []81 required_top = ["title", "slug", "tenant", "duration_s", "aspect",82 "resolution", "fps", "team", "scenes", "audio",83 "deliverables"]84 for k in required_top:85 if k not in plan:86 errors.append(f"missing required key: {k}")87 88 if "team" in plan:89 if not isinstance(plan["team"], list) or not plan["team"]:90 errors.append("team must be a non-empty list")91 else:92 roles = [t.get("role") for t in plan["team"]]93 if "director" not in roles:94 errors.append("team must include a director role")95 seen_profiles = set()96 for i, t in enumerate(plan["team"]):97 for k in ["profile", "role", "toolsets", "skills",98 "responsibilities"]:99 if k not in t:100 errors.append(f"team[{i}] missing {k}")101 # Profile name must match Hermes's regex (lowercase102 # alphanumeric + hyphens + underscores, up to 64 chars).103 if "profile" in t:104 if not PROFILE_NAME_RE.match(t["profile"]):105 errors.append(106 f"team[{i}].profile {t['profile']!r} must match "107 f"[a-z0-9][a-z0-9_-]{{0,63}} per Hermes profile rules"108 )109 if t["profile"] in seen_profiles:110 errors.append(111 f"team[{i}].profile {t['profile']!r} is duplicated"112 )113 seen_profiles.add(t["profile"])114 # Toolsets / skills must be lists, not strings.115 if "toolsets" in t and not isinstance(t["toolsets"], list):116 errors.append(117 f"team[{i}].toolsets must be a list of strings"118 )119 if "skills" in t and not isinstance(t["skills"], list):120 errors.append(121 f"team[{i}].skills must be a list of strings"122 )123 124 if "slug" in plan:125 if not SLUG_RE.match(plan["slug"]):126 errors.append("slug must be lowercase, hyphenated, "127 "starting with [a-z0-9]")128 129 return errors130 131 132def render_brief(plan: dict) -> str:133 """Render brief.md from the plan."""134 tmpl = load_template("brief.md.tmpl")135 extra = plan.get("brief_extra", {})136 137 # Scene table rows138 scene_rows = []139 for s in plan["scenes"]:140 scene_rows.append(141 f"| {s.get('n', '?')} | {s.get('time', '?')} | "142 f"{s.get('content', '')} | {s.get('tool', '')} | "143 f"{s.get('audio', '')} | {s.get('notes', '')} |"144 )145 scene_table = "\n".join(scene_rows) if scene_rows else "_(none yet)_"146 147 # Deliverable rows148 deliv_rows = []149 for d in plan["deliverables"]:150 deliv_rows.append(151 f"| {d.get('format', '?')} | {d.get('resolution', '?')} | "152 f"{d.get('notes', '')} |"153 )154 deliv_table = "\n".join(deliv_rows) if deliv_rows else "_(none)_"155 156 # Replacements (single-pass)157 replacements = {158 "TITLE": plan["title"],159 "SLUG": plan["slug"],160 "TENANT": plan["tenant"],161 "WORKSPACE": f"~/projects/video-pipeline/{plan['slug']}",162 "ONE_LINE_PITCH": extra.get("concept_one_liner", "_(TBD)_"),163 "EMOTIONAL_NORTH_STAR": extra.get("emotional_north_star", "_(TBD)_"),164 "DURATION_S": str(plan["duration_s"]),165 "ASPECT": plan["aspect"],166 "RESOLUTION": plan["resolution"],167 "FPS": str(plan["fps"]),168 "PLATFORMS": extra.get("platforms", "_(TBD)_"),169 "DEADLINE": extra.get("deadline", "_(none)_"),170 "QUALITY_BAR": extra.get("quality_bar", "polished"),171 "VISUAL_REFS": extra.get("visual_refs", "_(none)_"),172 "TONE": extra.get("tone", "_(TBD)_"),173 "BRAND_CONSTRAINTS": extra.get("brand_constraints", "_(none)_"),174 "AESTHETIC_RULES": extra.get("aesthetic_rules", "_(TBD)_"),175 "AUDIO_APPROACH": plan["audio"].get("approach", "_(TBD)_"),176 "VO_DETAILS": plan["audio"].get("vo", "_(n/a)_"),177 "MUSIC_DETAILS": plan["audio"].get("music", "_(n/a)_"),178 "SFX_DETAILS": plan["audio"].get("sfx", "_(n/a)_"),179 "PRIMARY_FORMAT": plan["deliverables"][0]["format"],180 "PRIMARY_RES": plan["deliverables"][0]["resolution"],181 "ALT_FORMAT_1": (plan["deliverables"][1]["format"]182 if len(plan["deliverables"]) > 1 else "_(none)_"),183 "ALT_RES_1": (plan["deliverables"][1]["resolution"]184 if len(plan["deliverables"]) > 1 else ""),185 "ALT_NOTES_1": (plan["deliverables"][1].get("notes", "")186 if len(plan["deliverables"]) > 1 else ""),187 "API_KEYS_REQUIRED": ", ".join(plan.get("api_keys_required", [])) or "none",188 "EXT_DEPS": extra.get("ext_deps", "ffmpeg, Python 3.11+"),189 "SOURCE_ASSETS": extra.get("source_assets", "_(none)_"),190 }191 out = tmpl192 for k, v in replacements.items():193 out = out.replace("{{" + k + "}}", str(v))194 195 # Scene + deliv tables: replace the placeholder row in the template196 out = re.sub(197 r"\|\s*1\s*\|\s*0:00–0:0X.+?\n\|\s*2\s*\|.+?\n",198 scene_table + "\n",199 out, flags=re.DOTALL,200 )201 return out202 203 204def render_team_md(plan: dict) -> str:205 """Render TEAM.md from the team list + scene → tool mapping."""206 lines = [f"# Team & Task Graph — {plan['title']}", "", "## Team", ""]207 for t in plan["team"]:208 skills = (209 f"loads `{', '.join(t['skills'])}`"210 if t["skills"] else "no skills required"211 )212 lines.append(213 f"- `{t['profile']}` — {t['responsibilities']} ({skills})"214 )215 lines.extend(["", "## Task Graph", "", "```"])216 217 # Build a simple task graph based on conventions218 profiles_by_role = {t["role"]: t["profile"] for t in plan["team"]}219 director = profiles_by_role.get("director", "director")220 lines.append(f"T0 {director} — decompose")221 222 next_id = 1223 parents_for_renderer: list[str] = ["T0"]224 225 if "cinematographer" in profiles_by_role:226 cid = f"T{next_id}"227 lines.append(228 f"{cid:5} {profiles_by_role['cinematographer']} — visual spec for all scenes (parent: T0)"229 )230 parents_for_renderer = [cid]231 next_id += 1232 233 if "music-supervisor" in profiles_by_role:234 cid = f"T{next_id}"235 lines.append(236 f"{cid:5} {profiles_by_role['music-supervisor']} — track analysis + beats.json (parent: T0)"237 )238 next_id += 1239 ms_id = cid240 else:241 ms_id = None242 243 # Scenes244 scene_ids = []245 for s in plan["scenes"]:246 cid = f"T{next_id}"247 renderer_profile = s.get("tool") or "renderer"248 # Lookup the actual profile name249 for t in plan["team"]:250 if t["role"] == renderer_profile or t["profile"] == renderer_profile:251 renderer_profile = t["profile"]252 break253 parents = parents_for_renderer + ([ms_id] if ms_id else [])254 parent_str = ", ".join(parents)255 lines.append(256 f"{cid:5} {renderer_profile} — scene {s.get('n', '?')}: "257 f"{s.get('content', '')[:50]} (parents: {parent_str})"258 )259 scene_ids.append(cid)260 next_id += 1261 262 # VO + audio mix263 if "voice-talent" in profiles_by_role:264 vo_id = f"T{next_id}"265 lines.append(f"{vo_id:5} {profiles_by_role['voice-talent']} — narration (parent: T0)")266 next_id += 1267 else:268 vo_id = None269 270 if "audio-mixer" in profiles_by_role:271 am_id = f"T{next_id}"272 am_parents = [p for p in [ms_id, vo_id] if p]273 lines.append(274 f"{am_id:5} {profiles_by_role['audio-mixer']} — mix audio (parents: {', '.join(am_parents)})"275 )276 next_id += 1277 else:278 am_id = None279 280 # Editor281 if "editor" in profiles_by_role:282 ed_id = f"T{next_id}"283 ed_parents = scene_ids + [p for p in [am_id, vo_id, ms_id] if p and p not in scene_ids]284 lines.append(285 f"{ed_id:5} {profiles_by_role['editor']} — assemble + mux (parents: {', '.join(ed_parents)})"286 )287 next_id += 1288 else:289 ed_id = None290 291 # Captioner292 if "captioner" in profiles_by_role and ed_id:293 cap_id = f"T{next_id}"294 lines.append(295 f"{cap_id:5} {profiles_by_role['captioner']} — SRT + burn (parent: {ed_id})"296 )297 next_id += 1298 last = cap_id299 else:300 last = ed_id301 302 # Reviewer303 if "reviewer" in profiles_by_role and last:304 rv_id = f"T{next_id}"305 lines.append(306 f"{rv_id:5} {profiles_by_role['reviewer']} — final QA (parent: {last})"307 )308 309 lines.append("```")310 lines.extend([311 "",312 "## Per-task workspace requirement",313 "",314 "All `kanban_create` calls MUST pass:",315 "```",316 'workspace_kind="dir"',317 f'workspace_path="$HOME/projects/video-pipeline/{plan["slug"]}"',318 f'tenant="{plan["tenant"]}"',319 "```",320 ])321 return "\n".join(lines)322 323 324def render_setup_sh(plan: dict, brief_md: str, team_md: str) -> str:325 """Render setup.sh from the plan."""326 tmpl = load_template("setup.sh.tmpl")327 328 # API key checks329 key_checks = []330 for key in plan.get("api_keys_required", []):331 key_checks.append(f'check_key {key} hermes {key} || exit 1')332 key_checks_str = "\n".join(key_checks) if key_checks else "# (no API keys required)"333 334 # Scene dirs335 scene_dir_lines = []336 for s in plan["scenes"]:337 n = s.get("n", "?")338 scene_dir_lines.append(f'mkdir -p "$WORKSPACE/scenes/scene-{n:02d}"/checkpoints')339 scene_dirs = "\n".join(scene_dir_lines) if scene_dir_lines else ""340 341 # Profile create342 profile_creates = []343 for t in plan["team"]:344 profile_creates.append(345 f'hermes profile create {t["profile"]} --clone 2>/dev/null || true'346 )347 348 # Profile config — emit JSON arrays so the bash function can pass them349 # safely through to the Python YAML patcher.350 profile_configs = []351 for t in plan["team"]:352 ts_json = json.dumps(t["toolsets"])353 sk_json = json.dumps(t["skills"])354 # Use single-quoted bash strings; JSON only contains "/[/], no single355 # quotes, so this is safe.356 profile_configs.append(357 f"configure_profile {t['profile']!r} {ts_json!r} {sk_json!r}"358 )359 360 # SOUL writes — uses heredocs per profile361 soul_writes = []362 for t in plan["team"]:363 soul_writes.append(364 f'cat > "$HOME/.hermes/profiles/{t["profile"]}/SOUL.md" <<\'SOUL_EOF\'\n'365 f"{render_soul_md(t, plan)}\n"366 f"SOUL_EOF\n"367 f'echo " ✓ SOUL.md for {t["profile"]}"'368 )369 370 # Taste writes (placeholder; real content optional)371 taste_writes = (372 'cat > "$WORKSPACE/taste/brand-guide.md" <<\'TASTE_EOF\'\n'373 '# Brand Guide\n\n'374 '_(Populate with project-specific colors, typography, motion rules)_\n'375 'TASTE_EOF\n'376 'cat > "$WORKSPACE/taste/emotional-dna.md" <<\'DNA_EOF\'\n'377 '# Emotional DNA\n\n'378 '_(What this piece should FEEL like — populate from the brief.)_\n'379 'DNA_EOF'380 )381 382 # Asset copies — leave empty by default; user fills in383 asset_copies = "# Add cp/rsync commands here for any provided assets"384 385 out = tmpl386 out = out.replace("{{TITLE}}", plan["title"])387 out = out.replace("{{SLUG}}", plan["slug"])388 out = out.replace("{{TENANT}}", plan["tenant"])389 out = out.replace("{{WORKSPACE}}", f"~/projects/video-pipeline/{plan['slug']}")390 out = out.replace("{{KEY_CHECKS}}", key_checks_str)391 out = out.replace("{{SCENE_DIRS}}", scene_dirs)392 out = out.replace("{{PROFILE_CREATE_COMMANDS}}", "\n".join(profile_creates))393 out = out.replace("{{PROFILE_CONFIG_COMMANDS}}", "\n".join(profile_configs))394 out = out.replace("{{SOUL_WRITES}}", "\n".join(soul_writes))395 out = out.replace("{{BRIEF_CONTENTS}}", brief_md)396 out = out.replace("{{TEAM_CONTENTS}}", team_md)397 out = out.replace("{{TASTE_WRITES}}", taste_writes)398 out = out.replace("{{ASSET_COPIES}}", asset_copies)399 400 return out401 402 403def render_soul_md(team_member: dict, plan: dict) -> str:404 """Render a profile's SOUL.md from a team member dict + plan context."""405 tmpl = load_template("soul.md.tmpl")406 role = team_member["role"]407 408 common_rules = (409 "- **Read the brief and team graph** before doing anything else.\n"410 "- **Pass `workspace_kind=\"dir\"` and `workspace_path` on every "411 "`kanban_create` call.** This keeps the team in one shared workspace.\n"412 f"- **Use tenant `{plan['tenant']}`** on every kanban call.\n"413 "- **Write outputs to predictable paths.** Other profiles depend on "414 "your filename conventions.\n"415 "- **Emit heartbeats** during long-running work. Renderers should "416 "report frame counts; editors should report assembly progress.\n"417 )418 419 if role == "director":420 common_rules += (421 "- **Do not execute the work yourself.** For every concrete task, "422 "create a kanban task and assign it to the appropriate profile.\n"423 "- **Decompose, route, comment, approve — that's the whole job.**\n"424 "- **Read TEAM.md** for the canonical task graph. Do not invent "425 "new roles unless the brief truly demands it.\n"426 )427 428 common_commands = (429 "```bash\n"430 "# Inspect a clip\n"431 "ffprobe -v quiet -show_entries format=duration -show_entries "432 "stream=codec_name,width,height,r_frame_rate <file.mp4>\n"433 "\n"434 "# Extract a frame for QA\n"435 "ffmpeg -y -i <input.mp4> -vf \"select='eq(n,30)'\" -vsync vfr <out.png>\n"436 "```"437 )438 439 out = tmpl440 out = out.replace("{{ROLE_NAME}}", role)441 out = out.replace("{{ROLE_RESPONSIBILITIES}}", team_member["responsibilities"])442 out = out.replace("{{INPUTS_READ}}", team_member.get("inputs", "_(see brief)_"))443 out = out.replace("{{OUTPUTS_PRODUCED}}", team_member.get("outputs", "_(see brief)_"))444 out = out.replace("{{TOOLSETS}}", ", ".join(team_member["toolsets"]))445 out = out.replace(446 "{{SKILLS}}",447 ", ".join(team_member["skills"]) if team_member["skills"] else "(none)"448 )449 out = out.replace(450 "{{EXTERNAL_TOOLS}}",451 team_member.get("external_tools", "ffmpeg, ffprobe (via terminal)")452 )453 out = out.replace(454 "{{ROLE_RULES}}",455 team_member.get("role_rules", "_(see TEAM.md and brief.md)_")456 )457 out = out.replace("{{COMMON_RULES}}", common_rules)458 out = out.replace("{{COMMON_COMMANDS}}", common_commands)459 return out460 461 462def main():463 ap = argparse.ArgumentParser(description=__doc__,464 formatter_class=argparse.RawDescriptionHelpFormatter)465 ap.add_argument("plan_json", help="Path to plan.json")466 ap.add_argument("--out", default="setup.sh",467 help="Output path for setup.sh (default: ./setup.sh)")468 ap.add_argument("--brief-out", default=None,469 help="Write brief.md alongside (default: skipped)")470 ap.add_argument("--team-out", default=None,471 help="Write TEAM.md alongside (default: skipped)")472 args = ap.parse_args()473 474 plan = json.loads(Path(args.plan_json).read_text(encoding="utf-8"))475 errors = validate_plan(plan)476 if errors:477 print("Plan validation failed:", file=sys.stderr)478 for e in errors:479 print(f" - {e}", file=sys.stderr)480 sys.exit(2)481 482 brief = render_brief(plan)483 team = render_team_md(plan)484 setup = render_setup_sh(plan, brief, team)485 486 Path(args.out).write_text(setup, encoding="utf-8")487 os.chmod(args.out, 0o755)488 print(f"Wrote {args.out}")489 490 if args.brief_out:491 Path(args.brief_out).write_text(brief, encoding="utf-8")492 print(f"Wrote {args.brief_out}")493 if args.team_out:494 Path(args.team_out).write_text(team, encoding="utf-8")495 print(f"Wrote {args.team_out}")496 497 498if __name__ == "__main__":499 main()500