diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..0e1c814 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# ace CODEOWNERS +# Maintainers own merge. Path owners review; they are not a merge bot. +* @kvnloo diff --git a/.github/DEPLOYMENT.md b/.github/DEPLOYMENT.md index 9215afe..14d0d06 100644 --- a/.github/DEPLOYMENT.md +++ b/.github/DEPLOYMENT.md @@ -32,6 +32,8 @@ After deployment, your sites will be available at: - **Production (main)**: `https://.github.io//` - **Development (dev)**: `https://.github.io//dev/` +Rolling Verified OSS Loop branches (`preview`, `nightly`) are **not** Pages slots. Do not invent `/preview//` without extending `.github/workflows/deploy.yml`. + Replace `` with your GitHub username and `` with your repository name. ## How It Works diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..c3978b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,31 @@ +name: Bug +description: Something is broken. Include a red command if you have one. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Search open issues and PRs first. Issues are not claims — a claim lease comes after this is triaged `claimable`. + - type: textarea + id: expected + attributes: + label: Expected + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual + validations: + required: true + - type: textarea + id: red + attributes: + label: Red command + description: Exact command that fails, or "manual / no command yet" + placeholder: pytest tests/test_foo.py::test_bar + - type: textarea + id: environment + attributes: + label: Environment + placeholder: OS, language version, commit SHA diff --git a/.github/ISSUE_TEMPLATE/claim.yml b/.github/ISSUE_TEMPLATE/claim.yml new file mode 100644 index 0000000..0a87591 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/claim.yml @@ -0,0 +1,25 @@ +name: Claim +description: Bounded claim lease for an already-triaged claimable issue. Prefer a comment on the existing issue. +title: "claim: " +labels: ["claimed"] +body: + - type: markdown + attributes: + value: | + Prefer commenting this YAML on the existing issue. Open this form only if the project asks for a dedicated claim issue. + - type: textarea + id: lease + attributes: + label: Lease + value: | + ```yaml + issue: + claimant: + base_revision: + claimed_at: + expires_at: + scope: + work_url: + ``` + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..de0aa65 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Discussions + url: https://github.com/kvnloo/ace/discussions + about: Ideas, adoption help, or questions that are not a claimable issue diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..5ac0089 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,26 @@ +name: Feature +description: A proposed change. Maintainers own whether it is claimable. +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Do not start work until this issue is `claimable` and you hold a claim lease. + - type: textarea + id: problem + attributes: + label: Problem + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + validations: + required: true + - type: textarea + id: acceptance + attributes: + label: Acceptance + description: What a reviewer can check on an exact head + placeholder: test name, docs path, or runtime check diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e85e1a3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,37 @@ +## Summary + + + +## Checklist + +- [ ] **Ownership respected**: changes stay inside `AGENTS.md` boundaries +- [ ] **Searched issues/PRs**: no duplicate in-flight work +- [ ] **Claimed issue**: work started after a bounded claim, not from the issue title alone +- [ ] **Fail-then-pass**: bug fixes include the red command and the green command +- [ ] **No secrets**: no tokens, API keys, `.env`, or pairing files +- [ ] **Mode** (select one): + - [ ] **Unattended** — donated compute (cloud agent) + - [ ] **Copilot** — human-supervised +- [ ] **Workers never merge `main`/`dev`**: this PR does not grant worker merge of production + +## Evidence + +```yaml +issue: +base_revision: +head_revision: +tests: + red: + green: + sabotage: +mutation: n/a +runtime_evidence: [] +limitations: [] +ai_assistance: +``` + +Tests from another head are not evidence. If mutation is `n/a`, write `n/a` — do not invent a score. The receipt workflow fails the PR if these keys are empty or `head_revision` is not this PR's SHA. + +## Related + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..04d1c80 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# Stack-neutral: GitHub Actions only. Do not add npm/pip/cargo ecosystems here; +# oss-onboard must not bake a language lockfile updater into every target. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - enhancement + - area:verify diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..fc2daaf --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,25 @@ +area:docs: + - changed-files: + - any-glob-to-any-file: + - 'docs/**/*' + - '*.md' + - 'AGENTS.md' + - 'README.md' + - 'CONTRIBUTING.md' + +area:loop: + - changed-files: + - any-glob-to-any-file: '.github/**/*' + +area:verify: + - changed-files: + - any-glob-to-any-file: + - 'tests/**/*' + - 'scripts/**/*' + - '**/*.test.*' + - 'skills/tdd/**' + - 'skills/verify/**' + - 'skills/orient/**' + - 'skills/anti-slop/**' + - 'skills/pstack/**' + - 'skills/dr-eggbot/**' diff --git a/.github/labels.md b/.github/labels.md new file mode 100644 index 0000000..cdc413b --- /dev/null +++ b/.github/labels.md @@ -0,0 +1,37 @@ +# Labels for the Verified OSS Loop + +Create these in GitHub Settings → Labels, or copy and run `.github/scripts/create-labels.sh` (`--labels` on init). + +## Loop + +| Name | Color | Meaning | +|---|---|---| +| claimable | 0E8A16 | Maintainers opened this for a bounded claim | +| claimed | FBCA04 | A live lease exists | +| needs-discussion | D876E3 | Maintainer or worker proposal; not a claim until promoted to claimable | +| needs-review | 5319E7 | Evidence receipt is attached; independent review next | +| blocked | D93F0B | External or policy block | +| keep | 1D76DB | KEEP after merge | +| discard | 6A737D | DISCARD with a lesson, not silence | + +## Priority + +| Name | Color | +|---|---| +| priority:P0 | D73A4A | +| priority:P1 | E99695 | +| priority:P2 | F9D0C4 | +| priority:P3 | FEF2C0 | + +## Kind + +| Name | Color | +|---|---| +| bug | D73A4A | +| enhancement | A2EEEF | +| security | B60205 | +| good-first-issue | 7057FF | +| area:docs | 0075CA | +| area:loop | C5DEF5 | +| area:verify | BFDADC | +| stale | FFFFFF | diff --git a/.github/scripts/check-receipt.py b/.github/scripts/check-receipt.py new file mode 100644 index 0000000..0e7e545 --- /dev/null +++ b/.github/scripts/check-receipt.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Check a Verified OSS Loop evidence receipt. Stdlib only. Does not merge.""" +from __future__ import annotations + +import argparse +import re +import sys +from typing import Any + +SHA_RE = re.compile(r"^[0-9a-f]{7,40}$", re.I) +REQUIRED_TOP = ("issue", "base_revision", "head_revision") + + +def extract_yaml_blocks(text: str) -> list[str]: + blocks = re.findall(r"```(?:ya?ml)\s*\n(.*?)```", text, re.S | re.I) + if blocks: + return blocks + stripped = text.strip() + if stripped.startswith("---") or re.search(r"^(issue|base_revision|head_revision)\s*:", stripped, re.M): + return [text] + return [text] + + +def parse_simple_yaml(block: str) -> dict[str, Any]: + """Tiny YAML subset: top-level keys and one nested mapping (tests).""" + data: dict[str, Any] = {} + current_map: dict[str, str] | None = None + current_key: str | None = None + for raw in block.splitlines(): + if not raw.strip() or raw.strip().startswith("#"): + continue + nested = re.match(r"^ ([A-Za-z0-9_]+)\s*:\s*(.*)$", raw) + if nested and current_map is not None: + current_map[nested.group(1)] = nested.group(2).strip() + continue + top = re.match(r"^([A-Za-z0-9_]+)\s*:\s*(.*)$", raw) + if not top: + continue + key, val = top.group(1), top.group(2).strip() + if val == "" or val == "|": + current_map = {} + data[key] = current_map + current_key = key + continue + data[key] = val + current_map = None + current_key = key + _ = current_key + return data + + +def pick_receipt(text: str) -> dict[str, Any]: + best: dict[str, Any] = {} + score = -1 + for block in extract_yaml_blocks(text): + parsed = parse_simple_yaml(block) + hits = sum(1 for k in REQUIRED_TOP if k in parsed) + if hits > score: + best, score = parsed, hits + return best + + +def nonempty(val: Any) -> bool: + if val is None: + return False + if isinstance(val, dict): + return any(nonempty(v) for v in val.values()) + return str(val).strip() not in ("", "null", "~") + + +def sha_ok(val: Any) -> bool: + s = str(val).strip().lower() + return bool(SHA_RE.match(s)) + + +def heads_match(receipt_head: str, expected: str) -> bool: + a, b = receipt_head.strip().lower(), expected.strip().lower() + return a == b or a.startswith(b) or b.startswith(a) + + +def check(text: str, expected_head: str | None = None) -> list[str]: + errors: list[str] = [] + data = pick_receipt(text) + if not data: + return ["no evidence YAML found (need a ```yaml receipt with issue/base_revision/head_revision)"] + + for key in REQUIRED_TOP: + if key not in data: + errors.append(f"missing {key}") + elif not nonempty(data[key]): + errors.append(f"{key} is empty") + + if "base_revision" in data and nonempty(data["base_revision"]) and not sha_ok(data["base_revision"]): + errors.append("base_revision is not a git SHA") + if "head_revision" in data and nonempty(data["head_revision"]) and not sha_ok(data["head_revision"]): + errors.append("head_revision is not a git SHA") + + tests = data.get("tests") + if not isinstance(tests, dict): + errors.append("missing tests mapping with red/green") + else: + if not nonempty(tests.get("red")): + errors.append("tests.red is empty") + if not nonempty(tests.get("green")): + errors.append("tests.green is empty") + + if expected_head: + head = str(data.get("head_revision") or "").strip() + if not head: + errors.append("head_revision missing; cannot bind to exact head") + elif not heads_match(head, expected_head): + errors.append( + f"head_revision {head} does not match PR head {expected_head} " + "(tests from another SHA are not evidence)" + ) + return errors + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Validate a Verified OSS Loop evidence receipt") + p.add_argument("--file", help="PR body or receipt markdown/YAML (default: stdin)") + p.add_argument("--head", help="Expected PR head SHA (exact-head check)") + args = p.parse_args(argv) + if args.file: + with open(args.file, encoding="utf-8") as f: + text = f.read() + else: + text = sys.stdin.read() + errors = check(text, args.head) + if errors: + print("receipt check failed:") + for e in errors: + print(f" - {e}") + return 1 + print("ok: evidence receipt is complete") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/create-labels.sh b/.github/scripts/create-labels.sh new file mode 100755 index 0000000..5655cf3 --- /dev/null +++ b/.github/scripts/create-labels.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Create Verified OSS Loop labels on the current GitHub repo. +# Requires: gh auth. Does not overwrite existing label descriptions. +set -euo pipefail + +if ! command -v gh >/dev/null 2>&1; then + echo "gh is required" >&2 + exit 1 +fi + +create() { + local name="$1" color="$2" desc="$3" + if gh label list --limit 200 --json name --jq '.[].name' | grep -Fxq "$name"; then + echo "exists: $name" + return 0 + fi + gh label create "$name" --color "$color" --description "$desc" +} + +create claimable "0E8A16" "Maintainers opened this for a bounded claim" +create claimed "FBCA04" "A live claim lease exists" +create "needs-discussion" "D876E3" "Maintainer or worker proposal; not a claim until promoted to claimable" +create needs-review "5319E7" "Evidence receipt attached; independent review next" +create blocked "D93F0B" "External or policy block" +create keep "1D76DB" "KEEP after merge" +create discard "6A737D" "DISCARD with a recorded lesson" +create "priority:P0" "D73A4A" "Critical" +create "priority:P1" "E99695" "High" +create "priority:P2" "F9D0C4" "Medium" +create "priority:P3" "FEF2C0" "Low" +create bug "D73A4A" "Something is broken" +create enhancement "A2EEEF" "Proposed change" +create security "B60205" "Security-sensitive; do not discuss exploits in public" +create "good-first-issue" "7057FF" "Good first claim after triage" +create "area:docs" "0075CA" "Documentation" +create "area:loop" "C5DEF5" "Claim/lease/receipt process" +create "area:verify" "BFDADC" "Tests, mutation, evidence" +create stale "FFFFFF" "Quiet; not a claim block" diff --git a/.github/scripts/expire-claims.sh b/.github/scripts/expire-claims.sh new file mode 100755 index 0000000..78f8555 --- /dev/null +++ b/.github/scripts/expire-claims.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Release expired Verified OSS Loop claim leases. Does not merge. Does not close issues. +# Usage: expire-claims.sh [--dry-run] [--max-age-hours 24] +set -euo pipefail + +DRY=0 +MAX_HOURS=24 +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY=1; shift ;; + --max-age-hours) MAX_HOURS="$2"; shift 2 ;; + -h|--help) + sed -n '2,4p' "$0" | sed 's/^# //' + exit 0 + ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +if ! command -v gh >/dev/null 2>&1; then + echo "gh is required" >&2 + exit 1 +fi + +export VOL_NOW_EPOCH +VOL_NOW_EPOCH="$(date -u +%s)" +export VOL_MAX_AGE_SECS=$((MAX_HOURS * 3600)) + +lease_state() { + # JSON comments array on stdin → two lines: yes|no and reason + python3 - <<'PY' +import datetime, json, os, re, sys +now = int(os.environ["VOL_NOW_EPOCH"]) +max_age = int(os.environ["VOL_MAX_AGE_SECS"]) +comments = json.load(sys.stdin) +iso = re.compile( + r"(?:expires_at|expires)\s*:\s*([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:Z|[+-][0-9]{2}:[0-9]{2})?)", + re.I, +) +claim_at = None +expiry = None +for c in comments: + body = c.get("body") or "" + created = c.get("createdAt") or c.get("created_at") or "" + if re.search(r"(?i)claiming for|claimant:|claimed_at:", body): + try: + claim_at = int(datetime.datetime.fromisoformat(created.replace("Z", "+00:00")).timestamp()) + except Exception: + pass + for m in iso.finditer(body): + raw = m.group(1) + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + try: + expiry = int(datetime.datetime.fromisoformat(raw).timestamp()) + except Exception: + pass +if expiry is not None: + expired = expiry <= now + reason = f"expires_at {expiry} <= now {now}" +elif claim_at is not None: + expired = (now - claim_at) >= max_age + reason = f"claim comment age {now - claim_at}s >= {max_age}s" +else: + expired = False + reason = "no claim timestamp" +print("yes" if expired else "no") +print(reason) +PY +} + +issues="$(gh issue list --label claimed --state open --limit 100 --json number --jq '.[].number')" +if [[ -z "$issues" ]]; then + echo "no claimed issues" + exit 0 +fi + +released=0 +while IFS= read -r num; do + [[ -n "$num" ]] || continue + comments="$(gh api "repos/{owner}/{repo}/issues/${num}/comments" --jq '[.[] | {body, createdAt: .created_at}]')" + result="$(printf '%s' "$comments" | lease_state)" + expired="$(printf '%s\n' "$result" | sed -n '1p')" + reason="$(printf '%s\n' "$result" | sed -n '2p')" + if [[ "$expired" != "yes" ]]; then + echo "keep #$num ($reason)" + continue + fi + echo "expire #$num ($reason)" + if [[ "$DRY" -eq 1 ]]; then + released=$((released + 1)) + continue + fi + gh issue edit "$num" --remove-label claimed --add-label claimable >/dev/null + gh issue comment "$num" --body "Claim lease expired (\`${reason}\`). Returned to \`claimable\`. Workers do not merge. A new claimant may take this issue." + released=$((released + 1)) +done <<<"$issues" + +echo "released $released lease(s)" diff --git a/.github/workflows/automerge-nightly.yml b/.github/workflows/automerge-nightly.yml new file mode 100644 index 0000000..c09d3e9 --- /dev/null +++ b/.github/workflows/automerge-nightly.yml @@ -0,0 +1,31 @@ +name: Automerge nightly + +on: + pull_request: + branches: [nightly] + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + - name: Enable auto-merge into nightly when scheme allows + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + run: | + if python3 scripts/rollout.py allow-automerge nightly; then + gh pr merge "$PR" --merge --auto --delete-branch=false + else + echo "scheme forbids nightly automerge" + fi diff --git a/.github/workflows/automerge-preview.yml b/.github/workflows/automerge-preview.yml new file mode 100644 index 0000000..5b59aa3 --- /dev/null +++ b/.github/workflows/automerge-preview.yml @@ -0,0 +1,31 @@ +name: Automerge preview + +on: + pull_request: + branches: [preview] + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + - name: Merge into preview when scheme allows + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + run: | + if python3 scripts/rollout.py allow-automerge preview; then + gh pr merge "$PR" --merge --delete-branch=false + else + echo "scheme forbids preview automerge" + fi diff --git a/.github/workflows/claim-expiry.yml b/.github/workflows/claim-expiry.yml new file mode 100644 index 0000000..f205960 --- /dev/null +++ b/.github/workflows/claim-expiry.yml @@ -0,0 +1,20 @@ +name: Expire claim leases + +on: + schedule: + - cron: '17 * * * *' + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + expire: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Release expired claims + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash .github/scripts/expire-claims.sh diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..56ce45c --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,18 @@ +name: Auto-label PRs + +on: + pull_request: + types: [opened, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml diff --git a/.github/workflows/promote-preview.yml b/.github/workflows/promote-preview.yml new file mode 100644 index 0000000..25c8c1e --- /dev/null +++ b/.github/workflows/promote-preview.yml @@ -0,0 +1,27 @@ +name: Promote preview to nightly + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Merge preview into nightly when scheme allows + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if ! python3 scripts/rollout.py allow-promote preview-to-nightly; then + echo "scheme forbids preview→nightly promote" + exit 0 + fi + git fetch origin preview nightly + git checkout nightly + git merge --no-ff origin/preview -m "Promote preview into nightly" + git push origin nightly diff --git a/.github/workflows/receipt.yml b/.github/workflows/receipt.yml new file mode 100644 index 0000000..91ab965 --- /dev/null +++ b/.github/workflows/receipt.yml @@ -0,0 +1,22 @@ +name: Evidence receipt + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + receipt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check evidence receipt and exact head + env: + BODY: ${{ github.event.pull_request.body }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + printf '%s' "$BODY" > /tmp/pr-body.md + python3 .github/scripts/check-receipt.py --file /tmp/pr-body.md --head "$HEAD" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..edb3a90 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,35 @@ +# OpenSSF Scorecard — security health, not a merge bot. +# Enable as a required check only if a maintainer wants it. Workers still never merge. +name: OpenSSF Scorecard + +on: + branch_protection_rule: + schedule: + - cron: '30 1 * * 1' + workflow_dispatch: + push: + branches: [main, master] + +permissions: read-all + +jobs: + analysis: + name: Scorecard + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + contents: read + actions: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: ossf/scorecard-action@v2.4.2 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + - uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..4beacf1 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,26 @@ +name: Mark stale issues and PRs + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-issue-stale: 60 + days-before-issue-close: 14 + days-before-pr-stale: 30 + days-before-pr-close: 14 + stale-issue-label: stale + exempt-issue-labels: claimed,pinned,work-in-progress + stale-pr-message: | + This PR has been quiet. Workers do not merge. A maintainer will close or relabel. diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..b90d20e --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,26 @@ +name: Validate + +on: + pull_request: + push: + branches: + - main + - preview + - nightly + - 'cursor/**' + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm test + - run: npm run build diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/.verified-oss-loop/README.md b/.verified-oss-loop/README.md new file mode 100644 index 0000000..966283a --- /dev/null +++ b/.verified-oss-loop/README.md @@ -0,0 +1,19 @@ +# Kit inventory + +This directory is written by `oss-onboard` / `init-oss-repo.sh` from [verified-oss-loop](https://github.com/kvnloo/verified-oss-loop). It is not a second loop. + +`inventory.yml` is the VCS-friendly provenance list: + +- **kit** — copied from the standard. Re-running onboard updates it when you have not edited the file. +- **modified** — started as kit, then changed in this repo. Re-run skips it. `--force` takes the new kit file. Or mark it `local`. +- **local** — added here (or marked local). Never overwritten, including under `--force`. + +New skills in a kit release appear on the next `oss-onboard` because they are missing dest files. + +```bash +python3 .verified-oss-loop/kit-inventory.py show --root . +python3 .verified-oss-loop/kit-inventory.py mark --root . --path skills/YOUR/SKILL.md --source local +python3 .verified-oss-loop/rollout.py show +``` + +Rollout scheme (`rollout.yml`): Arch-style `rolling` by default. `staged` or `stable` for slower repos. See the kit's [docs/rollout.md](https://github.com/kvnloo/verified-oss-loop/blob/main/docs/rollout.md). diff --git a/.verified-oss-loop/inventory.yml b/.verified-oss-loop/inventory.yml new file mode 100644 index 0000000..2744c6a --- /dev/null +++ b/.verified-oss-loop/inventory.yml @@ -0,0 +1,166 @@ +# Provenance for files copied from kvnloo/verified-oss-loop. +# source: kit = synced from the standard; local = developer-owned, never overwrite; +# modified = started as kit, local edits (re-run skips unless --force). +# Re-run oss-onboard to receive new kit skills. Mark a path local with: +# python3 scripts/kit-inventory.py mark --root . --path skills/NAME/SKILL.md --source local +schema: verified-oss-loop.inventory.v1 +kit: https://github.com/kvnloo/verified-oss-loop +kit_revision: 7ba2782090bef0a5fe8a8e039a700d76f23ca274 +synced_at: 2026-09-12T20:11:48Z +files: + - path: .github/CODEOWNERS + source: kit + sha256: 0270c50f0ecd8e2dd39f15715863a7909b9a2fe8e6a5e6492740844d9845f976 + kit_sha256: 0270c50f0ecd8e2dd39f15715863a7909b9a2fe8e6a5e6492740844d9845f976 + - path: .github/ISSUE_TEMPLATE/bug.yml + source: kit + sha256: c3be04fa057ed643073833d4b319dd45566b19fea5c47ed6c73038cd5de678b1 + kit_sha256: c3be04fa057ed643073833d4b319dd45566b19fea5c47ed6c73038cd5de678b1 + - path: .github/ISSUE_TEMPLATE/claim.yml + source: kit + sha256: 7f23ee391f54a05dda7649083a41bb10ac036ee0c2e28f3fb6a8ff788ad59899 + kit_sha256: 7f23ee391f54a05dda7649083a41bb10ac036ee0c2e28f3fb6a8ff788ad59899 + - path: .github/ISSUE_TEMPLATE/config.yml + source: kit + sha256: a22cd8312373f8c349d18e99e5f7895416d316d4f99a2df210366b1b1dbae92e + kit_sha256: a22cd8312373f8c349d18e99e5f7895416d316d4f99a2df210366b1b1dbae92e + - path: .github/ISSUE_TEMPLATE/feature.yml + source: kit + sha256: b54103aa4e35284f910619e5cb6ac22b100b6e2934091ef24a7f229b2534c3cd + kit_sha256: b54103aa4e35284f910619e5cb6ac22b100b6e2934091ef24a7f229b2534c3cd + - path: .github/PULL_REQUEST_TEMPLATE.md + source: modified + sha256: 81cd7758d58489d99a00085c1ea3a73c61620cf00a98b8570b67163dc8deecc8 + kit_sha256: 631bdec70a9b315bdd3d9f919f198716d6ea0ea1cdd021cc8cc15cadfa71e032 + - path: .github/dependabot.yml + source: kit + sha256: c8c84c10f737cdb53d3bb3e155fb9eab1efb4180b621bf0a137765df42e78020 + kit_sha256: c8c84c10f737cdb53d3bb3e155fb9eab1efb4180b621bf0a137765df42e78020 + - path: .github/labeler.yml + source: kit + sha256: 9574879997ebba5aa369389f2bacc007ea22f870e5ff951a4d368514b5758d28 + kit_sha256: 9574879997ebba5aa369389f2bacc007ea22f870e5ff951a4d368514b5758d28 + - path: .github/labels.md + source: kit + sha256: 9e269b8faffe1b137ee6b8819eebec0ed7c2a5740d810c9b76acdc106dcd083f + kit_sha256: 9e269b8faffe1b137ee6b8819eebec0ed7c2a5740d810c9b76acdc106dcd083f + - path: .github/scripts/check-receipt.py + source: kit + sha256: 188313013ff82f355612534a99f51efce0f2c2c5a1566cb5a4b1863f4e4b0063 + kit_sha256: 188313013ff82f355612534a99f51efce0f2c2c5a1566cb5a4b1863f4e4b0063 + - path: .github/scripts/create-labels.sh + source: kit + sha256: 5fcb9a409f80e5d0b68e41c777621ccdaeb1187bfb367b1e1dc614b20701f637 + kit_sha256: 5fcb9a409f80e5d0b68e41c777621ccdaeb1187bfb367b1e1dc614b20701f637 + - path: .github/scripts/expire-claims.sh + source: kit + sha256: f7b19ea5ae7226368a3515db9dd292ab20844fb09618bebb7c92f8abc3d6132d + kit_sha256: f7b19ea5ae7226368a3515db9dd292ab20844fb09618bebb7c92f8abc3d6132d + - path: .github/workflows/automerge-nightly.yml + source: kit + sha256: b1db0e5112b5f16cad13b3683ddf4ea699e398fc1c81221a8c34056e0d6c78b8 + kit_sha256: b1db0e5112b5f16cad13b3683ddf4ea699e398fc1c81221a8c34056e0d6c78b8 + - path: .github/workflows/automerge-preview.yml + source: kit + sha256: 573c5b5abd509eb84384f3ed21f77f76649b3e1c3bf105205c2d799af74242b3 + kit_sha256: 573c5b5abd509eb84384f3ed21f77f76649b3e1c3bf105205c2d799af74242b3 + - path: .github/workflows/claim-expiry.yml + source: kit + sha256: 23f4a98d0dff21e44251ae5804a00afd17d2c6214616f96083e6148347eff6b0 + kit_sha256: 23f4a98d0dff21e44251ae5804a00afd17d2c6214616f96083e6148347eff6b0 + - path: .github/workflows/labeler.yml + source: kit + sha256: 555c50172b8bc51d15fca6d896b4ed2d3420e01ae63af39013f95d7fcb99598b + kit_sha256: 555c50172b8bc51d15fca6d896b4ed2d3420e01ae63af39013f95d7fcb99598b + - path: .github/workflows/promote-preview.yml + source: kit + sha256: 0d27a95441c4f8483b4ffe6773d6323d40083615da77886536ab25b86d39da6d + kit_sha256: 0d27a95441c4f8483b4ffe6773d6323d40083615da77886536ab25b86d39da6d + - path: .github/workflows/receipt.yml + source: kit + sha256: 3b06f0f0928b6149beb187de8588280c637d2ed9167c2d4ad2c3457feb0af91f + kit_sha256: 3b06f0f0928b6149beb187de8588280c637d2ed9167c2d4ad2c3457feb0af91f + - path: .github/workflows/scorecard.yml + source: kit + sha256: e10cb2709765431fd549796883d441eac5425f4be9cfe54dd97ce635f02d0b43 + kit_sha256: e10cb2709765431fd549796883d441eac5425f4be9cfe54dd97ce635f02d0b43 + - path: .github/workflows/stale.yml + source: kit + sha256: 1c8b97c73c6dc87a541201e185dfd352fa0f8f738d29ba5981017124a451e798 + kit_sha256: 1c8b97c73c6dc87a541201e185dfd352fa0f8f738d29ba5981017124a451e798 + - path: .verified-oss-loop/README.md + source: kit + sha256: 35129e006379d553c6688159251ed77582e339c78b4dfe9e1fcaf020c0c8deef + kit_sha256: 35129e006379d553c6688159251ed77582e339c78b4dfe9e1fcaf020c0c8deef + - path: .verified-oss-loop/kit-inventory.py + source: kit + sha256: b393a6f25c5f86ddf9bdcb2ec0cd3ba4d8edc544dbe0b4029a5c2fd48d235cf6 + kit_sha256: b393a6f25c5f86ddf9bdcb2ec0cd3ba4d8edc544dbe0b4029a5c2fd48d235cf6 + - path: .verified-oss-loop/rollout.py + source: kit + sha256: 0f49ebbfb241fee00c21f2dbf587cb4c1807354a30a9c879ec0611b04ed83654 + kit_sha256: 0f49ebbfb241fee00c21f2dbf587cb4c1807354a30a9c879ec0611b04ed83654 + - path: .verified-oss-loop/rollout.yml + source: kit + sha256: 530e8b60c217e6e3db42b0e6799c30f7319a6c6b0c82874eb8f9cae6d18c1896 + kit_sha256: 530e8b60c217e6e3db42b0e6799c30f7319a6c6b0c82874eb8f9cae6d18c1896 + - path: AGENTS.md + source: local + sha256: 26a787d368b311f403e960f5762e318f46f252e01ca7e4276a40328623e2b4b7 + kit_sha256: 796c6248693433fb4d6ef6e4b8be675977bee0590097de8f73076b7c32b37263 + - path: CONTRIBUTING.md + source: kit + sha256: df73ebef9e33ca20433a1fc8ca8c40b3068c370df11cbc48a8c4caaa2eec03c7 + kit_sha256: df73ebef9e33ca20433a1fc8ca8c40b3068c370df11cbc48a8c4caaa2eec03c7 + - path: SECURITY.md + source: modified + sha256: f425442e0bb7efdb07b741eaa8e0997e9a8b03ce48f345a3f9e34c61745470c9 + kit_sha256: 3e7cb8117c023b4772f502f902c19a38921cc0901f4addf1b7a4c5c031fea04e + - path: prompt.md + source: kit + sha256: e68a9f288f9e702ab75e9b58fd8d6defda8f35299c28bc9b839ea8712f0f8bc6 + kit_sha256: e68a9f288f9e702ab75e9b58fd8d6defda8f35299c28bc9b839ea8712f0f8bc6 + - path: roadmap.example.yml + source: kit + sha256: 221df8f58a65aac116426397ae3957288279923ae18e622861b1dfbe1ccb9c53 + kit_sha256: 221df8f58a65aac116426397ae3957288279923ae18e622861b1dfbe1ccb9c53 + - path: scripts/cluster-similar-issues.py + source: kit + sha256: 35c21c0b5356bc319237101a3c4dc0ad6e3255024abf448e943cd780cb254ef2 + kit_sha256: 35c21c0b5356bc319237101a3c4dc0ad6e3255024abf448e943cd780cb254ef2 + - path: scripts/ensure-rollout-branches.sh + source: kit + sha256: ab0780c9f13d232827d3687727db823a2bec26a82a6aab420b8d0742fc4f2345 + kit_sha256: ab0780c9f13d232827d3687727db823a2bec26a82a6aab420b8d0742fc4f2345 + - path: scripts/rollout.py + source: kit + sha256: 0f49ebbfb241fee00c21f2dbf587cb4c1807354a30a9c879ec0611b04ed83654 + kit_sha256: 0f49ebbfb241fee00c21f2dbf587cb4c1807354a30a9c879ec0611b04ed83654 + - path: skills/anti-slop/SKILL.md + source: kit + sha256: 7151d5eea1dfd42f5cee0a37f0c03c5e151ab24a5d3f0fdaec94a65286facb76 + kit_sha256: 7151d5eea1dfd42f5cee0a37f0c03c5e151ab24a5d3f0fdaec94a65286facb76 + - path: skills/autodevelop/SKILL.md + source: kit + sha256: 1f421dd35fdaeaaa2c05472d1b8eec9d94592fb67092c4bbcf4eac0f25e88dce + kit_sha256: 1f421dd35fdaeaaa2c05472d1b8eec9d94592fb67092c4bbcf4eac0f25e88dce + - path: skills/dr-eggbot/SKILL.md + source: kit + sha256: 2d0aa3c4df219e25cbd89d6bacf8d0d2a01d5aeeaaeed963e6f3c9414b6855f5 + kit_sha256: 2d0aa3c4df219e25cbd89d6bacf8d0d2a01d5aeeaaeed963e6f3c9414b6855f5 + - path: skills/orient/SKILL.md + source: kit + sha256: 2f41f2ad4b411aa9e7fd9db704018838cd1e15eaca92eb45d5923886d1da6f2f + kit_sha256: 2f41f2ad4b411aa9e7fd9db704018838cd1e15eaca92eb45d5923886d1da6f2f + - path: skills/pstack/SKILL.md + source: kit + sha256: 8ba54e9382e1003d50a9851c79a1416f78da45babc84297a94841e2ff3c518c1 + kit_sha256: 8ba54e9382e1003d50a9851c79a1416f78da45babc84297a94841e2ff3c518c1 + - path: skills/tdd/SKILL.md + source: kit + sha256: 9f4ae8e66295ead9fd667b456eb886e1fa00c37cc2e792f189257b8b84661656 + kit_sha256: 9f4ae8e66295ead9fd667b456eb886e1fa00c37cc2e792f189257b8b84661656 + - path: skills/verify/SKILL.md + source: kit + sha256: 2fa5110bdc6b4e5254786e4e9058b7d9524618d02481da4d1c38481c1ffac7e0 + kit_sha256: 2fa5110bdc6b4e5254786e4e9058b7d9524618d02481da4d1c38481c1ffac7e0 diff --git a/.verified-oss-loop/kit-inventory.py b/.verified-oss-loop/kit-inventory.py new file mode 100644 index 0000000..88d8140 --- /dev/null +++ b/.verified-oss-loop/kit-inventory.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python3 +"""Provenance inventory for files copied from verified-oss-loop. + +Kit-owned paths update on re-run when unmodified. source: local is never +overwritten, including under --force. Stdlib only. Does not merge. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import stat +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +SCHEMA = "verified-oss-loop.inventory.v1" +INV_DIR = ".verified-oss-loop" +INV_NAME = "inventory.yml" +DEFAULT_KIT = "https://github.com/kvnloo/verified-oss-loop" +SKILL_ROOTS = ("skills", ".agents/skills", ".claude/skills") +SOURCES = ("kit", "local", "modified") + + +def sha256_file(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def now_rfc3339() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def inventory_path(root: Path) -> Path: + return root / INV_DIR / INV_NAME + + +def posix(path: str) -> str: + p = path.replace("\\", "/").strip() + while p.startswith("./"): + p = p[2:] + return p + + +def load_inventory(root: Path) -> dict[str, Any]: + path = inventory_path(root) + data: dict[str, Any] = { + "schema": SCHEMA, + "kit": DEFAULT_KIT, + "kit_revision": "", + "synced_at": "", + "files": {}, + } + if not path.is_file(): + return data + current: dict[str, str] | None = None + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0].rstrip() + if not line.strip(): + continue + dashed = line.strip() + if dashed.startswith("- path:"): + rel = posix(dashed.split(":", 1)[1].strip()) + current = {"path": rel, "source": "kit", "sha256": "", "kit_sha256": ""} + data["files"][rel] = current + continue + if ":" not in line: + continue + key, val = line.split(":", 1) + key, val = key.strip(), val.strip() + if current is not None and key in ("source", "sha256", "kit_sha256"): + current[key] = val + elif current is None and key in ("schema", "kit", "kit_revision", "synced_at"): + data[key] = val + return data + + +def emit_inventory(data: dict[str, Any]) -> str: + lines = [ + "# Provenance for files copied from kvnloo/verified-oss-loop.", + "# source: kit = synced from the standard; local = developer-owned, never overwrite;", + "# modified = started as kit, local edits (re-run skips unless --force).", + "# Re-run oss-onboard to receive new kit skills. Mark a path local with:", + "# python3 scripts/kit-inventory.py mark --root . --path skills/NAME/SKILL.md --source local", + f"schema: {data.get('schema', SCHEMA)}", + f"kit: {data.get('kit') or DEFAULT_KIT}", + f"kit_revision: {data.get('kit_revision') or ''}", + f"synced_at: {data.get('synced_at') or ''}", + "files:", + ] + files = data.get("files") or {} + for rel in sorted(files): + entry = files[rel] + lines.append(f" - path: {rel}") + lines.append(f" source: {entry.get('source') or 'kit'}") + if entry.get("sha256"): + lines.append(f" sha256: {entry['sha256']}") + if entry.get("kit_sha256"): + lines.append(f" kit_sha256: {entry['kit_sha256']}") + lines.append("") + return "\n".join(lines) + + +def save_inventory(root: Path, data: dict[str, Any]) -> Path: + dest = inventory_path(root) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(emit_inventory(data), encoding="utf-8") + return dest + + +def append_session(session: Path, rec: dict[str, str]) -> None: + session.parent.mkdir(parents=True, exist_ok=True) + with session.open("a", encoding="utf-8") as f: + f.write(json.dumps(rec, sort_keys=True) + "\n") + + +def decide(root: Path, rel: str, incoming: Path, dest: Path, force: bool) -> tuple[bool, str, str]: + """Return (write?, action, reason). Never writes source=local.""" + rel = posix(rel) + inv = load_inventory(root) + entry = (inv.get("files") or {}).get(rel) + source = (entry or {}).get("source") or "" + + if source == "local": + return False, "skip", "local" + + if not dest.exists(): + return True, "write", "new" + + incoming_hash = sha256_file(incoming) + dest_hash = sha256_file(dest) + if dest_hash == incoming_hash: + return False, "unchanged", "already current" + + if force: + return True, "force", "force" + + if not entry: + # First inventory: existing kit-path content may be an older template + # or a local edit. Do not clobber; next run can --force or mark kit. + return False, "skip", "modified" + + recorded = entry.get("sha256") or "" + if source == "kit" and recorded and dest_hash == recorded: + return True, "update", "kit file unmodified since last sync" + + if source == "modified" and dest_hash == incoming_hash: + return True, "update", "matches new kit; reclaim" + + return False, "skip", "modified" + + +def cmd_apply(args: argparse.Namespace) -> int: + root = Path(args.root).resolve() + rel = posix(args.path) + incoming = Path(args.incoming) + dest = root / rel + if not incoming.is_file(): + print(f"incoming missing: {incoming}", file=sys.stderr) + return 2 + force = bool(args.force) + write, action, reason = decide(root, rel, incoming, dest, force) + dest.parent.mkdir(parents=True, exist_ok=True) + incoming_hash = sha256_file(incoming) + if write: + shutil.copyfile(incoming, dest) + if args.chmod: + mode = int(args.chmod, 8) + os.chmod(dest, stat.S_IMODE(dest.stat().st_mode) | mode) + print(f"{action}: {rel}") + else: + print(f"skip {rel} ({reason})") + if args.session: + append_session( + Path(args.session), + { + "path": rel, + "action": action, + "reason": reason, + "incoming_sha256": incoming_hash, + }, + ) + return 0 + + +def iter_extra_skills(root: Path, kit_paths: set[str]) -> list[Path]: + found: list[Path] = [] + for rel_root in SKILL_ROOTS: + base = root / rel_root + if not base.is_dir(): + continue + for p in base.rglob("SKILL.md"): + rel = p.relative_to(root).as_posix() + if rel not in kit_paths: + found.append(p) + return found + + +def cmd_finalize(args: argparse.Namespace) -> int: + root = Path(args.root).resolve() + old = load_inventory(root) + files: dict[str, dict[str, str]] = dict(old.get("files") or {}) + session_rows: list[dict[str, str]] = [] + if args.session and Path(args.session).is_file(): + for line in Path(args.session).read_text(encoding="utf-8").splitlines(): + if line.strip(): + session_rows.append(json.loads(line)) + + kit_paths: set[str] = set() + if args.kit_paths: + for line in Path(args.kit_paths).read_text(encoding="utf-8").splitlines(): + line = posix(line.strip()) + if line: + kit_paths.add(line) + for row in session_rows: + kit_paths.add(posix(row["path"])) + + by_path = {posix(r["path"]): r for r in session_rows} + + for rel in sorted(kit_paths): + dest = root / rel + prev = files.get(rel) or {} + row = by_path.get(rel) + action = (row or {}).get("action") or "" + reason = (row or {}).get("reason") or "" + incoming_hash = (row or {}).get("incoming_sha256") or "" + + if prev.get("source") == "local" or reason == "local" or action == "skip" and reason == "local": + entry = {"path": rel, "source": "local", "sha256": "", "kit_sha256": prev.get("kit_sha256") or ""} + if dest.is_file(): + entry["sha256"] = sha256_file(dest) + files[rel] = entry + continue + + if not dest.is_file(): + files.pop(rel, None) + continue + + dest_hash = sha256_file(dest) + if action in ("write", "update", "force", "unchanged"): + files[rel] = { + "path": rel, + "source": "kit", + "sha256": dest_hash, + "kit_sha256": incoming_hash or dest_hash, + } + elif reason == "modified" or action == "skip": + files[rel] = { + "path": rel, + "source": "modified", + "sha256": dest_hash, + "kit_sha256": incoming_hash or prev.get("kit_sha256") or "", + } + else: + files[rel] = { + "path": rel, + "source": prev.get("source") or "kit", + "sha256": dest_hash, + "kit_sha256": incoming_hash or prev.get("kit_sha256") or dest_hash, + } + + for extra in iter_extra_skills(root, kit_paths): + rel = extra.relative_to(root).as_posix() + prev = files.get(rel) or {} + files[rel] = { + "path": rel, + "source": "local", + "sha256": sha256_file(extra), + "kit_sha256": prev.get("kit_sha256") or "", + } + + # Keep previously recorded local files that are not skills (rare). + for rel, entry in list(files.items()): + if rel in kit_paths: + continue + if entry.get("source") != "local": + continue + dest = root / rel + if dest.is_file(): + entry["sha256"] = sha256_file(dest) + files[rel] = entry + else: + files.pop(rel, None) + + data = { + "schema": SCHEMA, + "kit": args.kit_url or old.get("kit") or DEFAULT_KIT, + "kit_revision": args.kit_revision or old.get("kit_revision") or "", + "synced_at": now_rfc3339(), + "files": files, + } + dest = save_inventory(root, data) + print(f"wrote {dest.relative_to(root).as_posix()}") + return 0 + + +def cmd_show(args: argparse.Namespace) -> int: + root = Path(args.root).resolve() + inv = load_inventory(root) + if not inventory_path(root).is_file(): + print("no inventory; run oss-onboard to create .verified-oss-loop/inventory.yml") + return 1 + grouped: dict[str, list[str]] = {"kit": [], "modified": [], "local": []} + for rel, entry in (inv.get("files") or {}).items(): + src = entry.get("source") or "kit" + grouped.setdefault(src, []).append(rel) + if args.json: + print(json.dumps({"kit": inv.get("kit"), "kit_revision": inv.get("kit_revision"), "synced_at": inv.get("synced_at"), "files": inv.get("files")}, indent=2)) + return 0 + print(f"kit: {inv.get('kit')} @ {inv.get('kit_revision') or '?'}") + print(f"synced_at: {inv.get('synced_at')}") + for src in ("kit", "modified", "local"): + paths = grouped.get(src) or [] + print(f"\n{src}:") + if not paths: + print(" (none)") + continue + for p in sorted(paths): + print(f" {p}") + return 0 + + +def cmd_source(args: argparse.Namespace) -> int: + root = Path(args.root).resolve() + rel = posix(args.path) + entry = (load_inventory(root).get("files") or {}).get(rel) + if not entry: + print("missing") + return 0 + print(entry.get("source") or "missing") + return 0 + + +def cmd_mark(args: argparse.Namespace) -> int: + root = Path(args.root).resolve() + rel = posix(args.path) + source = args.source + if source not in SOURCES: + print(f"source must be one of {SOURCES}", file=sys.stderr) + return 2 + dest = root / rel + if not dest.is_file(): + print(f"missing file: {rel}", file=sys.stderr) + return 1 + inv = load_inventory(root) + files = inv.setdefault("files", {}) + prev = files.get(rel) or {} + files[rel] = { + "path": rel, + "source": source, + "sha256": sha256_file(dest), + "kit_sha256": prev.get("kit_sha256") or "", + } + inv["synced_at"] = inv.get("synced_at") or now_rfc3339() + save_inventory(root, inv) + print(f"marked {rel} source={source}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Verified OSS Loop kit inventory (provenance + sync)") + sub = p.add_subparsers(dest="cmd", required=True) + + ap = sub.add_parser("apply", help="copy incoming to dest if inventory allows") + ap.add_argument("--root", required=True) + ap.add_argument("--path", required=True, help="path relative to root") + ap.add_argument("--incoming", required=True) + ap.add_argument("--session", help="JSONL session file") + ap.add_argument("--force", action="store_true") + ap.add_argument("--chmod", help="octal bits to OR onto mode, e.g. 755") + ap.set_defaults(func=cmd_apply) + + fin = sub.add_parser("finalize", help="write inventory.yml after a sync") + fin.add_argument("--root", required=True) + fin.add_argument("--session") + fin.add_argument("--kit-paths", help="newline list of kit-relative paths attempted this run") + fin.add_argument("--kit-revision", default="") + fin.add_argument("--kit-url", default=DEFAULT_KIT) + fin.set_defaults(func=cmd_finalize) + + sh = sub.add_parser("show", help="print inventory grouped by source") + sh.add_argument("--root", required=True) + sh.add_argument("--json", action="store_true") + sh.set_defaults(func=cmd_show) + + src = sub.add_parser("source", help="print source for one path") + src.add_argument("--root", required=True) + src.add_argument("--path", required=True) + src.set_defaults(func=cmd_source) + + mk = sub.add_parser("mark", help="set source=local|kit|modified for a path") + mk.add_argument("--root", required=True) + mk.add_argument("--path", required=True) + mk.add_argument("--source", required=True, choices=SOURCES) + mk.set_defaults(func=cmd_mark) + + args = p.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.verified-oss-loop/rollout.py b/.verified-oss-loop/rollout.py new file mode 100644 index 0000000..b8a76b8 --- /dev/null +++ b/.verified-oss-loop/rollout.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Read .verified-oss-loop/rollout.yml. Stdlib only. Does not merge.""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +SCHEMES = ("rolling", "staged", "stable") + + +def load_scheme(root: Path) -> str: + path = root / ".verified-oss-loop" / "rollout.yml" + scheme = "rolling" + if path.is_file(): + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0].strip() + if line.startswith("scheme:"): + scheme = line.split(":", 1)[1].strip().strip("'\"") + break + if scheme not in SCHEMES: + raise SystemExit(f"unknown rollout scheme: {scheme}") + return scheme + + +def policy(scheme: str) -> dict[str, str | bool]: + if scheme == "stable": + return { + "scheme": scheme, + "worker_base": "main", + "feature_target": "main", + "overnight_target": "main", + "automerge_preview": False, + "automerge_nightly": False, + "promote_preview_to_nightly": False, + } + if scheme == "staged": + return { + "scheme": scheme, + "worker_base": "nightly", + "feature_target": "preview", + "overnight_target": "nightly", + "automerge_preview": True, + "automerge_nightly": False, + "promote_preview_to_nightly": True, + } + return { + "scheme": scheme, + "worker_base": "nightly", + "feature_target": "preview", + "overnight_target": "nightly", + "automerge_preview": True, + "automerge_nightly": True, + "promote_preview_to_nightly": True, + } + + +def cmd_show(root: Path) -> int: + p = policy(load_scheme(root)) + for k in ( + "scheme", + "worker_base", + "feature_target", + "overnight_target", + "automerge_preview", + "automerge_nightly", + "promote_preview_to_nightly", + ): + print(f"{k}={p[k]}".lower() if isinstance(p[k], bool) else f"{k}={p[k]}") + return 0 + + +def cmd_get(root: Path, key: str) -> int: + p = policy(load_scheme(root)) + if key not in p: + print(f"unknown key: {key}", file=sys.stderr) + return 2 + val = p[key] + print(str(val).lower() if isinstance(val, bool) else val) + return 0 + + +def cmd_allow(root: Path, channel: str) -> int: + if channel not in ("preview", "nightly"): + return 1 + p = policy(load_scheme(root)) + ok = bool(p[f"automerge_{channel}"]) + return 0 if ok else 1 + + +def cmd_allow_promote(root: Path, what: str) -> int: + if what != "preview-to-nightly": + return 1 + p = policy(load_scheme(root)) + return 0 if p["promote_preview_to_nightly"] else 1 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Verified OSS Loop rollout scheme") + p.add_argument("--root", default=".") + sub = p.add_subparsers(dest="cmd", required=True) + sub.add_parser("show") + g = sub.add_parser("get") + g.add_argument("key") + a = sub.add_parser("allow-automerge") + a.add_argument("channel", choices=("preview", "nightly")) + pr = sub.add_parser("allow-promote") + pr.add_argument("what", choices=("preview-to-nightly",)) + args = p.parse_args(argv) + root = Path(args.root).resolve() + if args.cmd == "show": + return cmd_show(root) + if args.cmd == "get": + return cmd_get(root, args.key) + if args.cmd == "allow-promote": + return cmd_allow_promote(root, args.what) + return cmd_allow(root, args.channel) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.verified-oss-loop/rollout.yml b/.verified-oss-loop/rollout.yml new file mode 100644 index 0000000..ca5bc35 --- /dev/null +++ b/.verified-oss-loop/rollout.yml @@ -0,0 +1,5 @@ +# Rollout scheme — Arch-style rolling by default. +# rolling | staged | stable +# See docs/rollout.md in kvnloo/verified-oss-loop. Not a second contribution loop. +schema: verified-oss-loop.rollout.v1 +scheme: rolling diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2d32600 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,96 @@ +# Notes for agents — ace + +You are a contributor, not a maintainer. Workers open PRs. They never merge `main` or `dev`. + +This project follows the [Verified OSS Loop](https://github.com/kvnloo/verified-oss-loop). Issues are not claims. AI work is untrusted until proven. + +## First 60 seconds + +1. Read this file, then `CONTRIBUTING.md`. +2. `git fetch origin`. `python3 .verified-oss-loop/rollout.py show`. Branch from `origin/$(python3 .verified-oss-loop/rollout.py get worker_base)` unless the issue names another base. Day-pass PRs target `feature_target`. Overnight unattended PRs target `overnight_target`. See `docs/rollout.md` in the kit (or `.verified-oss-loop/rollout.yml` here). +3. Search open issues and PRs. Do not duplicate in-flight work. +4. Orient (`skills/orient/SKILL.md`). If GitNexus MCP is already there: `query` → `context` → `impact`. Do not run `gitnexus analyze` unless a human asked. Else Serena symbols, else `rg` + read. + +```bash +gh issue list --label claimable --state open +gh pr list --state open +``` + +## Pick and claim + +Take **one** open issue labeled `claimable` and not `claimed`. Prefer `priority:P0`, then `P1`, then `good-first-issue`. Skip `needs-discussion` unless a human assigned it. + +If nothing is `claimable`: do not code. **Triage** — if a `needs-discussion` issue exists: one-paragraph proposal on the newest; stop. If none: mint **exactly one** issue from the first untracked item in `ROADMAP.md`, else a failing unit command from `AGENTS.md`, else docs drift; label **`needs-discussion` only**; stop. Do not self-apply `claimable`. Do not rewrite `ROADMAP.md`. **Stop** if triage found nothing untracked, a live claim exists, a competing PR covers the scope, or secrets are required. Comment the blocker only if an issue thread exists. Do not open a consolation PR. + +Claim comment (24h lease unless the project says otherwise): + +```text +claiming for autodevelop +claimant: +base: +expires: +scope: +``` + +Then add `claimed` and remove `claimable`. If a claim newer than 24h exists, pick a different issue. + +## Proof + +Commands were filled by `init-oss-repo.sh` / `oss-onboard` from the tree it saw, then ACE-ized. Do not invent a mutation score. Stryker is not adopted. + +| Layer | Command | +|---|---| +| Unit | `npm test` (`check:facility` + `check:landing`) | +| Mutation | `n/a` — Stryker not adopted; do not invent a score | +| Runtime | `npm run build` | + +1. Name the intended vs current behavior. +2. Fail, then pass (see `skills/tdd/SKILL.md`). +3. Keep the smallest complete change (`skills/anti-slop/SKILL.md`). +4. Run unit tests on the touched surface. +5. If mutation is not `n/a`, run it on the contract you changed. A surviving mutant is a missing assertion. +6. Open a PR at `feature_target` (or `overnight_target` if unattended overnight). Fill `.github/PULL_REQUEST_TEMPLATE.md`. Never merge `main` or `dev`. Do not merge preview/nightly yourself; automerge may, when `rollout.yml` allows. +7. If the project runs an independent review bot (Greptile, CodeRabbit, Bugbot, Copilot, …), treat its comments as review, not merge. Fix real findings. Do not wait for a bot to approve itself. + +**This branch:** honesty PR #7 still targets `main` because it predates rolling `preview`/`nightly`. After the maintainer runs `scripts/ensure-rollout-branches.sh --push`, new worker PRs target `preview`. + +## ACE ownership + +ACE Pages is the **public pretotype landing for ATLAS** (soil to cell: GrowTwin CEA + racquet Facility OS). The live GPU loop stays in the private racquet twin (`kvnloo/homebase-pickleball`). Photoreal farm twin is UE5 elsewhere. This tree must not start a second live GPU loop, bundle that engine, or publish private facility floor plans. + +A nested **peak-performance campus pretotype** remains on the Map view: Pascal Site → LawnTech (Naperville racquet **SPEC**) + APEX wing (**VISION** program from `origin/enhance/3D`). Mine the **program**. Do not resurrect the Three.js lab theater, debug FPS HUD, OpenTwins-as-wired, 88% completion badges, or `4 × 500 = 2,000 m²` as origin fact. + +The Naperville racquet building is the origin **SPEC** nested inside that vision: + +- 24 tennis / 16 badminton / 4 squash / 16 table tennis / 8 pickleball / 1 real tennis +- Grass lab: 500 m² per section; **section count unspecified** +- Envelope 140×120 m and 10 m storeys are **inferred** + +APEX rooms in Pascal (`building_apex`) are **VISION** named zones with inferred 30×30 m cells. Gym, pool, clubhouse, and physio are program identity, not origin measurements. + +### This repo runs + +- Pascal Viewer (`@pascal-app/core` / `viewer` / `nodes` 1.0.0) plus a CSS court/campus fallback +- Public ATLAS landing copy (`landing/public.ts`) — pretotype, not the live twin +- `facility/generateScene.ts` compiles Site → LawnTech (SPEC) + APEX wing (VISION) +- `npm test` → `facility/check-scene.ts` + `landing/check-copy.ts` + +### Do not (ACE-specific) + +- Copy HomeForge / zerOS house, `NOW.yaml`, `:4210` bus, claim protocol, HYBRID, FBP, Keel, devices, or drones into this tree. Pascal embed + `plan2pascal` node mapping are the donor; ACE compiles four sport-hall floors by hand because plan2pascal v0 is single-level. +- Start ACE as a second live GPU loop. Homebase (`kvnloo/homebase-pickleball`) owns the live GPU path on host 0. +- Treat OpenTwins / Eclipse Ditto / Hono / Jenkins / Unity-in-Docker as wired dependencies. They are origin research language. +- Invent `/preview//` Pages slots. Current deploy is `main` → `/ace/` and `dev` → `/ace/dev/` only. Rolling git branches are not Pages. +- Commit Sunshine credentials, API tokens, or `.env`. +- Mutate Linear until a human YES on the relevant issue. + +## Do not + +- Commit secrets, tokens, `.env`, or pairing files. +- Merge `main` or `dev`. +- Redefine the roadmap. +- Claim mutation coverage that the stack cannot run. +- Overwrite `LICENSE`. +- Duplicate `AGENTS.md` into `CLAUDE.md` / `GEMINI.md` / copilot-instructions. +- Run `gitnexus analyze` as a side effect of a claim. +- Dump the pstack plugin or Dr Eggbot marketplace pack into this tree. Pointers: `skills/pstack/SKILL.md`, `skills/dr-eggbot/SKILL.md`. diff --git a/App.tsx b/App.tsx index b7c3152..d233b5c 100644 --- a/App.tsx +++ b/App.tsx @@ -1,25 +1,13 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, Suspense } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { View, FeatureData } from './types'; import NavBar from './components/NavBar'; -import ThreeScene from './components/ThreeScene'; +const PascalFacility = React.lazy(() => import('./components/PascalFacility')); import AIChat from './components/AIChat'; import Specifications from './components/Specifications'; -import { - Zap, - Activity, - Camera, - Cpu, - Sprout, - Users, - ArrowRight, - PlayCircle, - Layers, - Wind, - ShieldCheck, - ShoppingBag -} from 'lucide-react'; +import AtlasLanding, { AtlasProduct } from './components/AtlasLanding'; +import { campusNested, product } from './landing/public.ts'; const App: React.FC = () => { const [currentView, setCurrentView] = useState(View.HOME); @@ -55,84 +43,7 @@ const App: React.FC = () => { variants={pageVariants} className="h-full overflow-y-auto custom-scrollbar pb-20" > - {/* Hero Section */} -
-
-
- -
- - - The Future of Tennis is Organic & Autonomous - - - - GRASS.
- AUTONOMOUS.
- PERFECTION. -
- - - Experience the world's first fully autonomous indoor grass court facility. - Replaceable modular turf, AI coaching, and injury prevention technology. - - - - - - -
-
- - {/* Statistics Teaser */} -
-
- -

Computer Vision

-

Real-time biomechanics analysis and injury prediction models running 60x per second.

-
-
- -

Modular Grass

-

Our on-site Grass Lab grows replacement grids. We swap worn turf in under 60 minutes.

-
-
- -

Performance

-

Strobe glass training and smart ball machines designed to break your reaction time plateaus.

-
-
+ )} @@ -161,16 +72,18 @@ const App: React.FC = () => { className="w-full h-full relative bg-gradient-to-b from-slate-900 to-black" >
- + }> + +
{/* HUD Layer */}
-

Facility Interactive Map

+

{campusNested.title}

- 24 Courts • Vertical Farm • Performance Gym
- Rotate the view to explore the entire complex. + {campusNested.body}
+ HUD is MOCK. CSS sketch if WebGL is blank. Not the {product.name} live twin.

@@ -219,128 +132,7 @@ const App: React.FC = () => { variants={pageVariants} className="h-full overflow-y-auto custom-scrollbar pb-20 px-6" > -
-

Facility Amenities

- -
- - {/* 1. Vertical Grass Lab */} -
-
-
- -
-

Level 3: The Vertical Grass Lab

-

- Our facility houses a 2,000 m² autonomous vertical farm on the top floor, divided into four specialized sectors. - We use advanced hydroponics and climate control to cultivate modular grass grids. - When a court wears down, robots transport and swap the turf in under 60 minutes. -

-
    -
  • Automated Hydroponics & Climate Control
  • -
  • Robotic Patch Transport System
  • -
  • 100% Sustainable Organic Surfaces
  • -
-
-
- {/* Updated Image to Vertical Farm Look */} - Vertical Farming with Autonomous Robots -
- STATUS: CULTIVATING -
-
-
- - {/* 2. The Racquet Ecosystem */} -
-
-
- -
-

Multi-Sport Ecosystem

-

- Spanning the Ground, 1st, and 2nd floors, we offer a comprehensive racquet experience. - From the high-speed action of Badminton and Table Tennis on the Mezzanine to the social atmosphere of Pickleball and the historic elegance of Real Tennis on the upper deck. -

-
-
- Ground Floor - 24 Tennis Courts (Hard, Clay, Grass, Wood) -
-
- First Floor - 16 Badminton, 4 Squash, 16 Table Tennis -
-
- Second Floor - 8 Pickleball Courts, 1 Real Tennis Court -
-
-
-
- Tennis Court with Real-time Biomechanics HUD -
-
- - {/* 3. Autonomous Operations */} -
-
-
- -
-

Autonomous Operations

-

- The facility runs itself. A centralized Building Management System (BMS) optimizes HVAC and lighting using solar power. - Robotic mowers maintain the courts while overhead drones constantly analyze surface quality. - Entry is seamless with biometric scanning, removing the need for keys or cards. -

-
-
- -
-

Smart HVAC

-

AI Climate Control

-
-
-
- -
-

Biometric Entry

-

Secure & Seamless

-
-
-
-
-
- Autonomous Tech -
-
- - {/* 4. Member Experience */} -
-
-
- -
-

Member Experience

-

- We prioritize comfort and recovery. Our Pro Shop is stocked with the latest gear. - Locker rooms and waiting areas are managed by smart sensors to ensure cleanliness and supply availability. - Automated emergency systems monitor the entire facility to instantly alert services if an incident occurs. -

-
    -
  • Smart Bathroom Sanitation Monitoring
  • -
  • Automated Emergency Response
  • -
  • Mobile App Booking & Payments
  • -
-
-
- Locker Room -
-
- -
-
+ )} @@ -356,8 +148,8 @@ const App: React.FC = () => { >
-

Join the Revolution

-

We are raising Series A funding to build the pilot facility in Austin, Texas.

+

Join {product.name}

+

Public pretotype landing for ATLAS — soil to cell. This form does not submit (MOCK).

e.preventDefault()}> diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8f04808 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing + +Humans and coding agents work on this repository together. This file is how to **use** the project and how to **contribute**. Project policy in `AGENTS.md` and `SECURITY.md` wins if anything conflicts. + +## Use + +See the README for install, run, and configuration. Do not commit secrets. + +## Contribute + +### Autodevelop (agents) + +If you were told to autodevelop, donate a coding pass, or pick the next issue: paste `prompt.md` (it links to this repo) and follow `AGENTS.md` and `skills/autodevelop/SKILL.md`. Do not invent a parallel process. + +Workers: + +1. Claim one `claimable` issue (24h lease). +2. `python3 .verified-oss-loop/rollout.py show`. Work on a branch from `origin/$(python3 .verified-oss-loop/rollout.py get worker_base)`. Day-pass PRs target `feature_target`. +3. Orient (`skills/orient/SKILL.md`), then fail, then pass. +4. Keep the smallest complete change (`skills/anti-slop/SKILL.md`). +5. Open a PR with an evidence receipt. +6. **Never merge `main` or `dev`.** + +### Humans + +Same loop, without the claim bot if you are a maintainer. You still do not need a second process. + +## Evidence + +Every PR fills `.github/PULL_REQUEST_TEMPLATE.md`: + +- issue number +- base SHA and head SHA +- red command (failing repro) and green command +- mutation command and score, or `n/a` +- contribution mode: unattended (cloud agent) or copilot (human-supervised) + +Tests from another head are not evidence. `--with-automation` adds a CI check that the YAML keys exist and `head_revision` matches the PR SHA. + +Independent review bots the project already installed (Greptile, CodeRabbit, Bugbot, Copilot, Codecov, CodSpeed) are reviewers and evidence, not merge authority. + +## Prior art + +Contribution contract: [kvnloo/verified-oss-loop](https://github.com/kvnloo/verified-oss-loop). Agent file convention: [agents.md](https://agents.md/). diff --git a/README.md b/README.md index 32f105a..65f9dda 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,46 @@ -# LawnTech Dynamics - Autonomous Indoor Grass Court Facility +# ATLAS — public pretotype landing
-**The world's first fully autonomous indoor grass court tennis facility** +**ATLAS is soil to cell: specify a stack, simulate it, bind a twin. Under an hour a week.** -[View Live Demo](https://kvnloo.github.io/ace/) • [Dev Preview](https://kvnloo.github.io/ace/dev/) • [Documentation](.github/DEPLOYMENT.md) +[Live Pages (`main`)](https://kvnloo.github.io/ace/) • [Dev Preview](https://kvnloo.github.io/ace/dev/) • [Deployment](.github/DEPLOYMENT.md) + +This Pages site is a **pretotype landing**, not a live GPU or UE5 twin. Stamps say **SHIPPED**, **LIVE** (elsewhere), **VISION**, **SPEC**, **PLANNED**, or **MOCK**. GitHub Pages deploys **`main`** and **`dev` only**.
--- -## 🎯 Project Overview +## What this site is + +Public face of **ATLAS** (outlined on Blueprint as soil-to-cell). GrowTwin is PCPartPicker for farms. The racquet Facility OS shipped in a private twin. This site does **not** start a second 60Hz loop, bundle that engine, or run Unreal. -LawnTech Dynamics is an innovative multi-floor racquet sports facility concept combining autonomous operations, sustainable grass cultivation, and AI-powered performance analytics. Located in Naperville, Illinois, this facility aims to revolutionize indoor sports through cutting-edge technology and sustainable practices. +A nested **Pascal campus sketch** remains: Naperville racquet **SPEC** plus an APEX peak-performance wing (**VISION**). That sketch is not a live twin. -### Core Innovation -- **🌱 Autonomous Grass Management**: Vertical farming system that grows and swaps court surfaces robotically -- **🤖 AI-Powered Operations**: Self-managing facility with minimal human oversight -- **📊 Performance Analytics**: Real-time biomechanics tracking and injury prevention -- **♻️ Sustainable Design**: Solar-powered with advanced resource optimization +| Stamp | What | +|---|---| +| **SHIPPED** | USAPA 2025 engine + Facility OS MVP in the private racquet twin (demo/sim data). Not this bundle. | +| **VISION** | GrowTwin CEA — spec → yield/watts → bind a twin. UE5/Cesium is the twin claim. | +| **LIVE** | GPU racquet twin and photoreal farm twin — private apps only. | +| **SPEC** | Naperville origin racquet counts on the nested sketch. Grass lab: 500 m² per section, section count unspecified. | +| **PLANNED / MOCK** | Blender-native GFX, web GFX quality lanes, waitlist form, Gemini chat. | +| **This repo runs** | ATLAS landing copy + Pascal Viewer + CSS fallback. Live loops stay private. | + +Do not copy facility floor plans or private twin source into this tree. Do not resurrect the Three.js lab theater as the renderer. Mine campus program through Pascal. --- -## 🏗️ Facility Layout +## 🏗️ Naperville origin layout (SPEC) ### **Ground Floor** - Tennis Complex -24 premium tennis courts featuring: +24 tennis courts featuring: - Grass courts (replaceable modular turf) - Hard courts - Clay courts - Wood courts -- Pro shop and premium locker rooms +- Pro shop and locker rooms +- Surface split is **unspecified** (not 6/6/6/6 unless the spec says so) ### **First Floor** - Mezzanine Sports - 16 badminton courts @@ -42,32 +52,40 @@ LawnTech Dynamics is an innovative multi-floor racquet sports facility concept c - 1 historic real tennis court ### **Third Floor** - Vertical Grass Lab -2,000 m² autonomous farming facility with: -- Hydroponics and climate control -- Robotic patch transport system -- 60-minute court surface replacement capability +Origin spec: **500 m² per section**. Section count is not in the origin (an earlier public page inferred 4 × 500 = 2,000 m²). +- Hydroponics and climate control (**PLANNED**) +- Patch transport (**PLANNED** — not a live robot fleet) +- Fast turf swap is a pretotype goal, not a measured receipt --- -## 🚀 Technology Stack +## What this repo actually runs + +### Pages demo (this tree) +- **ATLAS landing copy** (`landing/public.ts`) — pretotype; does not run the live twin +- **React 19.2** + TypeScript +- **Pascal Viewer** (`@pascal-app/core` / `viewer` / `nodes` 1.0.0) — nested campus sketch: Site → LawnTech levels 0–3 + APEX VISION wing +- Envelope **140×120 m** and **10 m** storeys are **inferred**, not origin measurements +- APEX rooms are **nine VISION zones** on inferred 30×30 m cells +- Grass lab is **one PLANNED zone** (origin: 500 m²/section; section count unspecified) +- CSS campus / court fallback when WebGL cannot paint +- **Framer Motion**, **Vite** +- **Gemini** chat is a **stub** on GitHub Pages (not a concierge product) +- Pascal **editor** is not shipped; `@pascal-app/editor` is a read-only shim so node definitions can load + +### Proof + +```bash +npm test # facility/check-scene.ts + landing/check-copy.ts +npm run build +``` -### Frontend -- **React 19** with TypeScript -- **Framer Motion** for animations -- **Three.js** for 3D facility visualization -- **Tailwind CSS** for styling -- **Vite** for build tooling +Mutation is **n/a** — Stryker is not adopted. Do not invent a score. -### AI & Analytics -- **Google Gemini AI** for chat interface -- Computer vision for biomechanics analysis -- Predictive maintenance algorithms +### Not in this tree +OpenTwins, Eclipse Ditto, Eclipse Hono, Jenkins, Unity-in-Docker, and a live BMS are **origin research language**, not wired dependencies. Do not treat README history as a running stack. -### Autonomous Systems -- Building Management System (BMS) -- Robotic mowers and maintenance drones -- Biometric access control -- Smart HVAC optimization +Agents: read [`AGENTS.md`](AGENTS.md) and [`ROADMAP.md`](ROADMAP.md). Workers never merge `main` or `dev`. --- @@ -75,7 +93,7 @@ LawnTech Dynamics is an innovative multi-floor racquet sports facility concept c ### Prerequisites - Node.js (v20 or higher) -- npm or yarn +- npm - Git ### Local Setup @@ -91,7 +109,7 @@ LawnTech Dynamics is an innovative multi-floor racquet sports facility concept c npm install ``` -3. **Configure environment** +3. **Configure environment** (optional; chat is a stub without it) Create a `.env.local` file: ```env GEMINI_API_KEY=your_api_key_here @@ -102,11 +120,10 @@ LawnTech Dynamics is an innovative multi-floor racquet sports facility concept c npm run dev ``` - Open [http://localhost:3000](http://localhost:3000) in your browser - ### Build for Production ```bash +npm test npm run build npm run preview ``` @@ -120,6 +137,8 @@ This project uses GitHub Actions for automated deployment to GitHub Pages: - **Production**: Automatically deploys from `main` branch to `/` - **Development**: Automatically deploys from `dev` branch to `/dev/` +Rolling git branches (`preview`, `nightly`) from the Verified OSS Loop are **not** Pages slots. Do not invent `/preview//` without extending `.github/workflows/deploy.yml`. + See [Deployment Guide](.github/DEPLOYMENT.md) for detailed setup instructions. ### Deployment URLs @@ -128,114 +147,13 @@ See [Deployment Guide](.github/DEPLOYMENT.md) for detailed setup instructions. --- -## 🎨 Features - -### Interactive 3D Facility Tour -Explore the entire facility complex with: -- Rotatable 3D visualization -- Interactive hotspots for each area -- Detailed feature information cards - -### AI-Powered Chat Assistant -Get instant answers about: -- Facility features and amenities -- Membership options -- Technical specifications -- Court booking - -### Real-Time Performance Analytics -- Biomechanics tracking at 60 FPS -- Serve speed analysis (225 km/h capability) -- Ground force measurement (1900 N torque) -- Pronation/supination tracking - -### Autonomous Operations Dashboard -Monitor facility systems: -- Smart HVAC climate control -- Robotic maintenance status -- Court surface quality metrics -- Energy consumption analytics - ---- - -## 🏛️ Project Architecture - -### Digital Twin Framework -Built on **OpenTwins** technology: -- **Eclipse Ditto**: Digital twin definitions -- **Eclipse Hono**: IoT device integration -- Real-time monitoring and control -- Predictive maintenance algorithms - -### Agent-Based Automation -Inspired by Voyager and Eureka methodologies: -- **Automatic Curriculum**: Dynamic task progression -- **Skill Library**: Reusable action patterns -- **Reward Optimization**: Evolutionary performance tuning -- **Self-Verification**: Continuous improvement loops - -### Feedback-Driven Learning -- Environment feedback integration -- Execution error analysis -- Iterative skill refinement -- Performance metrics tracking - ---- - -## 👥 Expert Roles - -| Role | Responsibility | -|------|----------------| -| **Architect** | Facility layout and safety compliance | -| **Digital Twin Modeler** | Virtual replica and simulation systems | -| **Automation Engineer** | Robotics and autonomous systems integration | -| **Sports Surface Specialist** | Court maintenance and quality assurance | -| **Building Systems Engineer** | BMS and energy optimization | - ---- - -## 🎯 Strategic Goals - -1. **Autonomous Excellence**: Achieve 24/7 operation with minimal human intervention -2. **Sustainability**: Net-zero energy consumption through solar and smart systems -3. **User Experience**: Seamless booking, access, and service delivery -4. **Performance**: Industry-leading analytics and injury prevention -5. **Community**: Local partnerships and educational collaborations - ---- - -## 🤝 Partnerships & Funding +## Spec lineage -### Strategic Partnerships -- Local sports organizations for cost sharing -- Educational institutions for research collaboration -- Government agencies for sustainability grants +- **Vision program**: `origin/enhance/3D` APEX campus (labs, physio, gym, pool, clubhouse, all sports) +- **Origin numbers**: private racket-sports spec (Naperville, four floors) +- Voyager/Eureka-style curriculum language is inspiration for a future ops loop, not a second scheduler in this tree -### Investment Opportunities -Currently raising Series A funding for Austin, Texas pilot facility. -Contact via the [Invest page](https://kvnloo.github.io/ace/) for more information. - ---- - -## 📊 Technical Specifications - -### Data Collection -- Multi-sensor array for court conditions -- Environmental monitoring (temperature, humidity, air quality) -- Player movement tracking and analysis -- Real-time video analytics - -### Simulation & Analysis -- Energy usage forecasting -- Maintenance schedule optimization -- Player traffic pattern analysis -- Resource allocation modeling - -### System Integration -- Centralized control via BMS -- Real-time IoT device communication -- Cloud-based analytics platform -- Mobile app for member access +A pretty 3D scene is a **projection**, not proof of autonomy. --- @@ -243,66 +161,32 @@ Contact via the [Invest page](https://kvnloo.github.io/ace/) for more informatio ``` ace/ -├── .github/ -│ ├── workflows/ # CI/CD pipelines -│ └── DEPLOYMENT.md # Deployment documentation -├── components/ # React components -│ ├── NavBar.tsx -│ ├── ThreeScene.tsx -│ ├── AIChat.tsx -│ └── Specifications.tsx -├── services/ # Service layer -├── App.tsx # Main application -├── index.tsx # Entry point -├── types.ts # TypeScript definitions -└── vite.config.ts # Build configuration +├── AGENTS.md # Verified OSS Loop + ACE ownership +├── ROADMAP.md # First untracked item is what autodevelop mints +├── facility/ # Pascal program + scene compiler +│ ├── program.ts +│ ├── vision.ts # APEX VISION rooms +│ ├── generateScene.ts +│ └── check-scene.ts +├── components/ +│ ├── PascalFacility.tsx +│ ├── facility/SketchMap.tsx +│ └── ThreeScene.tsx # unused legacy — do not remount +├── .github/workflows/ # Pages deploy + validate + loop receipts +└── App.tsx ``` --- -## 🔧 Configuration - -### Base Path Configuration -The project supports dynamic base paths for multi-environment deployment: - -```typescript -// vite.config.ts -const base = process.env.VITE_BASE_PATH || '/'; -``` - -Set via environment variable during build: -```bash -VITE_BASE_PATH=/dev/ npm run build -``` - ---- - -## 📖 Documentation - -- [Deployment Guide](.github/DEPLOYMENT.md) - GitHub Pages setup and troubleshooting -- [AI Studio Link](https://ai.studio/apps/drive/1Fc7kvKrC_eN-FRp7CerebmQONPqJW6z2) - Original project workspace - ---- - ## 📄 License This project is licensed under the terms specified in [LICENSE](LICENSE). --- -## 🙏 Acknowledgments - -- **OpenTwins** for digital twin infrastructure -- **Eclipse Foundation** for Ditto and Hono frameworks -- **Google** for Gemini AI integration -- **Community Contributors** for feedback and support +## Acknowledgments ---- - -
- -**Built with ❤️ by the LawnTech Dynamics team** - -[Report Bug](https://github.com/kvnloo/ace/issues) • [Request Feature](https://github.com/kvnloo/ace/issues) • [Contact Us](https://kvnloo.github.io/ace/) - -
+- Origin facility spec (private digital-twin racket-sports project) +- APEX campus program from the `enhance/3D` branch +- Pascal Viewer for the public sketch +- Gemini is optional and stubbed on Pages diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..19b2eaf --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,24 @@ +# ACE roadmap + +Workers mint **exactly one** `needs-discussion` issue from the first untracked item. Do not rewrite this file to claim work. Do not self-apply `claimable`. + +## Honesty split + +| Stamp | Meaning | +|---|---| +| **VISION** | Peak-performance campus for human flourishing from all angles: scientists, athletes, labs, physiotherapy, all sports. Program mined from `origin/enhance/3D` (APEX). Not origin measurements. | +| **SPEC** | Naperville origin racquet counts: 24 tennis / 16 badminton / 4 squash / 16 table tennis / 8 pickleball / 1 real tennis. Grass lab: 500 m² per section, section count unspecified. | +| **PLANNED / MOCK** | Live BMS, drones, 60 FPS biomechanics, Gemini concierge product, 6/6/6/6 split, four farm racks, 60-minute turf-swap receipt. | +| **This repo runs** | ATLAS public landing + Pascal pretotype + CSS fallback. Not a live facility. Live GPU loop stays private. | + +## Untracked (oldest first) + +1. Delete or isolate unused `components/ThreeScene.tsx` so inferred 6/6/6/6 and four farm racks cannot remount if that canvas is imported again. +2. GitHub Pages still deploys only `main` (`/ace/`) and `dev` (`/ace/dev/`). Rolling `preview` / `nightly` branches have no Pages slot — do not invent `/preview//` without extending `.github/workflows/deploy.yml` and documenting it. +3. APEX Pascal rooms are named VISION zones with inferred 30×30 m cells. Do not promote those dimensions (or 147 biomarkers, +28% VO₂, 1,388 seats) to origin until a spec says so. +4. Do not start ACE as a second live GPU loop. Homebase (`kvnloo/homebase-pickleball`) owns the live GPU path. + +## Maintainer (not worker) + +- `.github/scripts/create-labels.sh` (gh write; workers cannot). +- `bash scripts/ensure-rollout-branches.sh --root . --push` so `preview` and `nightly` exist. After that, new worker PRs target `preview`. Existing honesty PR #7 still targets `main` because it predates rolling branches. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b28c8e8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,6 @@ +# Security + +- Do not open issues with secrets, pairing tokens, API keys, or private transcripts. +- Report vulnerabilities privately via GitHub Security Advisories on `kvnloo/ace`, or to @kvnloo. Do not file a public CVE-shaped issue unless the project says so. +- Unknown authority, undeclared privileged capability, and unsigned receipts fail closed when the project has a validator. +- This file never authorizes autonomous merge. diff --git a/components/AIChat.tsx b/components/AIChat.tsx index 7e02a5e..481c90a 100644 --- a/components/AIChat.tsx +++ b/components/AIChat.tsx @@ -9,7 +9,7 @@ const AIChat: React.FC = () => { const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); const [messages, setMessages] = useState([ - { role: 'model', text: "Welcome to LawnTech Dynamics. I'm your AI Concierge. Ask me about our autonomous grass courts or replacement modular grids." } + { role: 'model', text: "This chat is a stub. ATLAS is the soil-to-cell pretotype. Ask about GrowTwin (CEA), the racquet Facility OS (private live loop), or the nested campus SPEC." } ]); const scrollRef = useRef(null); @@ -58,7 +58,7 @@ const AIChat: React.FC = () => {
- Facility AI Concierge + Campus sketch guide
+ + +
+
+ +
+ {pillars.map((pillar) => ( +
+ + {pillar.stamp} + +

{pillar.title}

+

{pillar.body}

+
+ ))} +
+ +
+

{product.thisSite}

+

{product.liveTwin}

+
+
+ ); +}; + +export const AtlasProduct: React.FC<{ onChangeView: (view: View) => void }> = ({ onChangeView }) => { + return ( +
+

{product.name}

+

{product.subhead}

+

+ {product.thisSite} {product.liveTwin} +

+ +
+ {lanes.map((lane) => ( +
+
+ {laneIcon(lane.group)} +
+

{lane.group}

+
    + {lane.items.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ ))} +
+ +
+ {inFlight.map((item) => ( +
+ {item.stamp} +

{item.title}

+

{item.body}

+
+ ))} +
+ +
+
+ {campusNested.stamp} +

{campusNested.title}

+

{campusNested.body}

+ +
+
+
+ {Array.from({ length: 9 }).map((_, i) => ( +
+ ))} +
+
+ + VISION · CAMPUS SKETCH + +
+
+
+
+ ); +}; + +export default AtlasLanding; diff --git a/components/NavBar.tsx b/components/NavBar.tsx index 107b43b..14774d1 100644 --- a/components/NavBar.tsx +++ b/components/NavBar.tsx @@ -31,17 +31,18 @@ const NavBar: React.FC = ({ currentView, onChangeView }) => { className="text-2xl font-extrabold tracking-tighter text-white cursor-pointer flex items-center gap-2" onClick={() => onChangeView(View.HOME)} > - - LAWNTECH DYNAMICS + + ATLAS + PRETOTYPE
{/* Desktop Nav */}
- + + - - - + + + ))} +
+ )} +
+ Naperville pretotype · VISION campus · Pascal facility +
+ + )} +
+ ); +}; + +export default PascalFacility; diff --git a/components/Specifications.tsx b/components/Specifications.tsx index 0882f70..2619d54 100644 --- a/components/Specifications.tsx +++ b/components/Specifications.tsx @@ -1,18 +1,27 @@ import React from 'react'; import { motion } from 'framer-motion'; -import { CheckCircle2, Layers, Droplets, Zap, Home, Server } from 'lucide-react'; +import { CheckCircle2, Layers, Droplets, Zap, Home, Server, Brain, Cpu } from 'lucide-react'; +import { product } from '../landing/public.ts'; const Specifications: React.FC = () => { const specs = [ + { + category: "ATLAS (private twins)", + icon: , + items: [ + { label: "Brand", value: "ATLAS — soil to cell pretotype" }, + { label: "GrowTwin", value: "CEA spec → simulate yield/watts → bind a twin (UE5 elsewhere)" }, + { label: "Facility OS", value: "USAPA 2025 + booking/robots — SHIPPED in private repo, not this bundle" }, + { label: "This Pages site", value: "PRETOTYPE landing — does not run the live GPU or UE5 loops" } + ] + }, { category: "Ground Floor: Tennis Arena", icon: , items: [ - { label: "Hard Courts (DecoTurf)", value: "6 Courts" }, - { label: "Clay Courts (Red Clay)", value: "6 Courts" }, - { label: "Grass Courts (Organic)", value: "6 Courts" }, - { label: "Wood Courts (Maple)", value: "6 Courts" }, - { label: "Amenities", value: "Pro Shop & Lockers" } + { label: "Tennis courts", value: "24 (hard, clay, grass, wood)" }, + { label: "Split", value: "Not specified as 6/6/6/6" }, + { label: "Amenities", value: "Pro shop & lockers" } ] }, { @@ -31,27 +40,37 @@ const Specifications: React.FC = () => { items: [ { label: "Pickleball Courts", value: "8 Courts" }, { label: "Real Tennis Court", value: "1 Historic Court" }, - { label: "Viewing Decks", value: "360° Glass Walkways" } + { label: "Viewing decks", value: "Not in origin spec" } ] }, { category: "Level 3: Vertical Farming", icon: , items: [ - { label: "Farming Area", value: "4 x 500m² Sections" }, - { label: "Technology", value: "Auto-Hydroponics" }, - { label: "Lighting", value: "Full Spectrum LED" }, - { label: "Robot Fleet", value: "Patch Transporters" } + { label: "Farming area", value: "500 m² per section (origin)" }, + { label: "Sections", value: "Unspecified (not 4×500 unless specced)" }, + { label: "Technology", value: "Hydroponics — PLANNED" }, + { label: "Patch transport", value: "PLANNED" } + ] + }, + { + category: "APEX campus (VISION)", + icon: , + items: [ + { label: "Labs", value: "Biometric, cognitive, movement, research, nutrition" }, + { label: "Recovery", value: "Physiotherapy + recovery suite" }, + { label: "Training", value: "Gym, pool, clubhouse — all sports" }, + { label: "Dimensions", value: "Inferred 30×30 m cells — not origin" } ] }, { - category: "Autonomous Systems", - icon: , + category: "Ops overlay (not live)", + icon: , items: [ - { label: "Access", value: "Biometric / Mobile App" }, - { label: "Monitoring", value: "Drone & Lidar Fleet" }, - { label: "Energy", value: "Solar + Smart BMS" }, - { label: "Irrigation", value: "AI Predictive Water" } + { label: "Access", value: "PLANNED — app / biometric language" }, + { label: "Monitoring", value: "MOCK — drone / lidar overlay" }, + { label: "Energy", value: "PLANNED — solar + BMS language" }, + { label: "Irrigation", value: "PLANNED" } ] } ]; @@ -59,9 +78,9 @@ const Specifications: React.FC = () => { return (
-

Facility Blueprints

+

{product.name} Blueprints

- A four-story vertical integration of sport, agriculture, and technology. + Public pretotype landing. ATLAS is soil to cell: GrowTwin CEA plus a racquet Facility OS. Nested campus numbers below are Naperville SPEC / APEX VISION — not a live twin.

diff --git a/components/ThreeScene.tsx b/components/ThreeScene.tsx index 8b64151..f9f7bdf 100644 --- a/components/ThreeScene.tsx +++ b/components/ThreeScene.tsx @@ -1,4 +1,5 @@ +/** Legacy Three.js primitive facility. Map view now mounts `PascalFacility`. */ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Canvas, useFrame, useThree } from '@react-three/fiber'; import { @@ -28,10 +29,10 @@ const BUILDING_DEPTH = 120; const BRAND_YELLOW = "#DFFF4F"; const FEATURES: FeatureData[] = [ - { id: 'ground_tennis', title: 'Ground: Tennis Arena', description: '24 Courts: 6 Hard, 6 Clay, 6 Grass, 6 Wood.', icon: '🎾', position: [0, 5, 20] }, + { id: 'ground_tennis', title: 'Ground: Tennis Arena', description: '24 tennis courts (hard, clay, grass, wood) plus a pro shop. The 6/6/6/6 split in this sketch is inferred, not origin-specified.', icon: '🎾', position: [0, 5, 20] }, { id: 'level1_racquet', title: 'L1: Racquet Mezzanine', description: '16 Badminton, 4 Squash, 16 Table Tennis.', icon: '🏸', position: [-20, 25, 0] }, { id: 'level2_social', title: 'L2: Pickleball & Heritage', description: '8 Pickleball courts and 1 Real Tennis court.', icon: '🏓', position: [20, 45, 0] }, - { id: 'level3_farm', title: 'L3: Vertical Grass Lab', description: '4x 500sqm Autonomous Farming Sectors.', icon: '🌱', position: [0, 65, 0] }, + { id: 'level3_farm', title: 'L3: Vertical Grass Lab', description: '500 m² per section (origin). Section count is unspecified — this sketch shows four as a layout inference.', icon: '🌱', position: [0, 65, 0] }, { id: 'outdoor_plaza', title: 'Outdoor Plaza', description: 'Public courts and relaxation zones.', icon: '🌳', position: [80, 0, 80] }, ]; @@ -160,6 +161,257 @@ const ControlsOverlay = ({ ); }; +function probeWebGLPaint(): boolean { + if (typeof document === 'undefined') return false; + try { + const canvas = document.createElement('canvas'); + canvas.width = 16; + canvas.height = 16; + const opts: WebGLContextAttributes = { + preserveDrawingBuffer: true, + antialias: false, + }; + const gl = (canvas.getContext('webgl2', opts) || + canvas.getContext('webgl', opts) || + canvas.getContext('experimental-webgl', opts)) as WebGLRenderingContext | null; + if (!gl) return false; + if (typeof gl.isContextLost === 'function' && gl.isContextLost()) return false; + gl.viewport(0, 0, 16, 16); + gl.clearColor(0.15, 0.55, 0.22, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + const px = new Uint8Array(4); + gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + const painted = px[0] > 10 || px[1] > 20 || px[2] > 10; + gl.getExtension('WEBGL_lose_context')?.loseContext(); + return painted; + } catch { + return false; + } +} + +class WebGLErrorBoundary extends React.Component< + { onFail: () => void; children: React.ReactNode }, + { failed: boolean } +> { + state = { failed: false }; + static getDerivedStateFromError() { + return { failed: true }; + } + componentDidCatch() { + this.props.onFail(); + } + render() { + if (this.state.failed) return null; + return this.props.children; + } +} + +const SKETCH_BY_FLOOR: Record< + string, + { title: string; note: string; variant: 'tennis' | 'badminton' | 'pickle' | 'farm' | 'campus' } +> = { + ALL: { + title: 'Naperville facility', + note: '24 tennis · grass lab · CSS sketch', + variant: 'campus', + }, + '0': { + title: 'Ground: Tennis arena', + note: '24 courts · hard / clay / grass / wood', + variant: 'tennis', + }, + '1': { + title: 'L1: Racquet mezzanine', + note: '16 badminton · 4 squash · 16 table tennis', + variant: 'badminton', + }, + '2': { + title: 'L2: Pickleball & heritage', + note: '8 pickleball · 1 real tennis', + variant: 'pickle', + }, + '3': { + title: 'L3: Vertical grass lab', + note: '500 m² per section · section count unspecified', + variant: 'farm', + }, +}; + +const CourtDiagram: React.FC<{ + variant: 'tennis' | 'badminton' | 'pickle' | 'farm' | 'campus'; +}> = ({ variant }) => { + if (variant === 'farm') { + return ( +
+
+
+ {[0, 1, 2, 3].map((i) => ( +
+ + SECTION · inferred + +
+ ))} +
+
+ ); + } + + const surface = + variant === 'pickle' + ? 'bg-[#3d7a3a]' + : variant === 'badminton' + ? 'bg-[#1f6b4a]' + : 'bg-[#3f6b1d]'; + + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +}; + +const SketchFallback: React.FC<{ + activeFloor: FloorLevel; + setActiveFloor: (f: FloorLevel) => void; + annotationMode: AnnotationMode; + setAnnotationMode: (m: AnnotationMode) => void; + selectedId: string | null; + onSelect: (feature: FeatureData) => void; +}> = ({ + activeFloor, + setActiveFloor, + annotationMode, + setAnnotationMode, + selectedId, + onSelect, +}) => { + const sketch = SKETCH_BY_FLOOR[String(activeFloor)] ?? SKETCH_BY_FLOOR.ALL; + const showLabels = annotationMode === 'LABELS' || annotationMode === 'MEASUREMENTS'; + const visibleFeatures = FEATURES.filter((f) => { + if (!showLabels) return false; + if (activeFloor === 'ALL') return true; + if (activeFloor === 0) return f.id.includes('ground'); + if (activeFloor === 1) return f.id.includes('level1'); + if (activeFloor === 2) return f.id.includes('level2'); + if (activeFloor === 3) return f.id.includes('level3'); + return true; + }); + + return ( +
+ + +
+

{sketch.title}

+

{sketch.note}

+ + CSS SKETCH · WebGL did not paint + + {annotationMode === 'MEASUREMENTS' && ( + + {sketch.variant === 'farm' ? '500 m² / section (origin)' : '10m × 22m · typical court'} + + )} +
+ {visibleFeatures.length > 0 && ( +
+ {visibleFeatures.map((f) => ( + + ))} +
+ )} +
+ Naperville pretotype · architectural sketch +
+
+ ); +}; + +const PaintGuard: React.FC<{ onPainted: () => void; onBlank: () => void }> = ({ + onPainted, + onBlank, +}) => { + const { gl } = useThree(); + const frames = useRef(0); + const settled = useRef(false); + + useFrame(() => { + if (settled.current) return; + frames.current += 1; + if (frames.current < 24) return; + settled.current = true; + try { + const ctx = gl.getContext(); + const w = ctx.drawingBufferWidth; + const h = ctx.drawingBufferHeight; + if (w < 2 || h < 2) { + onBlank(); + return; + } + const px = new Uint8Array(4); + const samples: Array<[number, number]> = [ + [0.5, 0.5], + [0.4, 0.45], + [0.6, 0.45], + [0.5, 0.38], + [0.5, 0.6], + ]; + let lit = false; + for (const [u, v] of samples) { + ctx.readPixels(Math.floor(w * u), Math.floor(h * v), 1, 1, ctx.RGBA, ctx.UNSIGNED_BYTE, px); + if (px[0] + px[1] + px[2] > 18) { + lit = true; + break; + } + } + if (lit) onPainted(); + else onBlank(); + } catch { + onBlank(); + } + }); + + return null; +}; + // --- 3D Components --- interface MarkerProps { @@ -186,10 +438,11 @@ const Marker: React.FC = ({ position, title, onClick, isSelected, v > @@ -296,7 +549,7 @@ const FloorRibbon = ({ width, depth }: { width: number, depth: number }) => { return ( - + ) } @@ -326,9 +579,9 @@ const FloorPlate = ({ @@ -340,7 +593,7 @@ const FloorPlate = ({ - + {/* Ceiling Lights */} {Array.from({length: 6}).map((_, i) => ( @@ -398,19 +651,20 @@ const Net = ({ width }: { width: number }) => ( ) const TennisCourt: React.FC<{ position: [number, number, number], type: 'grass' | 'hard' | 'clay' | 'wood' }> = ({ position, type }) => { - const colors = { grass: '#4d7c0f', hard: '#3b82f6', clay: '#ea580c', wood: '#d4a373' }; + const colors = { grass: '#3f6b1d', hard: '#3a5f9a', clay: '#c45c2c', wood: '#c4a574' }; + const roughness = { grass: 0.95, hard: 0.72, clay: 0.88, wood: 0.42 }; return ( - + - + - + @@ -422,7 +676,7 @@ const BadmintonCourt: React.FC<{ position: [number, number, number] }> = ({ posi - + @@ -432,7 +686,7 @@ const RealTennisCourt: React.FC<{ position: [number, number, number] }> = ({ pos - + @@ -455,10 +709,10 @@ const FarmRack: React.FC<{ position: [number, number, number] }> = ({ position } {[0.5, 1.5, 2.5, 3.5].map((y, i) => ( - + ))} - + ) @@ -541,7 +795,7 @@ const OrganicStructure = () => { {curves.map((curve, i) => ( - + ))} @@ -602,7 +856,7 @@ const GroundFloor = ({ active, showMeasurements, showLabels }: { active: boolean {/* Pro Shop Area */} - + {/* Per-Cluster Dimensions */} @@ -645,7 +899,7 @@ const GroundFloor = ({ active, showMeasurements, showLabels }: { active: boolean outlineWidth={0.1} outlineColor="#000" > - {row.label} COURTS + {row.label} · inferred ))} @@ -691,7 +945,7 @@ const LevelTwo = ({ active, showMeasurements }: { active: boolean, showMeasureme /> {Array.from({length:8}).map((_, i) => ( - + ))} @@ -768,7 +1022,7 @@ const CampusGrounds = () => { {/* Main Plaza Pavement */} - + {/* Outdoor Courts Feature (from image reference) */} @@ -799,9 +1053,17 @@ const ThreeScene: React.FC = ({ onFeatureSelect }) => { const [selectedId, setSelectedId] = useState(null); const [activeFloor, setActiveFloor] = useState('ALL'); const [annotationMode, setAnnotationMode] = useState('LABELS'); + const [attempt3d, setAttempt3d] = useState(() => probeWebGLPaint()); + const [webglLive, setWebglLive] = useState(false); const controlsRef = useRef(null); const isAnimatingRef = useRef(false); + useEffect(() => { + if (!attempt3d || webglLive) return; + const timer = window.setTimeout(() => setAttempt3d(false), 2500); + return () => window.clearTimeout(timer); + }, [attempt3d, webglLive]); + const handleSelect = (feature: FeatureData) => { setSelectedId(feature.id); onFeatureSelect(feature); @@ -815,28 +1077,71 @@ const ThreeScene: React.FC = ({ onFeatureSelect }) => { const showLabels = annotationMode === 'LABELS'; const showMeasurements = annotationMode === 'MEASUREMENTS'; + const fallback = ( + + ); + return (
- - - + {!webglLive && fallback} + + {attempt3d && ( + { + setAttempt3d(false); + setWebglLive(false); + }} + > +
+ { + gl.setClearColor(0x000000, 0); + gl.domElement.addEventListener( + 'webglcontextlost', + (event) => { + event.preventDefault(); + setAttempt3d(false); + setWebglLive(false); + }, + { once: true } + ); + }} + > + setWebglLive(true)} + onBlank={() => setAttempt3d(false)} + /> - + + - + @@ -867,7 +1172,7 @@ const ThreeScene: React.FC = ({ onFeatureSelect }) => { ) })} - + = ({ onFeatureSelect }) => { onStart={() => { isAnimatingRef.current = false; }} /> - -
- ECO-FACILITY VIEWER v3.3
- INTERACTIVE ARCHITECTURAL MODEL
+ + )} + + {webglLive && ( + <> + +
+ Naperville pretotype · architectural sketch +
+ + )}
); }; diff --git a/components/facility/SketchMap.tsx b/components/facility/SketchMap.tsx new file mode 100644 index 0000000..abee422 --- /dev/null +++ b/components/facility/SketchMap.tsx @@ -0,0 +1,264 @@ +import React from 'react'; +import { Layers, Ruler, Eye, Box, Maximize2 } from 'lucide-react'; +import { FeatureData } from '../types'; +import { + AnnotationMode, + FEATURES, + FloorLevel, + SKETCH_BY_FLOOR, +} from '../../facility/program'; + +export const ControlsOverlay: React.FC<{ + activeFloor: FloorLevel; + setActiveFloor: (f: FloorLevel) => void; + annotationMode: AnnotationMode; + setAnnotationMode: (m: AnnotationMode) => void; +}> = ({ activeFloor, setActiveFloor, annotationMode, setAnnotationMode }) => { + return ( +
+
+
+ Floor View +
+ {[ + { id: 'APEX', label: 'APEX: Campus' }, + { id: 3, label: 'L3: Lab' }, + { id: 2, label: 'L2: Social' }, + { id: 1, label: 'L1: Racquet' }, + { id: 0, label: 'G: Tennis' }, + { id: 'ALL', label: 'Full Campus' }, + ].map((item) => ( + + ))} +
+ +
+
+ Overlay +
+ + + +
+
+ ); +}; + +const CourtDiagram: React.FC<{ + variant: 'tennis' | 'badminton' | 'pickle' | 'farm' | 'campus' | 'apex'; +}> = ({ variant }) => { + if (variant === 'farm') { + return ( +
+
+
+ + GRASS LAB · one zone + + + 500 m² / section · count unspecified + +
+
+ ); + } + + if (variant === 'apex') { + const cells = [ + 'Biometric', + 'Cognitive', + 'Movement', + 'Research', + 'Nutrition', + 'Recovery', + 'Gym', + 'Pool', + 'Clubhouse', + ]; + return ( +
+
+
+ {cells.map((label) => ( +
+ {label} +
+ ))} +
+
+
+ ); + } + + if (variant === 'campus') { + return ( +
+
+
+ SPEC + LawnTech racquet + 24 tennis · grass lab +
+
+ {Array.from({ length: 9 }).map((_, i) => ( +
+ ))} +
+
+
+ VISION campus · inferred cells +
+
+ ); + } + + const surface = + variant === 'pickle' ? 'bg-[#3d7a3a]' : variant === 'badminton' ? 'bg-[#1f6b4a]' : 'bg-[#3f6b1d]'; + + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +}; + +export const SketchFallback: React.FC<{ + activeFloor: FloorLevel; + setActiveFloor: (f: FloorLevel) => void; + annotationMode: AnnotationMode; + setAnnotationMode: (m: AnnotationMode) => void; + selectedId: string | null; + onSelect: (feature: FeatureData) => void; + reason?: string; +}> = ({ + activeFloor, + setActiveFloor, + annotationMode, + setAnnotationMode, + selectedId, + onSelect, + reason = 'WebGL did not paint', +}) => { + const sketch = SKETCH_BY_FLOOR[String(activeFloor)] ?? SKETCH_BY_FLOOR.ALL; + const showLabels = annotationMode === 'LABELS' || annotationMode === 'MEASUREMENTS'; + const visibleFeatures = FEATURES.filter((f) => { + if (!showLabels) return false; + if (activeFloor === 'ALL') return true; + if (activeFloor === 0) return f.id.includes('ground'); + if (activeFloor === 1) return f.id.includes('level1'); + if (activeFloor === 2) return f.id.includes('level2'); + if (activeFloor === 3) return f.id.includes('level3'); + if (activeFloor === 'APEX') return f.id.includes('apex'); + return true; + }); + + return ( +
+ + + {sketch.variant === 'apex' || sketch.variant === 'campus' ? ( +
+ + CSS SKETCH · {reason} + + {annotationMode === 'MEASUREMENTS' && ( +
+ APEX cells inferred · not origin +
+ )} +
+ ) : ( +
+

{sketch.title}

+

{sketch.note}

+ + CSS SKETCH · {reason} + + {annotationMode === 'MEASUREMENTS' && ( + + {sketch.variant === 'farm' ? '500 m² / section (origin)' : 'envelope 140×120 m · inferred'} + + )} +
+ )} + {visibleFeatures.length > 0 && ( +
+ {visibleFeatures.map((f) => ( + + ))} +
+ )} +
+ Naperville pretotype · VISION campus · Pascal program sketch +
+
+ ); +}; diff --git a/facility/check-scene.ts b/facility/check-scene.ts new file mode 100644 index 0000000..55919db --- /dev/null +++ b/facility/check-scene.ts @@ -0,0 +1,72 @@ +import { generateLawnTechScene, sceneStats } from './generateScene.ts'; +import { APEX_BUILDING_ID, APEX_LEVEL_ID, APEX_ROOMS, APEX_ZONE_IDS } from './vision.ts'; + +const scene = generateLawnTechScene(); +const stats = sceneStats(scene); +const required = [ + 'site_naperville', + 'building_lawntech', + 'level_0', + 'level_1', + 'level_2', + 'level_3', + 'zone_grass-lab', + APEX_BUILDING_ID, + APEX_LEVEL_ID, + ...APEX_ZONE_IDS, +]; +const missing = required.filter((id) => !scene.nodes[id]); +const farmZones = Object.values(scene.nodes).filter( + (node) => node.type === 'zone' && String(node.id).includes('grass'), +); +const visionZones = Object.values(scene.nodes).filter((node) => { + if (node.type !== 'zone') return false; + return (node.metadata as { honesty?: string } | undefined)?.honesty === 'VISION'; +}); +const siteMeta = scene.nodes.site_naperville.metadata as { + envelope?: string; + vision?: string; +}; + +if (missing.length) { + throw new Error(`missing nodes: ${missing.join(', ')}`); +} +if (stats.kinds.building !== 2) { + throw new Error(`expected 2 buildings, got ${stats.kinds.building}`); +} +if (stats.kinds.level !== 5) { + throw new Error(`expected 4 LawnTech levels + 1 APEX level, got ${stats.kinds.level}`); +} +if (farmZones.length !== 1) { + throw new Error(`expected one grass-lab zone, got ${farmZones.length}`); +} +if (visionZones.length !== APEX_ROOMS.length) { + throw new Error(`expected ${APEX_ROOMS.length} VISION zones, got ${visionZones.length}`); +} +if (stats.hasFourFarmSections) { + throw new Error('inferred farm sections leaked into the Pascal scene'); +} +if (siteMeta.envelope !== 'inferred-140x120') { + throw new Error('site envelope stamp missing'); +} +if (siteMeta.vision !== 'peak-performance-campus') { + throw new Error('site vision stamp missing'); +} + +const site = scene.nodes.site_naperville as { children?: string[] }; +if (!site.children?.includes('building_lawntech') || !site.children?.includes(APEX_BUILDING_ID)) { + throw new Error('site must parent both LawnTech and APEX'); +} + +console.log( + JSON.stringify( + { + ok: true, + ...stats, + visionZones: visionZones.length, + apexRooms: APEX_ROOMS.map((room) => room.id), + }, + null, + 2, + ), +); diff --git a/facility/generateScene.ts b/facility/generateScene.ts new file mode 100644 index 0000000..f2d3f3e --- /dev/null +++ b/facility/generateScene.ts @@ -0,0 +1,511 @@ +import { + BuildingNode, + DoorNode, + LevelNode, + SiteNode, + SlabNode, + WallNode, + WindowNode, + ZoneNode, + type AnyNode, + type AnyNodeId, +} from '@pascal-app/core'; +import { + APEX_OFFSET_X, + BUILDING_DEPTH, + BUILDING_ID, + BUILDING_WIDTH, + SITE_ID, + WALL_HEIGHT, + WALL_THICKNESS, +} from './program.ts'; +import { + APEX_BUILDING_ID, + APEX_DEPTH, + APEX_LEVEL_ID, + APEX_ROOMS, + APEX_WIDTH, +} from './vision.ts'; + +type MaterialPreset = 'concrete' | 'plaster' | 'glass' | 'wood' | 'metal'; +type SceneNodes = Record; +type Point = [number, number]; + +type Room = { + id: string; + name: string; + color: string; + polygon: Point[]; + floorFinish: string; + occupancy: string; + metadata: Record; +}; + +type Opening = { + id: string; + kind: 'door' | 'window'; + along: number; + width: number; + height: number; + sill?: number; +}; + +const X0 = -BUILDING_WIDTH / 2; +const Z0 = -BUILDING_DEPTH / 2; +const X1 = BUILDING_WIDTH / 2; +const Z1 = BUILDING_DEPTH / 2; + +function rect(x: number, z: number, w: number, d: number): Point[] { + return [ + [x, z], + [x + w, z], + [x + w, z + d], + [x, z + d], + ]; +} + +function add(nodes: SceneNodes, node: AnyNode) { + nodes[node.id] = node; + return node; +} + +function wallLen(start: Point, end: Point) { + return Math.hypot(end[0] - start[0], end[1] - start[1]); +} + +function addWall( + nodes: SceneNodes, + levelId: string, + id: `wall_${string}`, + start: Point, + end: Point, + material: MaterialPreset, + openings: Opening[] = [], +) { + const children = openings.map((opening) => + opening.kind === 'door' ? (`door_${opening.id.replace(/^door_/, '')}` as const) : (`window_${opening.id.replace(/^window_/, '')}` as const), + ); + const wall = WallNode.parse({ + id, + name: id, + parentId: levelId, + start, + end, + height: WALL_HEIGHT, + thickness: WALL_THICKNESS, + material: { preset: material }, + children, + frontSide: 'exterior', + backSide: 'interior', + }); + add(nodes, wall); + + const length = wallLen(start, end); + for (const opening of openings) { + const along = Math.min(Math.max(opening.along, opening.width / 2), length - opening.width / 2); + if (opening.kind === 'door') { + const doorId = `door_${opening.id.replace(/^door_/, '')}` as const; + add( + nodes, + DoorNode.parse({ + id: doorId, + name: opening.id, + parentId: wall.id, + wallId: wall.id, + position: [along, opening.height / 2, 0], + width: opening.width, + height: opening.height, + doorCategory: 'interior', + doorType: opening.width >= 3.5 ? 'double' : 'hinged', + }), + ); + } else { + const windowId = `window_${opening.id.replace(/^window_/, '')}` as const; + const sill = opening.sill ?? 1.2; + add( + nodes, + WindowNode.parse({ + id: windowId, + name: opening.id, + parentId: wall.id, + wallId: wall.id, + position: [along, sill + opening.height / 2, 0], + width: opening.width, + height: opening.height, + windowType: 'fixed', + material: { preset: 'glass' }, + }), + ); + } + } + + return wall; +} + +function addRoom(nodes: SceneNodes, levelId: string, room: Room, slabMaterial: MaterialPreset) { + const zone = ZoneNode.parse({ + id: `zone_${room.id}`, + parentId: levelId, + name: room.name, + polygon: room.polygon, + color: room.color, + spaceRole: 'room', + occupancy: room.occupancy, + floorFinish: room.floorFinish, + ceilingHeight: WALL_HEIGHT, + metadata: room.metadata, + }); + const slab = SlabNode.parse({ + id: `slab_${room.id}`, + name: `${room.name} slab`, + parentId: levelId, + polygon: room.polygon, + elevation: 0.05, + material: { preset: slabMaterial }, + }); + add(nodes, zone); + add(nodes, slab); + return [zone.id, slab.id] as const; +} + +function envelopeOpenings(floor: number): { south: Opening[]; east: Opening[]; north: Opening[]; west: Opening[] } { + const ribbon = (prefix: string): Opening[] => [ + { id: `${prefix}_a`, kind: 'window', along: 28, width: 8, height: 3.2, sill: 1.4 }, + { id: `${prefix}_b`, kind: 'window', along: 112, width: 8, height: 3.2, sill: 1.4 }, + ]; + const south: Opening[] = + floor === 0 + ? [ + { id: 'entry', kind: 'door', along: BUILDING_WIDTH / 2, width: 4.2, height: 3.2 }, + { id: 'l0_south_a', kind: 'window', along: 28, width: 8, height: 3.2, sill: 1.4 }, + { id: 'l0_south_b', kind: 'window', along: 112, width: 8, height: 3.2, sill: 1.4 }, + ] + : ribbon(`l${floor}_south`); + return { + south, + east: ribbon(`l${floor}_east`), + north: ribbon(`l${floor}_north`), + west: ribbon(`l${floor}_west`), + }; +} + +function roomsForFloor(floor: number): Room[] { + if (floor === 0) { + return [ + { + id: 'tennis', + name: 'Tennis arena', + color: '#3f6b1d', + polygon: rect(X0, Z0, 120, BUILDING_DEPTH), + floorFinish: 'sport-court', + occupancy: '24 tennis courts (hard/clay/grass/wood — split unspecified)', + metadata: { originCount: 24, honesty: 'SPEC', split: 'unspecified' }, + }, + { + id: 'pro-shop', + name: 'Pro shop', + color: '#8B5A2B', + polygon: rect(X0 + 120, Z0, 20, 60), + floorFinish: 'wood', + occupancy: 'retail', + metadata: { honesty: 'SPEC' }, + }, + { + id: 'lockers', + name: 'Lockers', + color: '#4a5568', + polygon: rect(X0 + 120, Z0 + 60, 20, 60), + floorFinish: 'tile', + occupancy: 'lockers', + metadata: { honesty: 'SPEC' }, + }, + ]; + } + if (floor === 1) { + return [ + { + id: 'badminton', + name: 'Badminton', + color: '#1f6b4a', + polygon: rect(X0, Z0, 80, BUILDING_DEPTH), + floorFinish: 'sport-court', + occupancy: '16 badminton courts', + metadata: { originCount: 16, honesty: 'SPEC' }, + }, + { + id: 'squash', + name: 'Squash', + color: '#2c5282', + polygon: rect(X0 + 80, Z0, 60, 60), + floorFinish: 'sport-court', + occupancy: '4 squash courts', + metadata: { originCount: 4, honesty: 'SPEC' }, + }, + { + id: 'table-tennis', + name: 'Table tennis', + color: '#2b6cb0', + polygon: rect(X0 + 80, Z0 + 60, 60, 60), + floorFinish: 'sport-court', + occupancy: '16 table tennis stations', + metadata: { originCount: 16, honesty: 'SPEC' }, + }, + ]; + } + if (floor === 2) { + return [ + { + id: 'pickleball', + name: 'Pickleball', + color: '#3d7a3a', + polygon: rect(X0, Z0, 90, BUILDING_DEPTH), + floorFinish: 'sport-court', + occupancy: '8 pickleball courts', + metadata: { originCount: 8, honesty: 'SPEC' }, + }, + { + id: 'real-tennis', + name: 'Real tennis', + color: '#6b4f2a', + polygon: rect(X0 + 90, Z0, 50, BUILDING_DEPTH), + floorFinish: 'sport-court', + occupancy: '1 real tennis court', + metadata: { originCount: 1, honesty: 'SPEC' }, + }, + ]; + } + return [ + { + id: 'grass-lab', + name: 'Grass lab', + color: '#1a3d24', + polygon: rect(X0, Z0, BUILDING_WIDTH, BUILDING_DEPTH), + floorFinish: 'grow-deck', + occupancy: '500 m² per section; section count unspecified', + metadata: { + honesty: 'PLANNED', + origin: '500m2-per-section', + sectionCount: 'unspecified', + }, + }, + ]; +} + +function interiorWalls(floor: number): Array<{ id: `wall_${string}`; start: Point; end: Point; doorAlong: number }> { + if (floor === 0) { + return [ + { id: 'wall_l0_amenity', start: [X0 + 120, Z0], end: [X0 + 120, Z1], doorAlong: 60 }, + { id: 'wall_l0_shop_lockers', start: [X0 + 120, 0], end: [X1, 0], doorAlong: 10 }, + ]; + } + if (floor === 1) { + return [ + { id: 'wall_l1_split', start: [X0 + 80, Z0], end: [X0 + 80, Z1], doorAlong: 60 }, + { id: 'wall_l1_squash_tt', start: [X0 + 80, 0], end: [X1, 0], doorAlong: 30 }, + ]; + } + if (floor === 2) { + return [{ id: 'wall_l2_heritage', start: [X0 + 90, Z0], end: [X0 + 90, Z1], doorAlong: 60 }]; + } + return []; +} + +function addApexCampus(nodes: SceneNodes, siteId: string) { + const x0 = -APEX_WIDTH / 2; + const z0 = -APEX_DEPTH / 2; + const cols = 3; + const rows = 3; + const cellW = APEX_WIDTH / cols; + const cellD = APEX_DEPTH / rows; + const childIds: string[] = []; + + APEX_ROOMS.forEach((room, index) => { + const col = index % cols; + const row = Math.floor(index / cols); + const [zoneId, slabId] = addRoom( + nodes, + APEX_LEVEL_ID, + { + id: room.id, + name: room.name, + color: room.color, + polygon: rect(x0 + col * cellW, z0 + row * cellD, cellW, cellD), + floorFinish: 'campus-vision', + occupancy: room.occupancy, + metadata: { + honesty: 'VISION', + source: 'enhance/3D-apex-campus', + dimensions: 'inferred-30x30-cells', + cluster: room.cluster, + }, + }, + 'tile', + ); + childIds.push(zoneId, slabId); + }); + + const south = addWall(nodes, APEX_LEVEL_ID, 'wall_apex_south', [x0, z0], [x0 + APEX_WIDTH, z0], 'glass', [ + { id: 'apex_entry', kind: 'door', along: APEX_WIDTH / 2, width: 3.6, height: 3.2 }, + ]); + const east = addWall( + nodes, + APEX_LEVEL_ID, + 'wall_apex_east', + [x0 + APEX_WIDTH, z0], + [x0 + APEX_WIDTH, z0 + APEX_DEPTH], + 'glass', + ); + const north = addWall( + nodes, + APEX_LEVEL_ID, + 'wall_apex_north', + [x0 + APEX_WIDTH, z0 + APEX_DEPTH], + [x0, z0 + APEX_DEPTH], + 'glass', + ); + const west = addWall(nodes, APEX_LEVEL_ID, 'wall_apex_west', [x0, z0 + APEX_DEPTH], [x0, z0], 'glass'); + childIds.push(south.id, east.id, north.id, west.id); + + add( + nodes, + LevelNode.parse({ + id: APEX_LEVEL_ID, + name: 'APEX — peak-performance wing', + parentId: APEX_BUILDING_ID, + level: 0, + height: WALL_HEIGHT, + children: childIds, + metadata: { honesty: 'VISION', source: 'enhance/3D-apex-campus' }, + }), + ); + + add( + nodes, + BuildingNode.parse({ + id: APEX_BUILDING_ID, + name: 'APEX peak-performance campus (VISION)', + parentId: siteId, + position: [APEX_OFFSET_X, 0, 0], + rotation: [0, 0, 0], + children: [APEX_LEVEL_ID], + metadata: { honesty: 'VISION', source: 'enhance/3D-apex-campus' }, + }), + ); +} + +export type LawnTechScene = { + nodes: SceneNodes; + rootNodeIds: AnyNodeId[]; +}; + +export function generateLawnTechScene(): LawnTechScene { + const nodes: SceneNodes = {}; + const sitePad = 20; + const site = SiteNode.parse({ + id: SITE_ID, + name: 'Naperville — peak-performance pretotype', + polygon: { + type: 'polygon', + points: [ + [X0 - sitePad, Z0 - sitePad], + [APEX_OFFSET_X + APEX_WIDTH / 2 + sitePad, Z0 - sitePad], + [APEX_OFFSET_X + APEX_WIDTH / 2 + sitePad, Z1 + sitePad], + [X0 - sitePad, Z1 + sitePad], + ], + }, + metadata: { + origin: 'Naperville', + honesty: 'pretotype', + vision: 'peak-performance-campus', + envelope: 'inferred-140x120', + wallHeight: 'inferred-10m-sport-hall', + compiler: 'ace-facility@1', + farmSections: 'unspecified', + apex: 'vision-named-rooms-inferred-cells', + }, + children: [BUILDING_ID, APEX_BUILDING_ID], + }); + + const building = BuildingNode.parse({ + id: BUILDING_ID, + name: 'LawnTech Dynamics', + parentId: site.id, + position: [0, 0, 0], + rotation: [0, 0, 0], + children: ['level_0', 'level_1', 'level_2', 'level_3'], + }); + + const names = [ + 'Ground — tennis arena', + 'L1 — racquet mezzanine', + 'L2 — pickleball & heritage', + 'L3 — grass lab', + ]; + + for (let floor = 0; floor < 4; floor += 1) { + const levelId = `level_${floor}` as const; + const rooms = roomsForFloor(floor); + const openings = envelopeOpenings(floor); + const childIds: string[] = []; + + for (const room of rooms) { + const [zoneId, slabId] = addRoom( + nodes, + levelId, + room, + floor === 3 ? 'concrete' : floor === 0 && room.id !== 'tennis' ? 'wood' : 'tile', + ); + childIds.push(zoneId, slabId); + } + + const south = addWall(nodes, levelId, `wall_l${floor}_south`, [X0, Z0], [X1, Z0], 'concrete', openings.south); + const east = addWall(nodes, levelId, `wall_l${floor}_east`, [X1, Z0], [X1, Z1], 'concrete', openings.east); + const north = addWall(nodes, levelId, `wall_l${floor}_north`, [X1, Z1], [X0, Z1], 'concrete', openings.north); + const west = addWall(nodes, levelId, `wall_l${floor}_west`, [X0, Z1], [X0, Z0], 'concrete', openings.west); + childIds.push(south.id, east.id, north.id, west.id); + + for (const partition of interiorWalls(floor)) { + const wall = addWall(nodes, levelId, partition.id, partition.start, partition.end, 'plaster', [ + { + id: `${partition.id}_door`, + kind: 'door', + along: partition.doorAlong, + width: 2.4, + height: 2.4, + }, + ]); + childIds.push(wall.id); + } + + const level = LevelNode.parse({ + id: levelId, + name: names[floor], + parentId: building.id, + level: floor, + height: WALL_HEIGHT, + children: childIds, + metadata: { honesty: floor === 3 ? 'PLANNED' : 'SPEC' }, + }); + add(nodes, level); + } + + add(nodes, building); + addApexCampus(nodes, site.id); + add(nodes, site); + + return { nodes, rootNodeIds: [site.id] }; +} + +export function sceneStats(scene: LawnTechScene) { + const kinds: Record = {}; + for (const node of Object.values(scene.nodes)) { + const kind = String((node as { type?: string }).type ?? 'unknown'); + kinds[kind] = (kinds[kind] ?? 0) + 1; + } + return { + nodeCount: Object.keys(scene.nodes).length, + roots: scene.rootNodeIds, + kinds, + hasFourFarmSections: Object.keys(scene.nodes).some((id) => String(id).includes('section')), + }; +} diff --git a/facility/pascal-editor-shim.ts b/facility/pascal-editor-shim.ts new file mode 100644 index 0000000..7750320 --- /dev/null +++ b/facility/pascal-editor-shim.ts @@ -0,0 +1,186 @@ +/** + * Read-only stub for `@pascal-app/editor`. + * + * `@pascal-app/nodes` 1.0.0 still imports editor tools/panels at module load. + * ACE mounts the Viewer only — never the Pascal editor, Next, or HomeForge. + */ + +export const EDITOR_LAYER = 1; +export const NO_RAYCAST = 0; +export const WALL_CONNECT_SNAP_RADIUS = 0.4; +export const WALL_JOIN_SNAP_RADIUS = 0.4; +export const ARROW_COLOR = '#DFFF4F'; +export const ARROW_SCALE = 1; +export const MEASUREMENT_ACTIVE_COLOR = '#DFFF4F'; +export const DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE = Math.PI; +export const DRAWING_TYPE_OPTIONS = [] as const; +export const DRAFTING_SURFACE_EXTENSION_KEY = 'pascal:editor/drafting-surface'; + +const empty = {}; +const noop = (..._args: unknown[]) => null; +const False = (..._args: unknown[]) => false; +const Zero = (..._args: unknown[]) => 0; + +export function cn(...parts: Array) { + return parts.filter(Boolean).join(' '); +} + +function storeHook(selector?: (state: Record) => unknown) { + return selector ? selector(empty) : empty; +} +storeHook.getState = () => empty; +storeHook.setState = () => {}; +storeHook.subscribe = () => () => {}; + +export const useAlignmentGuides = storeHook; +export const useDraftNode = storeHook; +export const useDragAction = storeHook; +export const useDrawingView = storeHook; +export const useEditingHole = storeHook; +export const useEditor = storeHook; +export const useFacingPose = storeHook; +export const useFenceCurveDraft = storeHook; +export const useFloorplanDraftPreview = storeHook; +export const useFloorplanRender = storeHook; +export const useFreshPlacementVisibility = storeHook; +export const useInteractionScope = storeHook; +export const useMeasurementDraft = storeHook; +export const useOpeningGuides = storeHook; +export const usePathDraftPreview = storeHook; +export const usePlacementCoordinator = storeHook; +export const usePlacementPreview = storeHook; +export const useRegistryToolContext = storeHook; +export const useSegmentDraftChain = storeHook; +export const useWallMoveGhosts = storeHook; +export const useWallSnapIndicator = storeHook; + +export const ActionButton = (_props: Record) => null; +export const ActionGroup = (_props: Record) => null; +export const CollectionsPopover = (_props: Record) => null; +export const CursorSphere = (_props: Record) => null; +export const DimensionPill = (_props: Record) => null; +export const DragBoundingBox = (_props: Record) => null; +export const FloorplanGeometryRenderer = (_props: Record) => null; +export const HandleArrow = (_props: Record) => null; +export const MeasurementPill = (_props: Record) => null; +export const MetricControl = (_props: Record) => null; +export const NodeActionMenu = (_props: Record) => null; +export const PanelSection = (_props: Record) => null; +export const PanelWrapper = (_props: Record) => null; +export const PlacementBox = (_props: Record) => null; +export const PlacementDimensionGuides = (_props: Record) => null; +export const PolygonEditor = (_props: Record) => null; +export const SegmentedControl = (_props: Record) => null; +export const SliderControl = (_props: Record) => null; +export const ToggleControl = (_props: Record) => null; + +export const activateQuickMeasurementHudSource = noop; +export const alignFloorplanDraftPoint = noop; +export const applyFloorplanAlignment = noop; +export const boundaryReshapeScope = noop; +export const buildFloorplanStairEntry = noop; +export const buildMeasurementAngleArcPoints = noop; +export const buildSvgAnnularSectorPath = noop; +export const buildSvgArcPath = noop; +export const buildSvgArrowHeadPoints = noop; +export const calculateItemRotation = noop; +export const chainEndJoinsExistingWall = False; +export const clearCeilingSnapFeedback = noop; +export const clearPlacementSurface = noop; +export const clearQuickMeasurementHudSource = noop; +export const clearSlabSnapFeedback = noop; +export const clearStructuralElevationGuide = noop; +export const clearSurfacePlanSnapFeedback = noop; +export const commitFreshPlacementSubtree = noop; +export const commitMeasurementDraft = noop; +export const consumePlacementDragRelease = noop; +export const createEditorApi = noop; +export const createFenceOnCurrentLevel = noop; +export const createFreshPlacementSubtree = noop; +export const createQuickMeasurementPointerScheduler = noop; +export const createSplineFenceOnCurrentLevel = noop; +export const createWallOnCurrentLevel = noop; +export const curveReshapeScope = noop; +export const duplicateRoofSubtree = noop; +export const duplicateStairSubtree = noop; +export const finishMeasurementDraft = noop; +export const floorplanGeometryMetadata = noop; +export const formatAngleRadians = noop; +export const formatAreaLabel = noop; +export const formatLinearMeasurement = noop; +export const formatMeasurement = noop; +export const formatVolumeLabel = noop; +export const getAngleArcToSegmentReference = noop; +export const getAngleToSegmentReference = noop; +export const getArcPlanPoint = noop; +export const getFloatingMenuScale = Zero; +export const getFloorStackPreviewPosition = noop; +export const getFloorplanWallThickness = Zero; +export const getGridEventScreenProjection = noop; +export const getLinearUnitLabel = Zero; +export const getSegmentAngleReferenceAtPoint = Zero; +export const getSegmentGridStep = Zero; +export const getSideFromNormal = noop; +export const getStairLevelOptions = noop; +export const handleMeasurementDraftEscape = noop; +export const hasRoofFaceChildOverlap = False; +export const holeEditScope = noop; +export const isAlignmentGuideActive = False; +export const isAngleSnapActive = False; +export const isFreshPlacementMetadata = False; +export const isGridSnapActive = False; +export const isMagneticSnapActive = False; +export const isSegmentLongEnough = False; +export const isValidWallSideFace = False; +export const linearControlValueToMeters = noop; +export const linearUnitToMeters = Zero; +export const markToolCancelConsumed = noop; +export const measurementFloorplanPresentationColor = noop; +export const measurementPolygonLabelAnchor = noop; +export const measurementPolygonMidpoints = noop; +export const measurementPresentationColor = noop; +export const meshEditScope = noop; +export const metersToLinearUnit = Zero; +export const movementSfxStepKey = noop; +export const parseMeasurement = noop; +export const projectAlignmentGuidesWorldToActiveBuildingLocal = noop; +export const publishHorizontalConstructionPlane = noop; +export const publishPlacementSurface = noop; +export const publishQuickMeasurementHudSource = noop; +export const publishResolvedElevationGuide = noop; +export const publishStructuralElevationGuide = noop; +export const readFloorplanContext = noop; +export const readFloorplanGeometryMetadata = noop; +export const readFloorplanMetricNotationOverride = noop; +export const resampleTerrainConstructionPlane = noop; +export const resolveAlignmentForActiveBuilding = noop; +export const resolveCeilingPlanPointSnap = noop; +export const resolveElevatorNodeSupportY = noop; +export const resolveElevatorSupportY = noop; +export const resolveEndpointWallSplit = noop; +export const resolveEventConstructionPlane = noop; +export const resolveLevelConstructionPlane = noop; +export const resolvePlanarCursorPosition = noop; +export const resolvePointerSupportSurface = noop; +export const resolveQuickMeasurementReport = noop; +export const resolveRoofWallHit = noop; +export const resolveSlabEdgeBandSnap = noop; +export const resolveSlabPlanPointSnap = noop; +export const resolveStairDestinationLevel = noop; +export const resolveStairFromLevelId = noop; +export const resolveStairToLevelId = noop; +export const resolveStructuralElevationSnap = noop; +export const resolveSurfacePlanPointSnap = noop; +export const snapBuildingLocalToWorldGrid = noop; +export const snapFenceDraftPoint = noop; +export const snapPointToGrid = noop; +export const snapScalarToGrid = noop; +export const snapToHalf = noop; +export const snapWallDraftPoint = noop; +export const snapWallDraftPointDetailed = noop; +export const snapWorldXZForActiveBuilding = noop; +export const stripPlacementMetadataFlags = noop; +export const swallowNextClick = noop; +export const triangulateMeasurementPolygon = noop; +export const triggerSFX = noop; +export const withFloorplanGeometryMetadata = noop; diff --git a/facility/program.ts b/facility/program.ts new file mode 100644 index 0000000..0fbafcb --- /dev/null +++ b/facility/program.ts @@ -0,0 +1,117 @@ +import { FeatureData } from '../types'; +import { APEX_GAP } from './vision.ts'; + +export { APEX_BUILDING_ID, APEX_LEVEL_ID, APEX_ROOMS, APEX_ZONE_IDS } from './vision.ts'; + +/** Inferred envelope from the earlier Three.js sketch — not in the origin spec. */ +export const BUILDING_WIDTH = 140; +export const BUILDING_DEPTH = 120; +/** Sport-hall storey, not the 20 m theater height used by the old primitive scene. */ +export const WALL_HEIGHT = 10; +export const WALL_THICKNESS = 0.35; + +export const SITE_ID = 'site_naperville'; +export const BUILDING_ID = 'building_lawntech'; +/** East of LawnTech. Gap is inferred — not an origin campus survey. */ +export const APEX_OFFSET_X = BUILDING_WIDTH + APEX_GAP; + +export type FloorLevel = 'ALL' | 0 | 1 | 2 | 3 | 'APEX'; +export type AnnotationMode = 'NONE' | 'LABELS' | 'MEASUREMENTS'; +export type SketchVariant = 'tennis' | 'badminton' | 'pickle' | 'farm' | 'campus' | 'apex'; + +export const FEATURES: FeatureData[] = [ + { + id: 'ground_tennis', + title: 'Ground: Tennis Arena', + description: + '24 tennis courts (hard, clay, grass, wood) plus a pro shop. Surface split is unspecified in origin — this sketch does not invent 6/6/6/6.', + icon: '🎾', + position: [0, 5, 20], + }, + { + id: 'level1_racquet', + title: 'L1: Racquet Mezzanine', + description: '16 badminton, 4 squash, 16 table tennis.', + icon: '🏸', + position: [-20, 15, 0], + }, + { + id: 'level2_social', + title: 'L2: Pickleball & Heritage', + description: '8 pickleball courts and 1 real tennis court.', + icon: '🏓', + position: [20, 25, 0], + }, + { + id: 'level3_farm', + title: 'L3: Vertical Grass Lab', + description: + '500 m² per section (origin). Section count is unspecified — this sketch is one lab zone, not four inferred racks.', + icon: '🌱', + position: [0, 35, 0], + }, + { + id: 'apex_labs', + title: 'APEX labs (VISION)', + description: + 'Biometric, cognitive, movement, research, and nutrition rooms from the enhance/3D campus program. Named VISION zones — not origin measurements.', + icon: '◎', + position: [APEX_OFFSET_X, 5, 0], + }, + { + id: 'apex_recovery', + title: 'Recovery / physio (VISION)', + description: + 'Recovery suite and physiotherapy as campus identity. Capacity unspecified. Not a live clinic twin.', + icon: '✚', + position: [APEX_OFFSET_X, 5, 20], + }, + { + id: 'apex_training', + title: 'Gym / pool / clubhouse (VISION)', + description: + 'All-sports training floor, pool, and clubhouse for scientists and athletes. User-story program, not origin counts.', + icon: '▣', + position: [APEX_OFFSET_X, 5, -20], + }, +]; + +export const SKETCH_BY_FLOOR: Record< + string, + { title: string; note: string; variant: SketchVariant } +> = { + ALL: { + title: 'Peak-performance campus', + note: 'VISION campus + Naperville racquet SPEC · Pascal / CSS sketch', + variant: 'campus', + }, + '0': { + title: 'Ground: Tennis arena', + note: 'SPEC · 24 courts · hard / clay / grass / wood', + variant: 'tennis', + }, + '1': { + title: 'L1: Racquet mezzanine', + note: 'SPEC · 16 badminton · 4 squash · 16 table tennis', + variant: 'badminton', + }, + '2': { + title: 'L2: Pickleball & heritage', + note: 'SPEC · 8 pickleball · 1 real tennis', + variant: 'pickle', + }, + '3': { + title: 'L3: Vertical grass lab', + note: 'PLANNED · 500 m² per section · section count unspecified', + variant: 'farm', + }, + APEX: { + title: 'APEX campus wing', + note: 'VISION · labs, physio, gym, pool, clubhouse · cells inferred', + variant: 'apex', + }, +}; + +export function levelNodeId(floor: 0 | 1 | 2 | 3): `level_${string}` { + return `level_${floor}`; +} diff --git a/facility/vision.ts b/facility/vision.ts new file mode 100644 index 0000000..83eda39 --- /dev/null +++ b/facility/vision.ts @@ -0,0 +1,84 @@ +/** Peak-performance campus program mined from origin/enhance/3D. Not origin measurements. */ + +export const APEX_BUILDING_ID = 'building_apex'; +export const APEX_LEVEL_ID = 'level_apex'; +export const APEX_WIDTH = 90; +export const APEX_DEPTH = 90; +export const APEX_GAP = 40; + +export type VisionRoom = { + id: string; + name: string; + color: string; + occupancy: string; + cluster: 'labs' | 'recovery' | 'training'; +}; + +/** Six APEX labs plus gym / pool / clubhouse. Capacities and cell size are inferred. */ +export const APEX_ROOMS: VisionRoom[] = [ + { + id: 'biometric', + name: 'Biometric lab', + color: '#7f1d1d', + occupancy: 'VISION — VO2 / force plate / mocap language; capacity unspecified', + cluster: 'labs', + }, + { + id: 'cognitive', + name: 'Cognitive lab', + color: '#1e3a5f', + occupancy: 'VISION — cognitive training; capacity unspecified', + cluster: 'labs', + }, + { + id: 'movement', + name: 'Movement studio', + color: '#14532d', + occupancy: 'VISION — movement optimization; capacity unspecified', + cluster: 'labs', + }, + { + id: 'research', + name: 'Research integration', + color: '#3b0764', + occupancy: 'VISION — scientists / protocol translation; capacity unspecified', + cluster: 'labs', + }, + { + id: 'nutrition', + name: 'Nutrition kitchen', + color: '#3f3f1a', + occupancy: 'VISION — personalized nutrition language; capacity unspecified', + cluster: 'labs', + }, + { + id: 'recovery', + name: 'Recovery / physiotherapy', + color: '#0f766e', + occupancy: 'VISION — recovery suite + physio; capacity unspecified', + cluster: 'recovery', + }, + { + id: 'gym', + name: 'Gym', + color: '#1f2937', + occupancy: 'VISION — all-sports training floor; capacity unspecified', + cluster: 'training', + }, + { + id: 'pool', + name: 'Pool', + color: '#1e40af', + occupancy: 'VISION — aquatic training; capacity unspecified', + cluster: 'training', + }, + { + id: 'clubhouse', + name: 'Clubhouse', + color: '#44403c', + occupancy: 'VISION — scientists / athletes / members; capacity unspecified', + cluster: 'training', + }, +]; + +export const APEX_ZONE_IDS = APEX_ROOMS.map((room) => `zone_${room.id}`); diff --git a/index.html b/index.html index 614b0b0..f034e80 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - LawnTech Dynamics + ATLAS | Soil to cell