loosecanvas / archive /split_plan.py
Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c
Raw
History Blame Contribute Delete
14 kB
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",
]
@dataclass(slots=True)
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()