Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c | r"""Rebuild plan/ from the archived poc-modules snapshot. | |
| The active working surface is plan/. This script is retained so the smaller | |
| markdown slices can be regenerated from the archived legacy monolith when | |
| needed. | |
| Run with: | |
| .venv\Scripts\python.exe scripts\split_plan.py | |
| """ | |
| from __future__ import annotations | |
| import re | |
| import shutil | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Final | |
| ROOT = Path(__file__).resolve().parents[1] | |
| SOURCE = ROOT / "archive" / "poc-modules.md" | |
| OUTPUT = ROOT / "plan" | |
| SENTINEL = OUTPUT / ".generated-by-split-plan" | |
| HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$") | |
| MODULE_ID_RE = re.compile(r"\b(M\d{2}[a-z]?)\b", re.IGNORECASE) | |
| QUESTION_ID_RE = re.compile(r"\b(Q\d+)\b", re.IGNORECASE) | |
| BACKEND_SECTIONS: Final[list[str]] = [ | |
| "Overview", | |
| "Spirit guardrails", | |
| "Architecture: three layers that must not collapse", | |
| "Trust model (critical — never flatten into one field)", | |
| "Dependency Graph (corrected)", | |
| "Critical Path", | |
| "Parallel Workstreams", | |
| ] | |
| BACKEND_BLOCKERS: Final[list[str]] = ["Q1", "Q2", "Q4", "Q5", "Q6", "Q7"] | |
| BACKEND_MODULES: Final[list[str]] = [ | |
| "M01", | |
| "M03", | |
| "M08", | |
| "M11", | |
| "M13", | |
| "M14", | |
| "M15", | |
| "M17", | |
| "M20", | |
| "M21", | |
| "M22", | |
| "M23", | |
| ] | |
| FRONTEND_SECTIONS: Final[list[str]] = [ | |
| "Overview", | |
| "Spirit guardrails", | |
| "Architecture: three layers that must not collapse", | |
| "Parallel Workstreams", | |
| "First actions before writing any code (Wave 0 justification)", | |
| ] | |
| FRONTEND_BLOCKERS: Final[list[str]] = ["Q1", "Q3", "Q4"] | |
| FRONTEND_MODULES: Final[list[str]] = ["M17", "M18", "M19", "M22"] | |
| LLM_SECTIONS: Final[list[str]] = [ | |
| "Overview", | |
| "Spirit guardrails", | |
| "Trust model (critical — never flatten into one field)", | |
| "Critical Path", | |
| "Risk Register", | |
| ] | |
| LLM_BLOCKERS: Final[list[str]] = ["Q1", "Q2", "Q5", "Q6"] | |
| LLM_MODULES: Final[list[str]] = [ | |
| "M02", | |
| "M04b", | |
| "M08", | |
| "M09", | |
| "M10", | |
| "M13", | |
| "M14", | |
| "M22", | |
| ] | |
| class Section: | |
| level: int | |
| heading: str | |
| body_lines: list[str] = field(default_factory=list) | |
| children: list[Section] = field(default_factory=list) | |
| def title(self) -> str: | |
| return self.heading.strip() | |
| def content(self, *, heading_level: int = 1) -> str: | |
| lines = self.render(heading_level=heading_level) | |
| return "\n".join(lines).rstrip() + "\n" | |
| def render(self, *, heading_level: int) -> list[str]: | |
| lines = [f"{'#' * heading_level} {self.title()}", ""] | |
| lines.extend(self.body_lines) | |
| if self.body_lines and self.children: | |
| lines.append("") | |
| for index, child in enumerate(self.children): | |
| lines.extend(child.render(heading_level=heading_level + 1)) | |
| if index < len(self.children) - 1: | |
| lines.append("") | |
| return lines | |
| def slugify(value: str) -> str: | |
| value = value.strip().lower() | |
| value = value.replace("→", " to ") | |
| value = value.replace("/", " ") | |
| value = re.sub(r"[`*_(){}\[\]:.,!?'\"]", "", value) | |
| value = re.sub(r"[^a-z0-9]+", "-", value) | |
| return value.strip("-") or "section" | |
| def parse_sections(text: str) -> tuple[list[str], list[Section]]: | |
| preamble: list[str] = [] | |
| roots: list[Section] = [] | |
| stack: list[Section] = [] | |
| in_code_block = False | |
| for raw_line in text.splitlines(): | |
| line = raw_line.rstrip() | |
| stripped = line.strip() | |
| if stripped.startswith("```"): | |
| in_code_block = not in_code_block | |
| match = None if in_code_block else HEADING_RE.match(line) | |
| if match: | |
| level = len(match.group(1)) | |
| heading = match.group(2).strip() | |
| section = Section(level=level, heading=heading) | |
| while stack and stack[-1].level >= level: | |
| stack.pop() | |
| if stack: | |
| stack[-1].children.append(section) | |
| else: | |
| roots.append(section) | |
| stack.append(section) | |
| continue | |
| if stack: | |
| stack[-1].body_lines.append(line) | |
| else: | |
| preamble.append(line) | |
| return preamble, roots | |
| def write_file(path: Path, content: str) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text(content, encoding="utf-8") | |
| def clean_output_dir(output_dir: Path) -> None: | |
| if not output_dir.exists(): | |
| return | |
| if not SENTINEL.exists(): | |
| raise RuntimeError( | |
| f"Refusing to replace {output_dir} because it was not created by this script." | |
| ) | |
| shutil.rmtree(output_dir) | |
| def heading_link(path: Path) -> str: | |
| return path.relative_to(OUTPUT).as_posix() | |
| def module_filename(section: Section) -> str: | |
| match = MODULE_ID_RE.search(section.title()) | |
| module_id = match.group(1).lower() if match else "module" | |
| tail = slugify(MODULE_ID_RE.sub("", section.title(), count=1)) | |
| return f"{module_id}-{tail}.md" if tail else f"{module_id}.md" | |
| def require_path(path_map: dict[str, str], key: str) -> str: | |
| try: | |
| return path_map[key] | |
| except KeyError as exc: | |
| raise RuntimeError(f"Missing generated path for {key!r}") from exc | |
| def normalize_module_id(module_id: str) -> str: | |
| return module_id.upper() | |
| def question_filename(section: Section) -> str: | |
| match = QUESTION_ID_RE.search(section.title()) | |
| question_id = match.group(1).lower() if match else "question" | |
| tail = slugify(QUESTION_ID_RE.sub("", section.title(), count=1)) | |
| return f"{question_id}-{tail}.md" if tail else f"{question_id}.md" | |
| def write_blocker_breakout( | |
| section: Section, *, section_paths: dict[str, str], blocker_paths: dict[str, str] | |
| ) -> None: | |
| blockers_dir = OUTPUT / "blockers" | |
| blockers_dir.mkdir(parents=True, exist_ok=True) | |
| readme_lines = [ | |
| "# Blockers By Question", | |
| "", | |
| "Split from the pre-coding blockers section so agents can load one decision at a time.", | |
| "", | |
| ] | |
| for child in section.children: | |
| match = QUESTION_ID_RE.search(child.title()) | |
| if match is None: | |
| continue | |
| question_id = match.group(1).upper() | |
| filename = question_filename(child) | |
| question_path = blockers_dir / filename | |
| write_file(question_path, child.content()) | |
| rel_path = heading_link(question_path) | |
| blocker_paths[question_id] = rel_path | |
| readme_lines.append(f"- [{child.title()}]({filename})") | |
| write_file(blockers_dir / "README.md", "\n".join(readme_lines).rstrip() + "\n") | |
| section_paths["Blockers By Question"] = heading_link(blockers_dir / "README.md") | |
| def write_agent_guide( | |
| *, | |
| filename: str, | |
| title: str, | |
| summary: str, | |
| section_keys: list[str], | |
| blocker_ids: list[str], | |
| module_ids: list[str], | |
| section_paths: dict[str, str], | |
| blocker_paths: dict[str, str], | |
| module_paths: dict[str, str], | |
| ) -> None: | |
| lines = [ | |
| f"# {title}", | |
| "", | |
| summary, | |
| "", | |
| "Read the source file only when you need full surrounding prose; otherwise start from these slices.", | |
| "", | |
| "## Foundation", | |
| "", | |
| ] | |
| for key in section_keys: | |
| lines.append(f"- [{key}]({require_path(section_paths, key)})") | |
| lines.extend(["", "## Blockers", ""]) | |
| for blocker_id in blocker_ids: | |
| lines.append(f"- [{blocker_id}]({require_path(blocker_paths, blocker_id)})") | |
| lines.extend(["", "## Modules", ""]) | |
| for module_id in module_ids: | |
| lines.append( | |
| f"- [{module_id}]({require_path(module_paths, normalize_module_id(module_id))})" | |
| ) | |
| write_file(OUTPUT / filename, "\n".join(lines).rstrip() + "\n") | |
| def write_agent_guides( | |
| *, | |
| section_paths: dict[str, str], | |
| blocker_paths: dict[str, str], | |
| module_paths: dict[str, str], | |
| ) -> None: | |
| write_agent_guide( | |
| filename="for-backend.md", | |
| title="For Backend Agents", | |
| summary="Backend-heavy reading order for contracts, orchestration, validation, session management, and export work.", | |
| section_keys=BACKEND_SECTIONS, | |
| blocker_ids=BACKEND_BLOCKERS, | |
| module_ids=BACKEND_MODULES, | |
| section_paths=section_paths, | |
| blocker_paths=blocker_paths, | |
| module_paths=module_paths, | |
| ) | |
| write_agent_guide( | |
| filename="for-frontend.md", | |
| title="For Frontend Agents", | |
| summary="Frontend-heavy reading order for the Gradio host, renderer boundary, and Cytoscape component work.", | |
| section_keys=FRONTEND_SECTIONS, | |
| blocker_ids=FRONTEND_BLOCKERS, | |
| module_ids=FRONTEND_MODULES, | |
| section_paths=section_paths, | |
| blocker_paths=blocker_paths, | |
| module_paths=module_paths, | |
| ) | |
| write_agent_guide( | |
| filename="for-llm.md", | |
| title="For LLM Agents", | |
| summary="LLM-heavy reading order for structured output, prompting, validation boundaries, and model-facing risks.", | |
| section_keys=LLM_SECTIONS, | |
| blocker_ids=LLM_BLOCKERS, | |
| module_ids=LLM_MODULES, | |
| section_paths=section_paths, | |
| blocker_paths=blocker_paths, | |
| module_paths=module_paths, | |
| ) | |
| def build_plan(preamble: list[str], roots: list[Section]) -> None: | |
| clean_output_dir(OUTPUT) | |
| OUTPUT.mkdir(parents=True, exist_ok=True) | |
| SENTINEL.write_text("generated by scripts/split_plan.py\n", encoding="utf-8") | |
| title_section = next((section for section in roots if section.level == 1), None) | |
| if title_section is None: | |
| raise RuntimeError("Expected an H1 title in archive/poc-modules.md") | |
| h2_sections = [section for section in title_section.children if section.level == 2] | |
| if not h2_sections: | |
| raise RuntimeError("Expected H2 sections under the document title") | |
| index_lines = [ | |
| "# Plan Index", | |
| "", | |
| "Originally generated from the archived `archive/poc-modules.md` snapshot by `scripts/split_plan.py`.", | |
| "`plan/` is now the active working surface; `archive/` keeps the legacy monolith and research notes.", | |
| "", | |
| "## Archive Snapshot", | |
| "", | |
| f"- `../archive/poc-modules.md` ({SOURCE.as_posix()})", | |
| "", | |
| "## Agent Reading Lists", | |
| "", | |
| "- [For backend agents](for-backend.md)", | |
| "- [For frontend agents](for-frontend.md)", | |
| "- [For LLM agents](for-llm.md)", | |
| "", | |
| "## Sections", | |
| "", | |
| ] | |
| section_paths: dict[str, str] = {} | |
| module_paths: dict[str, str] = {} | |
| blocker_paths: dict[str, str] = {} | |
| overview_lines = [f"# {title_section.title()}", ""] | |
| if preamble: | |
| overview_lines.extend(preamble) | |
| overview_lines.append("") | |
| overview_lines.extend(title_section.body_lines) | |
| write_file(OUTPUT / "00-overview.md", "\n".join(overview_lines).rstrip() + "\n") | |
| index_lines.append("- [Overview](00-overview.md)") | |
| section_paths["Overview"] = "00-overview.md" | |
| non_module_counter = 1 | |
| for h2 in h2_sections: | |
| if h2.title() == "Module List": | |
| module_dir = OUTPUT / "modules" | |
| module_dir.mkdir(parents=True, exist_ok=True) | |
| module_index = [ | |
| "# Modules", | |
| "", | |
| "Split from the `Module List` section.", | |
| "", | |
| ] | |
| for layer_index, layer in enumerate(h2.children, start=1): | |
| layer_slug = f"{layer_index:02d}-{slugify(layer.title())}" | |
| layer_dir = module_dir / layer_slug | |
| layer_dir.mkdir(parents=True, exist_ok=True) | |
| layer_readme_lines = [f"# {layer.title()}", ""] | |
| if layer.body_lines: | |
| layer_readme_lines.extend(layer.body_lines) | |
| layer_readme_lines.append("") | |
| layer_readme_lines.append("## Modules") | |
| layer_readme_lines.append("") | |
| for module in layer.children: | |
| filename = module_filename(module) | |
| module_path = layer_dir / filename | |
| write_file(module_path, module.content()) | |
| layer_readme_lines.append(f"- [{module.title()}]({filename})") | |
| match = MODULE_ID_RE.search(module.title()) | |
| if match is not None: | |
| module_paths[match.group(1).upper()] = heading_link(module_path) | |
| write_file( | |
| layer_dir / "README.md", | |
| "\n".join(layer_readme_lines).rstrip() + "\n", | |
| ) | |
| rel_layer = heading_link(layer_dir / "README.md") | |
| module_index.append(f"- [{layer.title()}]({rel_layer})") | |
| write_file( | |
| module_dir / "README.md", "\n".join(module_index).rstrip() + "\n" | |
| ) | |
| index_lines.append("- [Modules](modules/README.md)") | |
| section_paths["Module List"] = "modules/README.md" | |
| continue | |
| filename = f"{non_module_counter:02d}-{slugify(h2.title())}.md" | |
| non_module_counter += 1 | |
| write_file(OUTPUT / filename, h2.content()) | |
| index_lines.append(f"- [{h2.title()}]({filename})") | |
| section_paths[h2.title()] = filename | |
| if ( | |
| h2.title() | |
| == "Pre-coding blockers (resolve in order before assigning any module)" | |
| ): | |
| write_blocker_breakout( | |
| h2, section_paths=section_paths, blocker_paths=blocker_paths | |
| ) | |
| index_lines.append("- [Blockers by question](blockers/README.md)") | |
| write_agent_guides( | |
| section_paths=section_paths, | |
| blocker_paths=blocker_paths, | |
| module_paths=module_paths, | |
| ) | |
| write_file(OUTPUT / "README.md", "\n".join(index_lines).rstrip() + "\n") | |
| def main() -> None: | |
| text = SOURCE.read_text(encoding="utf-8") | |
| preamble, roots = parse_sections(text) | |
| build_plan(preamble, roots) | |
| print(f"Wrote split plan to {OUTPUT}") | |
| if __name__ == "__main__": | |
| main() | |