diff --git a/.github-templates/CODEOWNERS b/.github-templates/CODEOWNERS
new file mode 100644
index 0000000..46cae53
--- /dev/null
+++ b/.github-templates/CODEOWNERS
@@ -0,0 +1 @@
+* @siddhss5
diff --git a/.github-templates/CODE_OF_CONDUCT.md b/.github-templates/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..00ac68a
--- /dev/null
+++ b/.github-templates/CODE_OF_CONDUCT.md
@@ -0,0 +1,13 @@
+# Code of Conduct
+
+This project adopts the [Contributor Covenant, version 2.1][cc].
+
+The full text is available at .
+
+## Reporting
+
+Instances of unacceptable behavior may be reported privately to the project
+maintainer at **siddhartha.srinivasa@gmail.com**. All reports will be
+reviewed and investigated promptly and fairly.
+
+[cc]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/
diff --git a/.github-templates/CONTRIBUTING.md b/.github-templates/CONTRIBUTING.md
new file mode 100644
index 0000000..a93a66d
--- /dev/null
+++ b/.github-templates/CONTRIBUTING.md
@@ -0,0 +1,50 @@
+# Contributing
+
+This repository is part of the [robot-code workspace](https://github.com/personalrobotics/robot-code).
+All projects in the workspace share a common layout (`src/` + `tests/`), a single
+package manager (`uv`), and a common CI setup.
+
+## Development setup
+
+```bash
+git clone https://github.com/personalrobotics/robot-code
+cd robot-code
+./setup.sh
+```
+
+This clones every sibling repo (including this one) into a single uv workspace
+and runs `uv sync`. You can then work in any sibling's directory.
+
+## Running tests and linters
+
+From inside this repo:
+
+```bash
+uv run pytest tests/ -v
+uv run ruff check .
+uv run ruff format --check .
+```
+
+From the workspace root, you can also run the cross-repo integration suite:
+
+```bash
+uv run pytest tests/integration -v
+```
+
+## Pull requests
+
+- Branch from `main`. Open a PR with a clear summary and test plan.
+- Per-repo CI (ruff + pytest) must pass.
+- Cross-repo integration CI in robot-code runs automatically when this repo's
+ `main` advances, and on scheduled nightly runs.
+- Review is by [@siddhss5](https://github.com/siddhss5) (enforced via CODEOWNERS).
+
+## Package manager: uv only
+
+We use [uv](https://docs.astral.sh/uv/) exclusively. **Do not use pip.**
+The workspace layout in robot-code relies on uv's workspace resolution.
+
+## License
+
+By contributing, you agree that your contributions will be licensed under the
+MIT License (see [LICENSE](LICENSE)).
diff --git a/.github-templates/ISSUE_TEMPLATE/bug_report.md b/.github-templates/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..182f8e2
--- /dev/null
+++ b/.github-templates/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,22 @@
+---
+name: Bug report
+about: Report a reproducible bug
+labels: bug
+---
+
+## Environment
+- OS:
+- Python version:
+- uv version:
+- Package version or commit:
+
+## Reproduction
+
+
+## Expected behavior
+
+## Actual behavior
+
+## Logs / traceback
+```
+```
diff --git a/.github-templates/ISSUE_TEMPLATE/config.yml b/.github-templates/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..cefaf35
--- /dev/null
+++ b/.github-templates/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Questions and discussion
+ url: https://github.com/personalrobotics/robot-code/discussions
+ about: Ask questions or discuss design in the umbrella repo.
diff --git a/.github-templates/ISSUE_TEMPLATE/improvement.md b/.github-templates/ISSUE_TEMPLATE/improvement.md
new file mode 100644
index 0000000..bdc37c9
--- /dev/null
+++ b/.github-templates/ISSUE_TEMPLATE/improvement.md
@@ -0,0 +1,13 @@
+---
+name: Improvement
+about: Propose an enhancement to existing functionality
+labels: enhancement
+---
+
+## Motivation
+
+
+## Proposal
+
+
+## Alternatives considered
diff --git a/.github-templates/ISSUE_TEMPLATE/task.md b/.github-templates/ISSUE_TEMPLATE/task.md
new file mode 100644
index 0000000..7f1e3fd
--- /dev/null
+++ b/.github-templates/ISSUE_TEMPLATE/task.md
@@ -0,0 +1,12 @@
+---
+name: Task
+about: A planned unit of work
+labels: task
+---
+
+## Goal
+
+## Acceptance criteria
+- [ ]
+
+## Notes
diff --git a/.github-templates/LICENSE b/.github-templates/LICENSE
new file mode 100644
index 0000000..5834249
--- /dev/null
+++ b/.github-templates/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Siddhartha Srinivasa
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/.github-templates/SECURITY.md b/.github-templates/SECURITY.md
new file mode 100644
index 0000000..931e848
--- /dev/null
+++ b/.github-templates/SECURITY.md
@@ -0,0 +1,7 @@
+# Security Policy
+
+If you discover a security vulnerability in this project, please report it
+privately to **siddhartha.srinivasa@gmail.com**.
+
+Please do not open a public issue for security reports. You can expect an
+initial response within a few business days.
diff --git a/.github-templates/branch-protection.json b/.github-templates/branch-protection.json
new file mode 100644
index 0000000..e758d84
--- /dev/null
+++ b/.github-templates/branch-protection.json
@@ -0,0 +1,20 @@
+{
+ "required_status_checks": {
+ "strict": true,
+ "contexts": ["test (3.10)", "test (3.11)", "test (3.12)"]
+ },
+ "enforce_admins": false,
+ "required_pull_request_reviews": {
+ "dismiss_stale_reviews": true,
+ "require_code_owner_reviews": true,
+ "required_approving_review_count": 1,
+ "require_last_push_approval": false
+ },
+ "restrictions": null,
+ "required_linear_history": true,
+ "allow_force_pushes": false,
+ "allow_deletions": false,
+ "required_conversation_resolution": true,
+ "lock_branch": false,
+ "allow_fork_syncing": true
+}
diff --git a/.github-templates/pull_request_template.md b/.github-templates/pull_request_template.md
new file mode 100644
index 0000000..7b85935
--- /dev/null
+++ b/.github-templates/pull_request_template.md
@@ -0,0 +1,24 @@
+## Summary
+
+
+
+## Changes
+
+
+-
+
+## Testing
+
+- [ ] `uv run pytest tests/ -v` passes
+- [ ] `uv run ruff check .` passes
+- [ ] `uv run ruff format --check .` passes
+- [ ] Integration tested locally against the robot-code workspace (if cross-repo)
+
+## Breaking changes
+
+- [ ] None
+- [ ] Yes (describe migration below):
+
+## Related issues
+
+
diff --git a/.github-templates/workflows/ci.yml b/.github-templates/workflows/ci.yml
new file mode 100644
index 0000000..073ecc1
--- /dev/null
+++ b/.github-templates/workflows/ci.yml
@@ -0,0 +1,58 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.11", "3.12"]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ enable-cache: true
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: uv sync --all-extras --dev
+
+ - name: Ruff lint
+ run: uv run ruff check .
+
+ - name: Ruff format check
+ run: uv run ruff format --check .
+
+ - name: Pytest
+ run: |
+ if [ -d tests ]; then
+ uv run pytest tests/ -v
+ else
+ echo "No tests/ directory; skipping pytest."
+ fi
+
+ notify-robot-code:
+ needs: test
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Dispatch integration run
+ uses: peter-evans/repository-dispatch@v3
+ with:
+ token: ${{ secrets.ROBOT_CODE_DISPATCH_TOKEN }}
+ repository: personalrobotics/robot-code
+ event-type: sibling-updated
+ client-payload: '{"repo":"${{ github.event.repository.name }}","sha":"${{ github.sha }}"}'
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
new file mode 100644
index 0000000..8a3c610
--- /dev/null
+++ b/.github/workflows/integration.yml
@@ -0,0 +1,107 @@
+name: Workspace Integration
+
+on:
+ pull_request:
+ branches: [main]
+ schedule:
+ # 08:00 UTC nightly
+ - cron: "0 8 * * *"
+ workflow_dispatch:
+ inputs:
+ override_repo:
+ description: "Sibling repo name to pin to a specific sha (optional)"
+ required: false
+ override_sha:
+ description: "Commit sha for the override_repo (optional)"
+ required: false
+ repository_dispatch:
+ types: [sibling-updated]
+
+concurrency:
+ group: integration-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ integration:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.12"]
+ steps:
+ - name: Checkout robot-code
+ uses: actions/checkout@v4
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ enable-cache: true
+ python-version: ${{ matrix.python-version }}
+
+ - name: Bootstrap siblings
+ env:
+ # While any sibling repo is still private, setup.sh needs auth to clone.
+ # Once all repos are public, GH_TOKEN can be removed.
+ GH_TOKEN: ${{ secrets.SIBLING_CHECKOUT_TOKEN || secrets.GITHUB_TOKEN }}
+ run: |
+ # Configure git to use the token for personalrobotics clones.
+ git config --global url."https://x-access-token:${GH_TOKEN}@github.com/personalrobotics/".insteadOf "https://github.com/personalrobotics/"
+ ./setup.sh
+
+ - name: Override sibling at specific sha (dispatch)
+ if: github.event_name == 'repository_dispatch'
+ working-directory: ${{ github.event.client_payload.repo }}
+ run: |
+ git fetch origin "${{ github.event.client_payload.sha }}"
+ git checkout "${{ github.event.client_payload.sha }}"
+
+ - name: Override sibling at specific sha (manual)
+ if: github.event_name == 'workflow_dispatch' && inputs.override_repo != ''
+ working-directory: ${{ inputs.override_repo }}
+ run: |
+ git fetch origin "${{ inputs.override_sha }}"
+ git checkout "${{ inputs.override_sha }}"
+
+ - name: Re-sync after override
+ if: github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_dispatch' && inputs.override_repo != '')
+ run: uv sync
+
+ - name: Per-sibling unit tests
+ run: |
+ set +e
+ fail=0
+ for d in asset_manager geodude geodude_assets mj_environment mj_manipulator mj_viser prl_assets pycbirrt tsr; do
+ if [ -d "$d/tests" ]; then
+ echo "::group::$d tests"
+ (cd "$d" && uv run pytest tests/ -q) || fail=1
+ echo "::endgroup::"
+ fi
+ done
+ exit $fail
+
+ - name: Cross-repo integration tests
+ run: uv run pytest tests/integration -v
+
+ - name: Import smoke check
+ run: |
+ uv run python -c "import asset_manager, geodude, geodude_assets, mj_environment, mj_manipulator, mj_viser, prl_assets, pycbirrt, tsr; print('all imports OK')"
+
+ notify-on-nightly-failure:
+ needs: integration
+ if: failure() && github.event_name == 'schedule'
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ steps:
+ - uses: actions/github-script@v7
+ with:
+ script: |
+ const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+ await github.rest.issues.create({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ title: `Nightly integration failed (run ${context.runId})`,
+ body: `Nightly workspace-integration run failed.\n\nSee: ${runUrl}`,
+ labels: ["ci", "nightly-fail"],
+ });
diff --git a/.gitignore b/.gitignore
index a302cf3..240321c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,8 @@ pr_assets/
*.txt
*.md.bak
# Explicitly tracked: CLAUDE.md, README.md, GEODUDE_DESIGN.md
+!scripts/*.py
+!tests/**/*.py
*.zip
*.jpg
*.png
diff --git a/scripts/add_headers.py b/scripts/add_headers.py
new file mode 100755
index 0000000..fbf38f1
--- /dev/null
+++ b/scripts/add_headers.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+"""Prepend SPDX + copyright header to every .py file in one or more directories.
+
+Usage:
+ scripts/add_headers.py --dry-run [ ...]
+ scripts/add_headers.py --apply [ ...]
+
+The header is two lines:
+ # SPDX-License-Identifier: MIT
+ # Copyright (c) 2025 Siddhartha Srinivasa
+
+Files already containing "SPDX-License-Identifier" are skipped. Shebangs and
+coding declarations on lines 1-2 are preserved.
+"""
+
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2025 Siddhartha Srinivasa
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+HEADER_LINES = [
+ "# SPDX-License-Identifier: MIT",
+ "# Copyright (c) 2025 Siddhartha Srinivasa",
+]
+SKIP_DIR_NAMES = {".git", ".venv", "__pycache__", "node_modules", "build", "dist", ".eggs"}
+
+
+def should_skip(path: Path) -> bool:
+ return any(part in SKIP_DIR_NAMES for part in path.parts)
+
+
+def add_header(path: Path, *, apply: bool) -> str:
+ """Return one of: 'added', 'present', 'empty'."""
+ text = path.read_text(encoding="utf-8")
+ if "SPDX-License-Identifier" in text:
+ return "present"
+ lines = text.splitlines(keepends=True)
+ if not lines:
+ return "empty"
+
+ # Preserve shebang and optional coding declaration.
+ prefix: list[str] = []
+ i = 0
+ if lines and lines[0].startswith("#!"):
+ prefix.append(lines[0])
+ i = 1
+ if i < len(lines) and ("coding:" in lines[i] or "coding=" in lines[i]) and lines[i].lstrip().startswith("#"):
+ prefix.append(lines[i])
+ i += 1
+
+ new_header = "\n".join(HEADER_LINES) + "\n"
+ rest = "".join(lines[i:])
+ # Ensure a blank line between header and rest if rest is non-empty and does not already start with one.
+ if rest and not rest.startswith("\n"):
+ new_header += "\n"
+ new_text = "".join(prefix) + new_header + rest
+
+ if apply:
+ path.write_text(new_text, encoding="utf-8")
+ return "added"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ mode = parser.add_mutually_exclusive_group(required=True)
+ mode.add_argument("--dry-run", action="store_true")
+ mode.add_argument("--apply", action="store_true")
+ parser.add_argument("roots", nargs="+", type=Path)
+ args = parser.parse_args()
+
+ added = 0
+ present = 0
+ empty = 0
+ for root in args.roots:
+ if not root.exists():
+ print(f"[warn] missing: {root}", file=sys.stderr)
+ continue
+ for py in root.rglob("*.py"):
+ if should_skip(py):
+ continue
+ result = add_header(py, apply=args.apply)
+ if result == "added":
+ added += 1
+ prefix = "[add] " if args.apply else "[would-add] "
+ print(f"{prefix}{py}")
+ elif result == "present":
+ present += 1
+ else:
+ empty += 1
+
+ verb = "added" if args.apply else "would add"
+ print(f"\nSummary: {verb}={added} already-present={present} empty={empty}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/sync_templates.sh b/scripts/sync_templates.sh
new file mode 100755
index 0000000..b3e1598
--- /dev/null
+++ b/scripts/sync_templates.sh
@@ -0,0 +1,108 @@
+#!/usr/bin/env bash
+# Sync canonical templates from .github-templates/ into each sibling repo.
+#
+# Usage:
+# scripts/sync_templates.sh --dry-run # show what would change (all repos)
+# scripts/sync_templates.sh --apply # apply to all repos
+# scripts/sync_templates.sh --apply --repo=tsr # apply to one repo
+#
+# The script copies files from .github-templates/ to each sibling. It does NOT
+# touch each sibling's pyproject.toml, README, LICENSE text, or source headers
+# — those are per-repo and handled separately.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+TEMPLATES="$ROOT/.github-templates"
+
+SIBLINGS=(
+ asset_manager
+ geodude
+ geodude_assets
+ mj_environment
+ mj_manipulator
+ mj_manipulator_ros
+ mj_viser
+ prl_assets
+ pycbirrt
+ tsr
+)
+
+# Files to copy from .github-templates/ into each sibling (src -> dst).
+# Left column is the path under .github-templates/; right column is the
+# destination path under the sibling repo.
+FILES=(
+ "CODEOWNERS:.github/CODEOWNERS"
+ "pull_request_template.md:.github/pull_request_template.md"
+ "ISSUE_TEMPLATE/bug_report.md:.github/ISSUE_TEMPLATE/bug_report.md"
+ "ISSUE_TEMPLATE/improvement.md:.github/ISSUE_TEMPLATE/improvement.md"
+ "ISSUE_TEMPLATE/task.md:.github/ISSUE_TEMPLATE/task.md"
+ "ISSUE_TEMPLATE/config.yml:.github/ISSUE_TEMPLATE/config.yml"
+ "workflows/ci.yml:.github/workflows/ci.yml"
+ "CONTRIBUTING.md:CONTRIBUTING.md"
+ "SECURITY.md:SECURITY.md"
+ "CODE_OF_CONDUCT.md:CODE_OF_CONDUCT.md"
+ "LICENSE:LICENSE"
+)
+
+MODE="dry-run"
+ONLY_REPO=""
+for arg in "$@"; do
+ case "$arg" in
+ --dry-run) MODE="dry-run" ;;
+ --apply) MODE="apply" ;;
+ --repo=*) ONLY_REPO="${arg#--repo=}" ;;
+ -h|--help)
+ sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ *) echo "unknown arg: $arg" >&2; exit 2 ;;
+ esac
+done
+
+sync_one() {
+ local sibling="$1"
+ local sib_dir="$ROOT/$sibling"
+ if [ ! -d "$sib_dir" ]; then
+ echo " [skip] $sibling (not cloned)"
+ return
+ fi
+ echo "== $sibling =="
+ for pair in "${FILES[@]}"; do
+ local src_rel="${pair%%:*}"
+ local dst_rel="${pair##*:}"
+ local src="$TEMPLATES/$src_rel"
+ local dst="$sib_dir/$dst_rel"
+ if [ ! -f "$src" ]; then
+ echo " [warn] missing template: $src_rel"
+ continue
+ fi
+ if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
+ # Already in sync.
+ continue
+ fi
+ if [ "$MODE" = "apply" ]; then
+ mkdir -p "$(dirname "$dst")"
+ cp "$src" "$dst"
+ echo " [sync] $dst_rel"
+ else
+ if [ -f "$dst" ]; then
+ echo " [diff] $dst_rel"
+ else
+ echo " [new] $dst_rel"
+ fi
+ fi
+ done
+}
+
+if [ -n "$ONLY_REPO" ]; then
+ sync_one "$ONLY_REPO"
+else
+ for s in "${SIBLINGS[@]}"; do
+ sync_one "$s"
+ done
+fi
+
+if [ "$MODE" = "dry-run" ]; then
+ echo ""
+ echo "Dry run only. Re-run with --apply to make changes."
+fi
diff --git a/tests/integration/README.md b/tests/integration/README.md
new file mode 100644
index 0000000..35eb9a9
--- /dev/null
+++ b/tests/integration/README.md
@@ -0,0 +1,18 @@
+# Workspace integration tests
+
+Cross-repo tests that exercise multiple sibling packages together. Unlike each
+sibling's own `tests/`, these tests assume the full uv workspace is checked out
+(i.e. `./setup.sh` has been run) and verify that the packages compose correctly.
+
+Run them from the workspace root:
+
+```bash
+uv run pytest tests/integration -v
+```
+
+## Guidelines
+
+- Keep tests **headless** (no display, no interactive viewer).
+- Keep runtime under ~10s per test when possible.
+- Prefer smoke checks (construct, step once, destroy) over full demos.
+- If a test needs a real robot or GPU, skip it via `pytest.mark.skipif` so CI stays green.
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/integration/test_imports.py b/tests/integration/test_imports.py
new file mode 100644
index 0000000..83077f5
--- /dev/null
+++ b/tests/integration/test_imports.py
@@ -0,0 +1,25 @@
+"""Verify every workspace sibling package is importable together."""
+
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2025 Siddhartha Srinivasa
+
+import importlib
+
+import pytest
+
+SIBLINGS = [
+ "asset_manager",
+ "geodude",
+ "geodude_assets",
+ "mj_environment",
+ "mj_manipulator",
+ "mj_viser",
+ "prl_assets",
+ "pycbirrt",
+ "tsr",
+]
+
+
+@pytest.mark.parametrize("name", SIBLINGS)
+def test_sibling_importable(name: str) -> None:
+ importlib.import_module(name)
diff --git a/tests/integration/test_no_stale_attribution.py b/tests/integration/test_no_stale_attribution.py
new file mode 100644
index 0000000..34580e7
--- /dev/null
+++ b/tests/integration/test_no_stale_attribution.py
@@ -0,0 +1,55 @@
+"""Guard against regressions: no UW/PRL attribution strings in any sibling repo.
+
+This test fails if any sibling repo reintroduces 'University of Washington',
+'Personal Robotics Lab(oratory)', or the old UW email address in text files.
+"""
+
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2025 Siddhartha Srinivasa
+
+from pathlib import Path
+
+BANNED_PATTERNS = [
+ "University of Washington",
+ "Personal Robotics Laboratory",
+ "Personal Robotics Lab,",
+ "siddh@cs.washington.edu",
+]
+
+SIBLING_DIRS = [
+ "asset_manager",
+ "geodude",
+ "geodude_assets",
+ "mj_environment",
+ "mj_manipulator",
+ "mj_manipulator_ros",
+ "mj_viser",
+ "prl_assets",
+ "pycbirrt",
+ "tsr",
+]
+
+TEXT_SUFFIXES = {".py", ".md", ".toml", ".yaml", ".yml", ".txt", ".cfg", ".ini", ".xml"}
+SKIP_DIR_NAMES = {".git", ".venv", "__pycache__", "node_modules", "mujoco_menagerie", "build", "dist"}
+
+
+def test_no_banned_attribution() -> None:
+ root = Path(__file__).resolve().parents[2]
+ hits: list[str] = []
+ for sibling in SIBLING_DIRS:
+ sib_dir = root / sibling
+ if not sib_dir.is_dir():
+ continue
+ for path in sib_dir.rglob("*"):
+ if not path.is_file() or path.suffix not in TEXT_SUFFIXES:
+ continue
+ if any(part in SKIP_DIR_NAMES for part in path.parts):
+ continue
+ try:
+ text = path.read_text(encoding="utf-8", errors="ignore")
+ except OSError:
+ continue
+ for pat in BANNED_PATTERNS:
+ if pat in text:
+ hits.append(f"{path.relative_to(root)}: {pat!r}")
+ assert not hits, "Banned attribution strings found:\n " + "\n ".join(hits)