From a21212d73d64e3adb5a6db1d76b5958edbb5de87 Mon Sep 17 00:00:00 2001 From: claude-ceo-assistant Date: Thu, 7 May 2026 20:48:16 -0700 Subject: [PATCH] scaffold(0001): validator + CI gate + dev-department.yaml manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial scaffold for the dev-department subtree repo. No workspace content yet — that lands in Phase 3c-2 (extract dev tree with git history from molecule-ai-org-template-molecule-dev). Files: - dev-department.yaml manifest with defaults + category_routing, empty roots: [] (gets populated by extract). - .molecule-ci/scripts/validate-tree.py orphan / reachability lint. Walks manifest → roots → recursive children + !include, compares against filesystem, reports orphans + cross-tree '..' refs + duplicate parents + missing workspace.yaml. Exits non-zero on any violation. Stdlib only + PyYAML. - .github/workflows/validate.yml CI gate runs the validator on every PR + push to main/staging. Pinned action SHAs per saved memory feedback_pin_third_party_actions. - README.md explains subtree contract: parent template must symlink the dev-department under a short name (e.g. `dev`), workspace files_dir paths inside this repo use the symlink prefix, this repo is NOT directly importable as a standalone org template. - .gitignore ignore .env (per-workspace secrets are populated by platform import, never committed). - .gitattributes force LF on shell/Python/YAML. Verified locally: - empty tree → "OK — tree is clean", exit 0. - cross-tree `..` fixture → exit 1, FAIL with reported violation. - orphan fixture → exit 1, FAIL with reported orphan folder. Refs: - internal#77 (extraction RFC, Phase 1+2 done as comment 1886) - molecule-core#102 (symlink-resolution contract pinned by tests) - Hongming GO 2026-05-08 ("you own this feature and repos, start") - SOP Phase 3b — task #223 --- .gitattributes | 6 + .github/workflows/validate.yml | 45 ++++ .gitignore | 19 ++ .molecule-ci/scripts/validate-tree.py | 326 ++++++++++++++++++++++++++ README.md | 142 ++++++++++- dev-department.yaml | 80 +++++++ 6 files changed, 616 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100755 .molecule-ci/scripts/validate-tree.py create mode 100644 dev-department.yaml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..07673c8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Force LF on shell + Python + YAML — Linux containers choke on \r\n +*.sh text eol=lf +*.py text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.md text eol=lf diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..a1aa2eb --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,45 @@ +# Gitea Actions CI gate: run the tree validator on every PR + push. +# +# The validator catches: orphan workspace folders, cross-tree `..` +# traversal in children: paths, duplicate parent claims, missing +# workspace.yaml, generic !include errors. +# +# Refs: internal#77 (Phase 3b — task #223). + +name: Validate dev-department tree + +on: + push: + branches: [main, staging] + pull_request: + branches: [main, staging] + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Validate tree + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # We don't follow submodules. The dev-department subtree is + # self-contained; cross-repo composition is verified at the + # parent-template's CI level (internal#77 Phase 4). + submodules: false + + - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 + with: + python-version: '3.11' + + - name: Install PyYAML + run: python -m pip install --no-input --disable-pip-version-check pyyaml==6.0.1 + + - name: Run validator + run: | + chmod +x .molecule-ci/scripts/validate-tree.py + .molecule-ci/scripts/validate-tree.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2844817 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Per-workspace secrets — populated at platform-import time, never committed. +# Each workspace's .env.example is the contract for what it expects. +**/.env + +# Editor + OS noise +.DS_Store +*.swp +.idea/ +.vscode/ + +# Python validator scratch +__pycache__/ +*.pyc +.pytest_cache/ + +# Local scratch +*.bak +*.orig +*.rej diff --git a/.molecule-ci/scripts/validate-tree.py b/.molecule-ci/scripts/validate-tree.py new file mode 100755 index 0000000..96c9d63 --- /dev/null +++ b/.molecule-ci/scripts/validate-tree.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +""" +validate-tree.py — orphan / reachability / shape lint for an org-template tree. + +Walks the manifest (org.yaml or dev-department.yaml) → roots → recursive +`children:` (and `!include`) → builds the set of reachable workspace +folders → compares against the filesystem → reports violations. + +Catches the four failure modes that motivated the RFC (internal#77): + + 1. Orphan workspace folders (folder exists, no parent claims it). + 2. Cross-tree `..` traversal in `children:` paths (atomization rule). + 3. Workspace folder without `workspace.yaml` (broken nest). + 4. Two parents claiming the same child workspace (graph not a tree). + +Usage: + + .molecule-ci/scripts/validate-tree.py [] + +Exits non-zero on any violation. With no arg, defaults to the first of +{dev-department.yaml, org.yaml} that exists in cwd. + +Standard library only — runs on every CI runner without `pip install`. + +Refs: internal#77 (Phase 3b — task #223). +""" + +from __future__ import annotations + +import os +import sys +import re +from pathlib import Path +from typing import Any + +try: + import yaml # PyYAML +except ImportError: + sys.stderr.write( + "validate-tree.py: PyYAML required. Install via `pip install pyyaml` or use a runner that bundles it.\n" + ) + sys.exit(2) + + +# ---------- !include + children: walker ---------- + +INCLUDE_TAG = "!include" + + +class IncludingLoader(yaml.SafeLoader): + """SafeLoader that records `!include ` scalars verbatim instead + of trying to resolve them. We do resolution explicitly so we can also + track the parent→child edge for the orphan/duplicate check.""" + + +def _include_constructor(loader: yaml.Loader, node: yaml.Node) -> dict: + """Replace a `!include` scalar with a sentinel dict the walker + interprets. We don't resolve the file content here — the walker does + that with full path-context awareness.""" + if not isinstance(node, yaml.ScalarNode): + raise yaml.YAMLError(f"!include must be a scalar path; got {node.tag} at line {node.start_mark.line}") + return {"__include__": loader.construct_scalar(node)} + + +IncludingLoader.add_constructor(INCLUDE_TAG, _include_constructor) + + +def _yaml_load(path: Path) -> Any: + with path.open("r", encoding="utf-8") as f: + return yaml.load(f, Loader=IncludingLoader) + + +# ---------- Tree walk ---------- + + +class TreeReport: + def __init__(self) -> None: + self.parent_of: dict[str, str] = {} # workspace-folder → parent-folder + self.cross_tree_refs: list[tuple[str, str]] = [] # (where, escaping path) + self.duplicates: list[tuple[str, str, str]] = [] # (folder, parent_a, parent_b) + self.missing_workspace_yaml: list[str] = [] # folders referenced as children but no workspace.yaml + self.errors: list[str] = [] # generic errors (yaml parse, missing include) + + def add_edge(self, parent_folder: str, child_folder: str) -> None: + if child_folder in self.parent_of: + self.duplicates.append((child_folder, self.parent_of[child_folder], parent_folder)) + else: + self.parent_of[child_folder] = parent_folder + + def reachable(self) -> set[str]: + return set(self.parent_of.keys()) + + def has_violations(self) -> bool: + return bool(self.cross_tree_refs or self.duplicates or self.missing_workspace_yaml or self.errors) + + +def _walk_workspace_node( + node: Any, + yaml_dir: Path, # dir of the YAML file currently being processed (for relative paths) + repo_root: Path, # repo root (for orphan-set comparison + escape detection) + parent_folder: str | None, + report: TreeReport, +) -> None: + """Walk a workspace-shaped dict (or list of children) recursively. + + For each `!include` we encountered (now wrapped as `{"__include__": ""}`), + we resolve to the target file, register the workspace folder, and + recurse into the loaded content. + """ + if node is None: + return + + # Top-level YAML doc may have `workspaces:` or `roots:` (the + # dev-department.yaml convention) listing the root workspaces. + if isinstance(node, dict) and ("workspaces" in node or "roots" in node): + roots = node.get("roots") or node.get("workspaces") or [] + for child in roots: + _walk_workspace_node(child, yaml_dir, repo_root, parent_folder=None, report=report) + return + + # !include sentinel: resolve, register, recurse. + if isinstance(node, dict) and "__include__" in node: + rel = node["__include__"] + target = (yaml_dir / rel).resolve() + try: + target.relative_to(repo_root.resolve()) + except ValueError: + # The !include path escapes the repo root. This is the + # cross-repo symlink case (parent template !include-ing into + # the dev-department subtree via a symlink). The child folder + # is OUTSIDE repo_root — record but don't claim as duplicate. + # For the dev-department validator, repo_root IS dev-department, + # so its own internal !includes never escape; cross-repo + # composition is parent-template's concern. + report.errors.append( + f"!include {rel!r} (from {yaml_dir.name}) resolves outside repo root: {target}" + ) + return + if not target.exists(): + report.errors.append(f"!include {rel!r} (from {yaml_dir.name}): target does not exist: {target}") + return + + # If the include targets a workspace.yaml, the FOLDER containing + # it is the workspace identity. + if target.name == "workspace.yaml": + child_folder = str(target.parent.resolve().relative_to(repo_root.resolve())) + else: + # Team-shaped !include (e.g. teams/core-platform.yaml) — not a + # workspace folder of its own. Recurse into its content. + child_folder = None + + if child_folder is not None and parent_folder is not None: + # Reject `..` traversal in the path the user wrote (atomization + # rule). The resolved target may legitimately be in a parent + # folder (sibling tree), but the dev-department's `children:` + # paths are required to be `./` only. + if rel.startswith("..") or "/.." in rel: + report.cross_tree_refs.append((parent_folder, rel)) + + if child_folder is not None: + report.add_edge(parent_folder or "", child_folder) + + # Load and recurse. + try: + sub = _yaml_load(target) + except yaml.YAMLError as e: + report.errors.append(f"yaml parse {target}: {e}") + return + _walk_workspace_node( + sub, + yaml_dir=target.parent, + repo_root=repo_root, + parent_folder=child_folder if child_folder is not None else parent_folder, + report=report, + ) + return + + # Inline workspace-shaped dict. + if isinstance(node, dict): + # `files_dir:` identifies the workspace folder for inline declarations. + files_dir = node.get("files_dir") + if files_dir and parent_folder is None: + # A root-level workspace declared inline (no !include). The + # files_dir is the folder. + files_dir_resolved = (repo_root / files_dir).resolve() + try: + rel_to_root = files_dir_resolved.relative_to(repo_root.resolve()) + except ValueError: + report.errors.append(f"files_dir {files_dir!r} escapes repo root") + return + this_folder = str(rel_to_root) + report.add_edge("", this_folder) + # Verify a workspace.yaml exists in that folder for atomized + # tree (post-Phase 3c-2). + ws_yaml = files_dir_resolved / "workspace.yaml" + if not ws_yaml.exists(): + # Pre-atomization, a workspace can be declared inline at + # the manifest level without a workspace.yaml in its + # files_dir. Don't false-positive. + pass + current_folder = this_folder + else: + current_folder = parent_folder + + # Recurse into children. + for child in node.get("children") or []: + _walk_workspace_node(child, yaml_dir, repo_root, current_folder, report) + return + + if isinstance(node, list): + for child in node: + _walk_workspace_node(child, yaml_dir, repo_root, parent_folder, report) + return + + +# ---------- Filesystem scan ---------- + + +# Folders inside the repo that are NOT workspace folders. The validator +# allows these to exist without a parent in the tree. +NON_WORKSPACE_DIRS = { + ".git", ".github", ".molecule-ci", "docs", "scripts", "tests", "fixtures", + "node_modules", "__pycache__", ".cache", ".venv", "venv", +} + + +def _scan_workspace_folders(repo_root: Path) -> set[str]: + """Every directory containing a workspace.yaml is a workspace folder. + Path returned is repo-relative and POSIX-style.""" + found: set[str] = set() + for dirpath, dirnames, filenames in os.walk(repo_root, followlinks=False): + # Prune obvious non-workspace dirs. + dirnames[:] = [d for d in dirnames if d not in NON_WORKSPACE_DIRS] + if "workspace.yaml" in filenames: + rel = Path(dirpath).resolve().relative_to(repo_root.resolve()) + if str(rel) != ".": + found.add(str(rel)) + return found + + +# ---------- Top-level ---------- + + +def _find_manifest() -> Path: + for name in ("dev-department.yaml", "org.yaml"): + p = Path(name) + if p.exists(): + return p + sys.stderr.write( + "validate-tree.py: no manifest found in cwd. Looked for: dev-department.yaml, org.yaml\n" + ) + sys.exit(2) + + +def main() -> int: + if len(sys.argv) > 2: + sys.stderr.write("usage: validate-tree.py []\n") + return 2 + manifest = Path(sys.argv[1]) if len(sys.argv) == 2 else _find_manifest() + if not manifest.exists(): + sys.stderr.write(f"validate-tree.py: manifest does not exist: {manifest}\n") + return 2 + + repo_root = manifest.resolve().parent + report = TreeReport() + + try: + root_doc = _yaml_load(manifest) + except yaml.YAMLError as e: + sys.stderr.write(f"validate-tree.py: parsing {manifest}: {e}\n") + return 2 + + _walk_workspace_node( + root_doc, + yaml_dir=manifest.parent.resolve(), + repo_root=repo_root, + parent_folder=None, + report=report, + ) + + fs_workspaces = _scan_workspace_folders(repo_root) + reachable = report.reachable() + orphans = sorted(fs_workspaces - reachable) + + # Build report. + print(f"=== validate-tree.py report — manifest: {manifest} ===") + print(f" filesystem workspace folders : {len(fs_workspaces)}") + print(f" reachable from manifest : {len(reachable)}") + print(f" orphans : {len(orphans)}") + print(f" cross-tree '..' refs : {len(report.cross_tree_refs)}") + print(f" duplicate-parent claims : {len(report.duplicates)}") + print(f" missing workspace.yaml : {len(report.missing_workspace_yaml)}") + print(f" generic errors : {len(report.errors)}") + print() + + if orphans: + print("ORPHANS (workspace folder exists but no parent claims it):") + for o in orphans: + print(f" - {o}") + print() + if report.cross_tree_refs: + print("CROSS-TREE '..' REFS (atomization rule violation):") + for parent, path in report.cross_tree_refs: + print(f" - parent={parent} path={path}") + print() + if report.duplicates: + print("DUPLICATE PARENT CLAIMS (graph not a tree):") + for child, p1, p2 in report.duplicates: + print(f" - child={child} claimed_by=[{p1}, {p2}]") + print() + if report.errors: + print("ERRORS:") + for e in report.errors: + print(f" - {e}") + print() + + fail = bool(orphans) or report.has_violations() + if fail: + print("FAIL — see above") + return 1 + print("OK — tree is clean") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/README.md b/README.md index a79da8c..ca102fc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,141 @@ -# molecule-dev-department +# molecule-ai/molecule-dev-department -Importable dev-team subtree for molecule-ai org templates. Extracted from molecule-ai-org-template-molecule-dev (internal#77). Atomized: each workspace owns its persona, plugins, skills, and .env. Composed via folder-tree (no !include cross-references). \ No newline at end of file +**Importable engineering-tree subtree** for Molecule AI org templates. + +This repo is **not a standalone org template**. It is designed to be +grafted into a parent template (e.g. `molecule-ai-org-template-molecule-dev`) +via filesystem symlink at deploy time. The parent template owns the org +identity, top-level workspaces (PM, Marketing, Research, …), and +imports this repo's `dev-lead/` subtree as its engineering org. + +## Why a separate repo + +`molecule-ai-org-template-molecule-dev` had grown to ~60 workspace +folders + 11 `teams/*.yaml` composition files + 17 *orphaned* folders +that no `!include` chain reached. The orphan accumulation was a sign +the structure had outgrown a single repo. + +Splitting the dev tree out: + +- Atomizes engineering as a self-contained unit that other org templates + can reuse (one link to add the whole department). +- Makes orphan accumulation impossible — the validator (CI gate) walks + the manifest → roots → children and fails on any folder not reachable. +- Lets the dev tree evolve on its own cadence without churning the + parent template. +- Keeps the parent template's structure focused on org identity (PM, + Marketing, Research) and removes the ~50% of mass that's dev-specific. + +Full design rationale: [internal#77 RFC](https://git.moleculesai.app/molecule-ai/internal/issues/77) + +## Subtree contract + +This repo is consumed by parent templates via this convention: + +1. **Operator-side deploy layout** clones both repos as siblings under + `/org-templates/`: + + ``` + /org-templates/ + molecule-ai-org-template-molecule-dev/ ← parent template + molecule-dev-department/ ← THIS repo + ``` + +2. **Parent template** has a relative directory symlink at its root + (or under `teams/`): + + ``` + parent-template/ + org.yaml + dev → ../molecule-dev-department/ ← symlink + ``` + +3. **Parent's `org.yaml`** imports the subtree: + + ```yaml + workspaces: + - !include teams/pm.yaml + - !include teams/marketing.yaml + - !include dev/dev-lead/workspace.yaml ← into the symlinked subtree + ``` + +4. **Workspace `files_dir:` paths inside this repo** use the symlink + prefix (`dev/`) so they resolve correctly when the + subtree is imported via the parent. This means the subtree is **not + directly importable as a standalone org template** — by design. + +The platform's org importer (`workspace-server/internal/handlers/org_include.go`) +follows symlinks at the OS layer (`os.ReadFile` is symlink-aware) while +its security check (`filepath.Abs` / `filepath.Rel`) operates on path +strings (passes for symlinked paths because the link's path is inside +the parent root). The contract is pinned by tests in +[molecule-core PR #102](https://git.moleculesai.app/molecule-ai/molecule-core/pulls/102). + +## Repo layout + +``` +. +├── dev-department.yaml ← manifest: defaults + category_routing + roots +├── .molecule-ci/scripts/ +│ └── validate-tree.py ← orphan / reachability lint (CI gate) +├── .github/workflows/ +│ └── validate.yml ← runs validate-tree.py on every PR +├── README.md ← this file +├── LICENSE ← MIT +└── ← scaffolded empty; populated by Phase 3c-2 +``` + +After Phase 3c-2 (extract dev tree with git history) the repo will +contain the dev-lead/ workspace tree with nested sub-teams. After +Phase 3c-3 (move documentation-specialist + triage-operator into the +tree per Hongming Q1+Q2) those workspaces will live under +`dev-lead/app-docs/documentation-specialist/` and `dev-lead/triage-operator/` +respectively. + +## Validating locally + +```bash +.molecule-ci/scripts/validate-tree.py +# OK — tree is clean + +# Or with explicit manifest: +.molecule-ci/scripts/validate-tree.py dev-department.yaml +``` + +The validator: + +- Walks `dev-department.yaml → roots → children` recursively, including + through `!include` directives. +- Lists every directory containing `workspace.yaml`. +- Reports orphans (filesystem dirs not reachable from manifest), + cross-tree `..` traversal in `children:` paths, duplicate parents, + and missing `workspace.yaml`. +- Exits non-zero on any violation. + +CI runs the same script via `.github/workflows/validate.yml` on every +push and PR — orphan accumulation is caught at PR time, not at deploy +time. + +## Phase status + +| Phase | Status | Where | +|---|---|---| +| 1 — Investigate platform org importer | ✓ done | internal#77 comment 1886 | +| 2 — Design (SSOT, alternatives, security, versioning) | ✓ done | internal#77 | +| 3a — Platform `external:` ref support | parked (deferred) | task #222 | +| 3b — Validator + CI gate | ✓ done | this commit | +| 3c-1 — Scaffold this repo | ✓ done | this commit | +| 3c-2 — Extract dev tree with history | pending | task #224 | +| 3c-3 — Atomize structure + move doc-spec + triage-op | pending | task #224 | +| 3d — Slim parent template + wire symlink + delete orphans | pending | task #225 | +| 4 — End-to-end verify on staging | pending | task #226 | + +## Refs + +- [internal#77](https://git.moleculesai.app/molecule-ai/internal/issues/77) — extraction RFC +- [molecule-core#102](https://git.moleculesai.app/molecule-ai/molecule-core/pulls/102) — symlink-resolution test +- Hongming GO 2026-05-08 ("you own this feature and repos, start") + +## License + +MIT — see [LICENSE](./LICENSE) diff --git a/dev-department.yaml b/dev-department.yaml new file mode 100644 index 0000000..f3151bc --- /dev/null +++ b/dev-department.yaml @@ -0,0 +1,80 @@ +# Molecule AI — Dev Department subtree manifest +# +# This file is the importable-subtree's root config. It carries the same +# shape as a full org template's `org.yaml` — defaults + category_routing +# + plugin set + roots — but is consumed via gitops-style symlink from a +# parent template (see README §Subtree contract). +# +# Hongming-confirmed name: dev-department.yaml (2026-05-08). +# +# Refs: +# internal#77 — gitops-style extraction RFC +# molecule-core#102 — symlink-resolution contract pinned by tests + +name: Molecule AI Dev Department +description: >- + Importable subtree containing the engineering org tree: + Dev Lead + Core Platform + Controlplane + App-Docs (incl. Documentation + Specialist) + Infra + SDK sub-teams, plus floaters (Release Manager, + Integration Tester, Fullstack), plus Triage Operator. + +# Defaults applied to every workspace in this subtree. Per-workspace +# `plugins:` field UNIONs with this list (see Hongming Q1: per-workspace +# plugins are first-class). A leading `!` or `-` opts a default plugin +# OUT for one workspace. +# +# Same shape as parent's org.yaml `defaults:` block. When this manifest +# is grafted into a parent template via the symlink contract, the +# parent's own defaults still apply at the parent-template level — these +# only set defaults INSIDE the dev tree. +defaults: + runtime: claude-code + tier: 2 + + plugins: + - ecc # Everything Claude Code guardrails + coding skills + - molecule-dev # Molecule AI codebase conventions, past bugs, review-loop + - superpowers # systematic-debugging, TDD, planning, verification + - molecule-careful-bash # refuse destructive shell (rm -rf, push --force, DROP TABLE) + - molecule-prompt-watchdog # warn on destructive user prompts + - molecule-audit-trail # append every Edit/Write to .claude/audit.jsonl + - molecule-session-context # auto-load cron learnings + PR/issue counts on SessionStart + - molecule-skill-cron-learnings # per-tick learning JSONL (pairs with session-context) + - molecule-skill-update-docs # keep architecture / README / edit-history aligned + + # Audit-summary routing — Auditors fan out findings to the listed roles. + # Roles are by display name (Dev Lead, Backend Engineer, ...) not by + # workspace folder name. Roles must exist in this subtree's roots: + # block — the validator will catch dangling references in a follow-up. + category_routing: + security: [Backend Engineer, DevOps Engineer] + offensive: [Security Auditor, Backend Engineer, DevOps Engineer] + ui: [Frontend Engineer] + ux: [Frontend Engineer] + infra: [DevOps Engineer, Platform Engineer, SRE Engineer] + cloud: [DevOps Engineer, Platform Engineer, SRE Engineer, Backend Engineer] + qa: [QA Engineer] + performance: [Backend Engineer] + docs: [Documentation Specialist] + mixed: [Dev Lead] + research: [Research Lead] + plugins: [Technical Researcher] + template: [Dev Lead] + channels: [DevOps Engineer] + + idle_prompt: "" # Off by default — set per-workspace to enable idle reflection + +# Roots block: list the top-level workspaces of this subtree. +# +# Each root entry is a `!include /workspace.yaml` reference to a +# workspace folder at the repo root level. The validator walks each +# referenced workspace.yaml recursively via its `children:` field. +# +# Atomization rule (Hongming Q3+Q5): `children:` paths inside a +# workspace.yaml MUST be relative-and-down-only (`./`); no `..`. +# The `.molecule-ci/scripts/validate-tree.py` CI gate enforces this. +# +# This list is empty in the scaffold commit. Phase 3c-2 (extract content +# with git history) populates it. Phase 3c-3 nests doc-spec + triage-op +# under dev-lead/. +roots: []