init-sanctum-template.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. #!/usr/bin/env python3
  2. """
  3. First Breath — Deterministic sanctum scaffolding.
  4. This script runs BEFORE the conversational awakening. It creates the sanctum
  5. folder structure, copies template files with config values substituted,
  6. copies all capability files and their supporting references into the sanctum,
  7. and auto-generates CAPABILITIES.md from capability prompt frontmatter.
  8. After this script runs, the sanctum is fully self-contained — the agent does
  9. not depend on the skill bundle location for normal operation.
  10. This initializes the agent's runtime sanctum memory, not build-time config. It
  11. reads config.yaml and config.user.yaml strictly to substitute values into the
  12. sanctum templates, and it never writes or authors any config file. Build-time
  13. customization is owned by customize.toml, a separate surface this script never
  14. touches.
  15. Usage:
  16. uv run init-sanctum.py <project-root> <skill-path>
  17. project-root: The root of the project (where _bmad/ lives)
  18. skill-path: Path to the skill directory (where SKILL.md, references/, assets/ live)
  19. """
  20. import sys
  21. import re
  22. import shutil
  23. from datetime import date
  24. from pathlib import Path
  25. # --- Agent-specific configuration (set by builder) ---
  26. SKILL_NAME = "{skillName}"
  27. SANCTUM_DIR = SKILL_NAME
  28. # Files that stay in the skill bundle (only used during First Breath)
  29. SKILL_ONLY_FILES = {"{skill-only-files}"}
  30. TEMPLATE_FILES = [
  31. {template-files-list}
  32. ]
  33. # Whether the owner can teach this agent new capabilities
  34. EVOLVABLE = {evolvable}
  35. # --- End agent-specific configuration ---
  36. def parse_yaml_config(config_path: Path) -> dict:
  37. """Simple YAML key-value parser. Handles top-level scalar values only."""
  38. config = {}
  39. if not config_path.exists():
  40. return config
  41. with open(config_path) as f:
  42. for line in f:
  43. line = line.strip()
  44. if not line or line.startswith("#"):
  45. continue
  46. if ":" in line:
  47. key, _, value = line.partition(":")
  48. value = value.strip().strip("'\"")
  49. if value:
  50. config[key.strip()] = value
  51. return config
  52. def parse_frontmatter(file_path: Path) -> dict:
  53. """Extract YAML frontmatter from a markdown file."""
  54. meta = {}
  55. with open(file_path) as f:
  56. content = f.read()
  57. match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
  58. if not match:
  59. return meta
  60. for line in match.group(1).strip().split("\n"):
  61. if ":" in line:
  62. key, _, value = line.partition(":")
  63. meta[key.strip()] = value.strip().strip("'\"")
  64. return meta
  65. def copy_references(source_dir: Path, dest_dir: Path) -> list[str]:
  66. """Copy all reference files (except skill-only files) into the sanctum."""
  67. dest_dir.mkdir(parents=True, exist_ok=True)
  68. copied = []
  69. for source_file in sorted(source_dir.iterdir()):
  70. if source_file.name in SKILL_ONLY_FILES:
  71. continue
  72. if source_file.is_file():
  73. shutil.copy2(source_file, dest_dir / source_file.name)
  74. copied.append(source_file.name)
  75. return copied
  76. def copy_scripts(source_dir: Path, dest_dir: Path) -> list[str]:
  77. """Copy any scripts the capabilities might use into the sanctum."""
  78. if not source_dir.exists():
  79. return []
  80. dest_dir.mkdir(parents=True, exist_ok=True)
  81. copied = []
  82. for source_file in sorted(source_dir.iterdir()):
  83. if source_file.is_file() and source_file.name != "init-sanctum.py":
  84. shutil.copy2(source_file, dest_dir / source_file.name)
  85. copied.append(source_file.name)
  86. return copied
  87. def discover_capabilities(references_dir: Path, sanctum_refs_path: str) -> list[dict]:
  88. """Scan references/ for capability prompt files with frontmatter."""
  89. capabilities = []
  90. for md_file in sorted(references_dir.glob("*.md")):
  91. if md_file.name in SKILL_ONLY_FILES:
  92. continue
  93. meta = parse_frontmatter(md_file)
  94. if meta.get("name") and meta.get("code"):
  95. capabilities.append({
  96. "name": meta["name"],
  97. "description": meta.get("description", ""),
  98. "code": meta["code"],
  99. "source": f"{sanctum_refs_path}/{md_file.name}",
  100. })
  101. return capabilities
  102. def generate_capabilities_md(capabilities: list[dict], evolvable: bool) -> str:
  103. """Generate CAPABILITIES.md content from discovered capabilities."""
  104. lines = [
  105. "# Capabilities",
  106. "",
  107. "## Built-in",
  108. "",
  109. "| Code | Name | Description | Source |",
  110. "|------|------|-------------|--------|",
  111. ]
  112. for cap in capabilities:
  113. lines.append(
  114. f"| [{cap['code']}] | {cap['name']} | {cap['description']} | `{cap['source']}` |"
  115. )
  116. if evolvable:
  117. lines.extend([
  118. "",
  119. "## Learned",
  120. "",
  121. "_Capabilities added by the owner over time. Prompts live in `capabilities/`._",
  122. "",
  123. "| Code | Name | Description | Source | Added |",
  124. "|------|------|-------------|--------|-------|",
  125. "",
  126. "## How to Add a Capability",
  127. "",
  128. 'Tell me "I want you to be able to do X" and we\'ll create it together.',
  129. "I'll write the prompt, save it to `capabilities/`, and register it here.",
  130. "Next session, I'll know how.",
  131. "Load `references/capability-authoring.md` for the full creation framework.",
  132. ])
  133. lines.extend([
  134. "",
  135. "## Tools",
  136. "",
  137. "Prefer crafting your own tools over depending on external ones. A script you wrote "
  138. "and saved is more reliable than an external API. Use the file system creatively.",
  139. "",
  140. "### User-Provided Tools",
  141. "",
  142. "_MCP servers, APIs, or services the owner has made available. Document them here._",
  143. ])
  144. return "\n".join(lines) + "\n"
  145. def substitute_vars(content: str, variables: dict) -> str:
  146. """Replace {var_name} placeholders with values from the variables dict."""
  147. for key, value in variables.items():
  148. content = content.replace(f"{{{key}}}", value)
  149. return content
  150. def main():
  151. if len(sys.argv) < 3:
  152. print("Usage: uv run init-sanctum.py <project-root> <skill-path>")
  153. sys.exit(1)
  154. project_root = Path(sys.argv[1]).resolve()
  155. skill_path = Path(sys.argv[2]).resolve()
  156. # Paths
  157. bmad_dir = project_root / "_bmad"
  158. memory_dir = bmad_dir / "memory"
  159. sanctum_path = memory_dir / SANCTUM_DIR
  160. assets_dir = skill_path / "assets"
  161. references_dir = skill_path / "references"
  162. scripts_dir = skill_path / "scripts"
  163. # Sanctum subdirectories
  164. sanctum_refs = sanctum_path / "references"
  165. sanctum_scripts = sanctum_path / "scripts"
  166. # Relative path for CAPABILITIES.md references (agent loads from within sanctum)
  167. sanctum_refs_path = "references"
  168. # Check if sanctum already exists
  169. if sanctum_path.exists():
  170. print(f"Sanctum already exists at {sanctum_path}")
  171. print("This agent has already been born. Skipping First Breath scaffolding.")
  172. sys.exit(0)
  173. # Load config
  174. config = {}
  175. for config_file in ["config.yaml", "config.user.yaml"]:
  176. config.update(parse_yaml_config(bmad_dir / config_file))
  177. # Build variable substitution map
  178. today = date.today().isoformat()
  179. variables = {
  180. "user_name": config.get("user_name", "friend"),
  181. "communication_language": config.get("communication_language", "English"),
  182. "birth_date": today,
  183. "project_root": str(project_root),
  184. "sanctum_path": str(sanctum_path),
  185. }
  186. # Create sanctum structure
  187. sanctum_path.mkdir(parents=True, exist_ok=True)
  188. (sanctum_path / "capabilities").mkdir(exist_ok=True)
  189. (sanctum_path / "sessions").mkdir(exist_ok=True)
  190. print(f"Created sanctum at {sanctum_path}")
  191. # Copy reference files (capabilities + techniques + guidance) into sanctum
  192. copied_refs = copy_references(references_dir, sanctum_refs)
  193. print(f" Copied {len(copied_refs)} reference files to sanctum/references/")
  194. for name in copied_refs:
  195. print(f" - {name}")
  196. # Copy any supporting scripts into sanctum
  197. copied_scripts = copy_scripts(scripts_dir, sanctum_scripts)
  198. if copied_scripts:
  199. print(f" Copied {len(copied_scripts)} scripts to sanctum/scripts/")
  200. for name in copied_scripts:
  201. print(f" - {name}")
  202. # Copy and substitute template files
  203. for template_name in TEMPLATE_FILES:
  204. template_path = assets_dir / template_name
  205. if not template_path.exists():
  206. print(f" Warning: template {template_name} not found, skipping")
  207. continue
  208. # Remove "-template" from the output filename and uppercase it
  209. output_name = template_name.replace("-template", "").upper()
  210. # Fix extension casing: .MD -> .md
  211. output_name = output_name[:-3] + ".md"
  212. content = template_path.read_text()
  213. content = substitute_vars(content, variables)
  214. output_path = sanctum_path / output_name
  215. output_path.write_text(content)
  216. print(f" Created {output_name}")
  217. # Auto-generate CAPABILITIES.md from references/ frontmatter
  218. capabilities = discover_capabilities(references_dir, sanctum_refs_path)
  219. capabilities_content = generate_capabilities_md(capabilities, evolvable=EVOLVABLE)
  220. (sanctum_path / "CAPABILITIES.md").write_text(capabilities_content)
  221. print(f" Created CAPABILITIES.md ({len(capabilities)} built-in capabilities discovered)")
  222. print()
  223. print("First Breath scaffolding complete.")
  224. print("The conversational awakening can now begin.")
  225. print(f"Sanctum: {sanctum_path}")
  226. if __name__ == "__main__":
  227. main()