1st commit

This commit is contained in:
rob 2025-10-28 21:23:08 -03:00
parent e914caf15f
commit a0b2816cc5
10 changed files with 337 additions and 194 deletions

View File

@ -0,0 +1,25 @@
version: 1
voting:
values: [READY, CHANGES, REJECT]
allow_agent_votes: true
quorum:
discussion: { ready: all, reject: all }
design: { ready: all, reject: all }
implementation: { ready: 1_human, reject: all }
testing: { ready: all, reject: all }
review: { ready: 1_human, reject: all }
eligibility:
agents_allowed: true
require_human_for: [implementation, review]
etiquette:
name_prefix_agents: "AI_"
vote_line_regex: "^VOTE:\\s*(READY|CHANGES|REJECT)\\s*$"
response_timeout_hours: 24
timeouts:
discussion_stale_days: 3
nudge_interval_hours: 24
promotion_timeout_days: 14
security:
scanners:
enabled: true
tool: gitleaks

View File

@ -0,0 +1,62 @@
version: 1
file_associations:
"feature.discussion.md": "feature_discussion"
"feature.discussion.sum.md": "discussion_summary"
"design.discussion.md": "design_discussion"
"design.discussion.sum.md": "discussion_summary"
"implementation.discussion.md": "impl_discussion"
"implementation.discussion.sum.md":"discussion_summary"
"testing.discussion.md": "test_discussion"
"testing.discussion.sum.md": "discussion_summary"
"review.discussion.md": "review_discussion"
"review.discussion.sum.md": "discussion_summary"
rules:
feature_discussion:
outputs:
summary_companion:
path: "{dir}/discussions/feature.discussion.sum.md"
output_type: "discussion_summary_writer"
instruction: |
Keep bounded sections only: DECISIONS, OPEN_QUESTIONS, AWAITING, ACTION_ITEMS, VOTES, TIMELINE, LINKS.
design_discussion:
outputs:
summary_companion:
path: "{dir}/discussions/design.discussion.sum.md"
output_type: "discussion_summary_writer"
instruction: |
Same policy as feature; include link to ../design/design.md if present.
impl_discussion:
outputs:
summary_companion:
path: "{dir}/discussions/implementation.discussion.sum.md"
output_type: "discussion_summary_writer"
instruction: |
Same policy; include any unchecked tasks from ../implementation/tasks.md.
test_discussion:
outputs:
summary_companion:
path: "{dir}/discussions/testing.discussion.sum.md"
output_type: "discussion_summary_writer"
instruction: |
Same policy; surface FAILS either in OPEN_QUESTIONS or AWAITING.
review_discussion:
outputs:
summary_companion:
path: "{dir}/discussions/review.discussion.sum.md"
output_type: "discussion_summary_writer"
instruction: |
Same policy; record READY_FOR_RELEASE decision date if present.
discussion_summary:
outputs:
normalize:
path: "{path}"
output_type: "discussion_summary_normalizer"
instruction: |
If missing, create summary with standard markers. Never edit outside markers.

View File

@ -0,0 +1,23 @@
version: 1
# Root defaults all folders inherit unless a closer .ai-rules.yml overrides them.
file_associations:
"README.md": "readme"
"process/policies.yml": "policies"
rules:
readme:
outputs:
normalize:
path: "{repo}/README.md"
output_type: "readme_normalizer"
instruction: |
Ensure basic sections exist: Overview, Install, Usage, License. Be idempotent.
policies:
outputs:
validate:
path: "{dir}/policies.yml"
output_type: "policy_validator"
instruction: |
Validate YAML keys according to DESIGN.md Appendix A. Do not auto-edit.

View File

@ -74,7 +74,6 @@ Human → Git Commit → Pre-commit Hook → AI Generator → Markdown Artifact
│ ├─ agents.yml # Role → stages mapping
│ └─ config.yml # Configuration (future)
├─ process/ # Process documentation & templates
│ ├─ design.md # This document
│ ├─ policies.md # Human-friendly policy documentation
│ ├─ policies.yml # Machine-readable policy configuration
│ └─ templates/
@ -146,7 +145,16 @@ CascadingDev/
├─ src/cascadingdev/ # core logic & optional dev CLI
├─ assets/ # single source of truth for shipped files
│ ├─ hooks/pre-commit
│ ├─ templates/{feature_request.md,discussion.md,design_doc.md}
│ ├─ templates/
│ │ ├─ USER_GUIDE.md
│ │ ├─ design_doc.md
│ │ ├─ discussion.md
│ │ ├─ feature_request.md
│ │ ├─ process/
│ │ │ └─ policies.yml
│ │ └─ rules/
│ │ ├─ root.ai-rules.yml # this becomes ./.ai-rules.yml
│ │ └─ features.ai-rules.yml # this becomes Docs/features/.ai-rules.yml
│ └─ runtime/{ramble.py,create_feature.py}
├─ tools/build_installer.py # creates install/cascadingdev-<version>/
├─ install/ # build output (git-ignored)

View File

@ -0,0 +1,4 @@
# src/cascadingdev/__init__.py
from .utils import read_version
__all__ = ["cli"]
__version__ = read_version()

View File

@ -0,0 +1,67 @@
# src/cascadingdev/cli.py
import argparse, sys, shutil
from pathlib import Path
from . import __version__
from .utils import ROOT, read_version, bump_version, run
def main():
ap = argparse.ArgumentParser(prog="cascadingdev", description="CascadingDev CLI")
ap.add_argument("--version", action="store_true", help="Show version and exit")
sub = ap.add_subparsers(dest="cmd")
sub.add_parser("doctor", help="Check environment and templates")
sub.add_parser("smoke", help="Run smoke test")
p_build = sub.add_parser("build", help="Build installer bundle (no version bump)")
p_rel = sub.add_parser("release", help="Bump version and rebuild")
p_rel.add_argument("--kind", choices=["major","minor","patch"], default="patch")
p_pack = sub.add_parser("pack", help="Zip the current installer bundle")
p_pack.add_argument("--out", help="Output zip path (default: ./install/cascadingdev-<ver>.zip)")
args = ap.parse_args()
if args.version:
print(__version__)
return 0
if args.cmd == "doctor":
# minimal checks
required = [
ROOT / "assets" / "templates" / "USER_GUIDE.md",
ROOT / "assets" / "templates" / "rules" / "features.ai-rules.yml",
ROOT / "assets" / "hooks" / "pre-commit",
ROOT / "src" / "cascadingdev" / "setup_project.py",
]
missing = [str(p) for p in required if not p.exists()]
if missing:
print("Missing:\n " + "\n ".join(missing)); return 2
print("Doctor OK."); return 0
if args.cmd == "smoke":
return run([sys.executable, str(ROOT / "tools" / "smoke_test.py")])
if args.cmd == "build":
return run([sys.executable, str(ROOT / "tools" / "build_installer.py")])
if args.cmd == "release":
newv = bump_version(args.kind, ROOT / "VERSION")
print(f"Bumped to {newv}")
rc = run([sys.executable, str(ROOT / "tools" / "build_installer.py")])
if rc == 0:
print(f"Built installer for {newv}")
return rc
if args.cmd == "pack":
ver = read_version(ROOT / "VERSION")
bundle = ROOT / "install" / f"cascadingdev-{ver}"
if not bundle.exists():
print(f"Bundle not found: {bundle}. Run `cascadingdev build` first.")
return 2
out = Path(args.out) if args.out else (ROOT / "install" / f"cascadingdev-{ver}.zip")
if out.exists():
out.unlink()
shutil.make_archive(out.with_suffix(""), "zip", root_dir=bundle)
print(f"Packed → {out}")
return 0
ap.print_help()
return 0

View File

@ -13,12 +13,12 @@ Examples:
python setup_cascadingdev.py --target ~/dev/my-new-repo
python setup_cascadingdev.py --target /abs/path --no-ramble
"""
import sys
import json
import shutil
import argparse
import subprocess
import datetime
import sys
import subprocess
import shutil
from pathlib import Path
# Bundle root (must contain assets/, ramble.py, VERSION)
@ -32,42 +32,42 @@ if not (INSTALL_ROOT / "assets").exists():
# ---------- Helper Functions ----------
def sh(cmd, check=True, cwd=None):
"""Run a shell command and return the completed process."""
return subprocess.run(cmd, check=check, text=True, capture_output=True, cwd=cwd)
def say(msg):
"""Print a message with immediate flush."""
def say(msg: str) -> None:
print(msg, flush=True)
def ensure_dir(p: Path) -> None:
p.mkdir(parents=True, exist_ok=True)
def write_if_missing(path: Path, content: str):
"""Write content to a file only if it doesn't already exist."""
path.parent.mkdir(parents=True, exist_ok=True)
def write_if_missing(path: Path, content: str) -> None:
ensure_dir(path.parent)
if not path.exists():
path.write_text(content, encoding="utf-8")
def copy_if_exists(src: Path, dst: Path):
"""Copy a file from source to destination if the source exists."""
def copy_if_exists(src: Path, dst: Path) -> None:
if src.exists():
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(src), str(dst))
ensure_dir(dst.parent)
shutil.copy2(src, dst)
def copy_if_missing(src: Path, dst: Path) -> None:
ensure_dir(dst.parent)
if not dst.exists():
shutil.copy2(src, dst)
def run(cmd: list[str], cwd: Path | None = None) -> int:
proc = subprocess.Popen(cmd, cwd=cwd, stdout=sys.stdout, stderr=sys.stderr)
return proc.wait()
def ensure_git_repo(target: Path):
"""Initialize a git repository if one doesn't exist at the target path."""
if not (target / ".git").exists():
# Initialize git repo with main branch
sh(["git", "init", "-b", "main"], cwd=str(target))
run(["git", "init", "-b", "main"], cwd=target)
# Create basic .gitignore file
write_if_missing(target / ".gitignore", "\n".join([
".env", ".env.*", "secrets/", ".git/ai-rules-*", "__pycache__/",
"*.pyc", ".pytest_cache/", ".DS_Store",
]) + "\n")
def install_precommit_hook(target: Path):
"""Install the pre-commit hook from installer assets to target git hooks."""
hook_src = INSTALL_ROOT / "assets" / "hooks" / "pre-commit"
@ -143,50 +143,31 @@ def run_ramble_and_collect(target: Path, provider: str = "mock", claude_cmd: str
def seed_process_and_rules(target: Path):
"""Create minimal, machine-readable rules that let the FIRST FEATURE define the project."""
"""Seed machine-readable policies and stage rules by copying installer templates."""
# Seed process/policies.yml (machine-readable), per DESIGN.md Appendix A
process_dir = target / "process"
rules_dir = target / "Docs" / "features"
process_dir.mkdir(parents=True, exist_ok=True)
rules_dir.mkdir(parents=True, exist_ok=True)
# Create AI rules configuration for general markdown files
write_if_missing(target / ".ai-rules.yml",
"""version: 1
file_associations:
"*.md": "md-file"
# Locate templates in THIS installer bundle
t_root = INSTALL_ROOT / "assets" / "templates"
t_process = t_root / "process" / "policies.yml"
t_rules_root = t_root / "rules" / "root.ai-rules.yml"
t_rules_features = t_root / "rules" / "features.ai-rules.yml"
rules:
md-file:
description: "Normalize Markdown"
instruction: |
Keep markdown tidy (headings, lists, spacing). No content churn.
settings:
model: "local-mock"
temperature: 0.1
""")
# Copy policies
if t_process.exists():
copy_if_missing(t_process, process_dir / "policies.yml")
# Create AI rules specific to feature discussions
write_if_missing(target / "Docs" / "features" / ".ai-rules.yml",
"""version: 1
file_associations:
"feature.discussion.md": "feature_discussion"
"feature.discussion.sum.md": "discussion_summary"
rules:
feature_discussion:
outputs:
summary_companion:
path: "{dir}/discussions/feature.discussion.sum.md"
output_type: "discussion_summary_writer"
instruction: |
Ensure the summary file exists and maintain only the bounded sections:
DECISIONS, OPEN_QUESTIONS, AWAITING, ACTION_ITEMS, VOTES, TIMELINE, LINKS.
discussion_summary:
outputs:
normalize:
path: "{dir}/feature.discussion.sum.md"
output_type: "discussion_summary_normalizer"
instruction: |
If missing, create summary with standard markers. Do not edit text outside markers.
""")
# Copy rules files into expected locations
# Root rules (optional if you want a project-wide baseline)
if t_rules_root.exists():
copy_if_missing(t_rules_root, target / ".ai-rules.yml")
# Discussion/feature rules (cascade/override within Docs/features)
if t_rules_features.exists():
copy_if_missing(t_rules_features, rules_dir / ".ai-rules.yml")
def seed_initial_feature(target: Path, req_fields: dict | None):
"""Create the initial feature request and associated discussion files."""
@ -307,8 +288,8 @@ def copy_install_assets_to_target(target: Path):
def first_commit(target: Path):
"""Perform the initial git commit of all scaffolded files."""
try:
sh(["git", "add", "-A"], cwd=str(target))
sh(["git", "commit", "-m", "chore: bootstrap Cascading Development scaffolding"], cwd=str(target))
run(["git", "add", "-A"], cwd=target)
run(["git", "commit", "-m", "chore: bootstrap Cascading Development scaffolding"], cwd=target)
except Exception:
# Silently continue if commit fails (e.g., no git config)
pass

View File

@ -0,0 +1,62 @@
from __future__ import annotations
from pathlib import Path
import shutil, subprocess, sys, re
ROOT = Path(__file__).resolve().parents[2] # repo root
def say(msg: str) -> None:
print(msg, flush=True)
def ensure_dir(p: Path) -> None:
p.mkdir(parents=True, exist_ok=True)
def write_if_missing(path: Path, content: str) -> None:
ensure_dir(path.parent)
if not path.exists():
path.write_text(content, encoding="utf-8")
def copy_if_exists(src: Path, dst: Path) -> None:
if src.exists():
ensure_dir(dst.parent)
shutil.copy2(src, dst)
def copy_if_missing(src: Path, dst: Path) -> None:
dst.parent.mkdir(parents=True, exist_ok=True)
if not dst.exists():
shutil.copy2(src, dst)
def run(cmd: list[str], cwd: Path | None = None) -> int:
proc = subprocess.Popen(cmd, cwd=cwd, stdout=sys.stdout, stderr=sys.stderr)
return proc.wait()
def read_version(version_file: Path | None = None) -> str:
vf = version_file or (ROOT / "VERSION")
return (vf.read_text(encoding="utf-8").strip() if vf.exists() else "0.0.0")
def write_version(new_version: str, version_file: Path | None = None) -> None:
vf = version_file or (ROOT / "VERSION")
vf.write_text(new_version.strip() + "\n", encoding="utf-8")
_semver = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
def bump_version(kind: str = "patch", version_file: Path | None = None) -> str:
cur = read_version(version_file)
m = _semver.match(cur) or _semver.match("0.1.0") # default if missing
major, minor, patch = map(int, m.groups())
if kind == "major":
major, minor, patch = major + 1, 0, 0
elif kind == "minor":
minor, patch = minor + 1, 0
else:
patch += 1
new = f"{major}.{minor}.{patch}"
write_version(new, version_file)
return new
def bundle_path(version_file: Path | None = None) -> Path:
"""
Return the install bundle path for the current VERSION (e.g., install/cascadingdev-0.1.2).
Raises FileNotFoundError if missing.
"""
ver = read_version(version_file)
bp = ROOT / "install" / f"cascadingdev-{ver}"
if not bp.exists():
raise FileNotFoundError(f"Bundle not found: {bp}. Build it with `cascadingdev build`.")
return bp

View File

@ -8,149 +8,38 @@ VER = (ROOT / "VERSION").read_text().strip() if (ROOT / "VERSION").exists() els
BUNDLE = OUT / f"cascadingdev-{VER}"
def main():
# Removes the old install bundle if it already exists
if BUNDLE.exists():
shutil.rmtree(BUNDLE)
# copy essentials
# Create the directories
(BUNDLE / "assets" / "hooks").mkdir(parents=True, exist_ok=True)
(BUNDLE / "assets" / "templates").mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / "DESIGN.md", BUNDLE / "DESIGN.md")
# Copy the git hook and any other runtime utilities.
shutil.copy2(ROOT / "assets" / "runtime" / "ramble.py", BUNDLE / "ramble.py")
shutil.copy2(ROOT / "assets" / "runtime" / "create_feature.py", BUNDLE / "create_feature.py")
shutil.copy2(ROOT / "assets" / "hooks" / "pre-commit", BUNDLE / "assets" / "hooks" / "pre-commit")
for t in ["feature_request.md","discussion.md","design_doc.md"]:
# copy core templates
for t in ["feature_request.md","discussion.md","design_doc.md","USER_GUIDE.md"]:
shutil.copy2(ROOT / "assets" / "templates" / t, BUNDLE / "assets" / "templates" / t)
# copy (recursively) the contents of process/ and rules/ templates folders
def copy_tree(src, dst):
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
copy_tree(ROOT / "assets" / "templates" / "process", BUNDLE / "assets" / "templates" / "process")
copy_tree(ROOT / "assets" / "templates" / "rules", BUNDLE / "assets" / "templates" / "rules")
# write installer entrypoint
(BUNDLE / "setup_cascadingdev.py").write_text(INSTALLER_PY, encoding="utf-8")
shutil.copy2(ROOT / "src" / "cascadingdev" / "setup_project.py",
BUNDLE / "setup_cascadingdev.py")
(BUNDLE / "INSTALL.md").write_text("Unzip, then run:\n\n python3 setup_cascadingdev.py\n", encoding="utf-8")
(BUNDLE / "VERSION").write_text(VER, encoding="utf-8")
print(f"[✓] Built installer → {BUNDLE}")
INSTALLER_PY = r'''#!/usr/bin/env python3
import argparse, json, os, shutil, subprocess, sys, datetime
from pathlib import Path
HERE = Path(__file__).resolve().parent
def sh(cmd, cwd=None):
return subprocess.run(cmd, check=True, text=True, capture_output=True, cwd=cwd)
def say(x): print(x, flush=True)
def write_if_missing(p: Path, content: str):
p.parent.mkdir(parents=True, exist_ok=True)
if not p.exists():
p.write_text(content, encoding="utf-8")
def copytree(src: Path, dst: Path):
dst.parent.mkdir(parents=True, exist_ok=True)
if src.is_file():
shutil.copy2(src, dst)
else:
shutil.copytree(src, dst, dirs_exist_ok=True)
def ensure_git_repo(target: Path):
if not (target / ".git").exists():
sh(["git", "init", "-b", "main"], cwd=target)
write_if_missing(target / ".gitignore", ".env\n.env.*\nsecrets/\n__pycache__/\n*.pyc\n.pytest_cache/\n.DS_Store\n")
def install_hook(target: Path):
hooks = target / ".git" / "hooks"; hooks.mkdir(parents=True, exist_ok=True)
src = HERE / "assets" / "hooks" / "pre-commit"
dst = hooks / "pre-commit"
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
dst.chmod(0o755)
def run_ramble(target: Path, provider="mock"):
ramble = target / "ramble.py"
if not ramble.exists(): return None
args = [sys.executable, str(ramble),
"--provider", provider,
"--prompt", "Describe your initial feature request for this repository",
"--fields", "Summary", "Title", "Intent", "ProblemItSolves", "BriefOverview",
"--criteria", '{"Summary":"<= 2 sentences","Title":"camelCase, <= 24 chars"}']
say("[•] Launching Ramble…")
p = subprocess.run(args, text=True, capture_output=True, cwd=target)
try:
return json.loads((p.stdout or "").strip())
except Exception:
say("[-] Could not parse Ramble output; using template defaults.")
return None
def seed_rules_and_templates(target: Path):
write_if_missing(target / ".ai-rules.yml",
"version: 1\nfile_associations:\n \"*.md\": \"md-file\"\n\nrules:\n md-file:\n description: \"Normalize Markdown\"\n instruction: |\n Keep markdown tidy (headings, lists, spacing). No content churn.\nsettings:\n model: \"local-mock\"\n temperature: 0.1\n")
# copy templates
copytree(HERE / "assets" / "templates", target / "process" / "templates")
def seed_first_feature(target: Path, req):
today = datetime.date.today().isoformat()
fr = target / "Docs" / "features" / f"FR_{today}_initial-feature-request"
disc = fr / "discussions"; disc.mkdir(parents=True, exist_ok=True)
if req:
fields = req.get("fields", {}) or {}
title = (fields.get("Title") or "initialProjectDesign").strip()
intent = (fields.get("Intent") or "").strip()
problem = (fields.get("ProblemItSolves") or "").strip()
brief = (fields.get("BriefOverview") or "").strip()
summary = (req.get("summary") or "").strip()
body = f"# Feature Request: {title}\n\n**Intent**: {intent}\n**Motivation / Problem**: {problem}\n**Brief Overview**: {brief}\n\n**Summary**: {summary}\n**Meta**: Created: {today}\n"
else:
body = (target / "process" / "templates" / "feature_request.md").read_text(encoding="utf-8")
(fr / "request.md").write_text(body, encoding="utf-8")
(disc / "feature.discussion.md").write_text(
f"---\ntype: discussion\nstage: feature\nstatus: OPEN\nfeature_id: FR_{today}_initial-feature-request\ncreated: {today}\n---\n## Summary\nKickoff discussion. Append comments below.\n\n## Participation\n- Maintainer: Kickoff. VOTE: READY\n", encoding="utf-8")
(disc / "feature.discussion.sum.md").write_text(
"# Summary — Feature\n\n<!-- SUMMARY:DECISIONS START -->\n## Decisions (ADR-style)\n- (none yet)\n<!-- SUMMARY:DECISIONS END -->\n\n<!-- SUMMARY:OPEN_QUESTIONS START -->\n## Open Questions\n- (none yet)\n<!-- SUMMARY:OPEN_QUESTIONS END -->\n\n<!-- SUMMARY:AWAITING START -->\n## Awaiting Replies\n- (none yet)\n<!-- SUMMARY:AWAITING END -->\n\n<!-- SUMMARY:ACTION_ITEMS START -->\n## Action Items\n- (none yet)\n<!-- SUMMARY:ACTION_ITEMS END -->\n\n<!-- SUMMARY:VOTES START -->\n## Votes (latest per participant)\nREADY: 1 • CHANGES: 0 • REJECT: 0\n- Maintainer\n<!-- SUMMARY:VOTES END -->\n\n<!-- SUMMARY:TIMELINE START -->\n## Timeline (most recent first)\n- {today} Maintainer: Kickoff\n<!-- SUMMARY:TIMELINE END -->\n\n<!-- SUMMARY:LINKS START -->\n## Links\n- Design/Plan: ../design/design.md\n<!-- SUMMARY:LINKS END -->\n".replace("{today}", today), encoding="utf-8")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--target", help="Destination path for the user's project")
ap.add_argument("--no-ramble", action="store_true")
ap.add_argument("--provider", default="mock")
args = ap.parse_args()
target = Path(args.target or input("User's project folder: ").strip()).expanduser().resolve()
target.mkdir(parents=True, exist_ok=True)
say(f"[=] Installing into: {target}")
# copy top-level assets
shutil.copy2(HERE / "DESIGN.md", target / "DESIGN.md")
shutil.copy2(HERE / "ramble.py", target / "ramble.py")
# basic tree
for p in [target / "Docs" / "features",
target / "Docs" / "discussions" / "reviews",
target / "Docs" / "diagrams" / "file_diagrams",
target / "scripts" / "hooks",
target / "src", target / "tests", target / "process"]:
p.mkdir(parents=True, exist_ok=True)
# rules / templates
seed_rules_and_templates(target)
# git + hook
ensure_git_repo(target)
install_hook(target)
# ramble
req = None if args.no_ramble else run_ramble(target, provider=args.provider)
# seed FR
seed_first_feature(target, req)
try:
sh(["git", "add", "-A"], cwd=target)
sh(["git", "commit", "-m", "chore: bootstrap Cascading Development scaffolding"], cwd=target)
except Exception:
pass
say("[✓] Done. Next:\n cd " + str(target) + "\n git status")
if __name__ == "__main__":
main()
'''
if __name__ == "__main__":
main()

View File

@ -0,0 +1,22 @@
#!/usr/bin/env python3
from pathlib import Path
def main():
root = Path(__file__).resolve().parents[1]
required = [
root / "assets" / "hooks" / "pre-commit",
root / "assets" / "templates" / "feature_request.md",
root / "assets" / "templates" / "discussion.md",
root / "assets" / "templates" / "design_doc.md",
root / "assets" / "templates" / "USER_GUIDE.md", # now required
root / "assets" / "runtime" / "ramble.py",
root / "tools" / "build_installer.py",
root / "src" / "cascadingdev" / "setup_project.py",
]
missing = [str(p) for p in required if not p.exists()]
if missing:
print("Missing:", *missing, sep="\n ")
raise SystemExit(2)
print("Smoke OK.")
if __name__ == "__main__":
main()