{"owner":"ponylang","repo":"ponyc","hasSkills":true,"totalSkillsCount":17,"totalTokensCount":42359,"categories":["root-instruction","claude-rule","anthropic-skill","plugin-manifest"],"hasMcp":false,"mcpConfig":null,"found":[".ci-scripts/AGENTS.md",".ci-scripts/CLAUDE.md",".claude/skills/32-bit-support-validation/SKILL.md",".claude/skills/add-linux-release-target/SKILL.md",".claude/skills/create-dragonfly-dev-env/SKILL.md",".claude/skills/create-freebsd-dev-env/SKILL.md",".claude/skills/create-openbsd-dev-env/SKILL.md",".claude/skills/update-ssl-builders/SKILL.md",".claude/skills/upgrade-llvm/SKILL.md","AGENTS.md","CLAUDE.md","benchmark/AGENTS.md","packages/net/AGENTS.md","packages/net/CLAUDE.md","tools/lib/README.md","tools/pony-agent-server/README.md","tools/pony-lsp/README.md"],"skills":{".ci-scripts/AGENTS.md":"# .ci-scripts\n\nHelper scripts that CI workflows call (Python and shell). Not part of the ponyc\nbuild, the runtime, or the stdlib — these only run in GitHub Actions (and locally\nwhen you want to exercise them).\n\n## Python conventions\n\n- **Stdlib only.** CI runners have no `pip install` step, so scripts must not\n  import third-party packages. Use `urllib`, `json`, `subprocess`, `tarfile`,\n  etc. — never `requests`, `pyyaml`, `pytest`, …\n- **ruff lints everything here** (`.github/workflows/lint-python.yml`,\n  which now only lints — the tests live in `test-ci-scripts.yml` and\n  `test-rt-stress.yml`). There is no ruff config file, so the defaults apply. A\n  `lint` job runs `ruff check .ci-scripts`; a separate `lint-rt-stress` job runs\n  `ruff check test/rt-stress` — these conventions also cover `test/rt-stress/`,\n  whose generative stress harness carries Python (an orchestrator) under the same\n  rules even though it lives with a test rather than in `.ci-scripts/`.\n- **Tests are `*_test.py` files, self-contained, no pytest.** Each is runnable as\n  `python3 <path>/<name>_test.py`, exits 0 on pass / 1 on fail, and prints a\n  one-line summary (see `pr_changed_suites_test.py` for the shape).\n  **`test-ci-scripts.yml` discovers every `*_test.py` under `.ci-scripts/` (via\n  `find … -name '*_test.py'`) and runs each one; `test-rt-stress.yml` does the\n  same for `test/rt-stress/`, on Linux and Windows** — so a new `*_test.py` is\n  picked up automatically — you do not register it anywhere. When you add or\n  change a script, add or update its `*_test.py`; it will run in CI on the next\n  PR.\n- After writing a test, break each assertion once to confirm it actually fires\n  (counterfactual check) before trusting it.\n\n## Subdirectory and import conventions\n\n- Related scripts are grouped in a subdirectory (e.g. `libs-cache/`). A script\n  imports its **siblings by module name** (`import cache_arch`); this resolves\n  because the scripts are invoked by full path, so `sys.path[0]` is the script's\n  own directory. **Move a script together with its siblings and its importers**,\n  or the imports break.\n- A subdirectory mixes **entry scripts** (runnable by full path, each with a\n  `main` and a CLI) with **support-library modules** (import-only, no `main`). In\n  `libs-cache/` the library modules are `common.py` (die/info/`require_env`/the\n  orchestrator helpers, plus the shared entry-script path constants\n  `MAIN_CACHE`/`BRANCH_CACHE`/`PROMOTE`), `registry.py` (the GHCR registry-v2\n  push/pull client, including `copy` — the cross-namespace promote the warmer uses\n  to reuse a branch build), `ghpackages.py` (the GitHub-packages REST client), and\n  `cache_arch.py` (arch normalization); the entry scripts (`*_libs_cache.py`,\n  `resolve_libs_cache.py`) are thin layers over them — `promote_libs_cache.py`\n  resolves the branch (source) and main (destination) names and calls\n  `registry.copy`. Shared logic belongs in a library module, not copied across\n  entry scripts.\n- A module that derives the **repo root** from `__file__` (e.g.\n  `libs-cache/registry.py`'s `repo_root()`) hardcodes the directory depth with a\n  fixed number of `os.path.dirname` calls. If you move it to a different depth,\n  update that count — `libs-cache/repo_root_test.py` pins it, so a wrong depth\n  fails the test job rather than only surfacing at runtime in CI.\n\n## Pointers\n\n- The libs cache (the `libs-cache/` scripts: warmer, consumers, branch cache,\n  retention, clear) is documented in `libs-cache/README.md`.\n- The `bsd/` scripts provision the BSD CI VMs and run the in-VM libs handling,\n  both shared by `update-lib-cache.yml` and `ponyc-tier3.yml`, one script per\n  platform: `{freebsd,openbsd,dragonfly}-provision.bash` (+ `dfly_configure_vm.py`)\n  boot and set up the VM, and `{freebsd,openbsd,dragonfly}-libs-cache.bash\n  <operation>` restores or builds-and-pushes the libs cache over ssh. See\n  `libs-cache/README.md`.\n- `windows-install-deps.ps1` installs the pinned msys2 lldb and the libraries it\n  loads against. It holds the only copy of that pin, and every Windows job\n  needing lldb calls it. Don't inline the `pacman` block into a workflow: the\n  PR job that checks lldb's behavior has to install the same lldb the\n  scheduled lanes do, or it guards a version nothing runs. The script's header\n  carries the pin history.\n",".ci-scripts/CLAUDE.md":"@AGENTS.md\n",".claude/skills/32-bit-support-validation/SKILL.md":"---\nname: 32-bit-support-validation\ndescription: Load when validating ponyc 32-bit support. Covers syncing the test machine, building LLVM and ponyc, and running the full test suite on a 32-bit ARM RPi4.\ndisable-model-invocation: false\n---\n\n# Validating 32-bit support\n\nRun the test suite on a 32-bit ARM machine to verify nothing is broken.\nThe default test machine is `pony-rpi4-32` (user `pi`, checkout at\n`/home/pi/code/ponylang/ponyc`). It has a persistent checkout and a built LLVM\nthat only needs rebuilding when the vendored LLVM changes.\n\nThis is slow — LLVM builds take many hours, ponyc builds take roughly an hour each,\nand the test suites add more on top. Run long steps detached and poll their logs.\n\n## Known ILP32 failures\n\nThe 32-bit address space (~3 GB user) constrains what can be compiled and\nlinked as a single binary. These are current failures, not accepted\nlimitations — they need to be fixed.\n\n- **The monolithic stdlib test** (`ctest -R stdlib`) compiles every stdlib\n  package into one binary. LLVM runs out of memory during code generation\n  (the \"Function prototypes\" phase), regardless of LTO mode, jemalloc, or\n  debug/release codegen. Stdlib tests are run per-package instead\n  (step 4c / 5c) as a workaround.\n\n## Gotchas (read first)\n\n- **Check the login shell.** If the machine's login shell is not bash (e.g. fish),\n  every SSH command must go through bash explicitly — either\n  `ssh pi@pony-rpi4-32 bash << 'ENDSSH'` for heredocs or\n  `ssh pi@pony-rpi4-32 bash -c '...'` for one-liners.\n- **Check the compiler.** The cmake presets default to `clang`/`clang++`. If the\n  machine only has GCC, override on every cmake configure:\n  `-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++`.\n- **`-mtune=generic` is invalid on ARM GCC.** The `native` preset sets\n  `-march=native -mtune=generic`, but GCC on ARM rejects `generic`. Override:\n  `\"-DCMAKE_C_FLAGS=-march=native\" \"-DCMAKE_CXX_FLAGS=-march=native\"`.\n  The LLVM build (`lib/build-libs.cmake`) does not set these flags, so it is not\n  affected.\n- **Don't run concurrent builds to the same build directory.** If an SSH session\n  times out mid-build, kill the orphaned cmake process before starting another —\n  two builds writing to the same directory corrupt it.\n- **Detach long builds.** Write a bash script to the machine, run it with `nohup`,\n  and poll the log file. Don't use `tee` over SSH — it keeps the connection alive\n  and the SSH session will time out. Redirect to a file instead.\n- **OpenSSL version matters for per-package tests.** cmake auto-detects the SSL\n  library for ctest, but per-package compilation (steps 4c/5c) needs the flag\n  passed explicitly. The scripts detect it via `openssl version`.\n- **LLVM build parallelism.** LLVM is memory-hungry. On machines with limited RAM,\n  use `-DJOBS=2` (or lower) to avoid OOM during the LLVM build.\n\n\n## Step 1 — verify the machine is reachable\n\n```bash\nssh -o ConnectTimeout=5 pi@pony-rpi4-32 bash -c '\"uname -m && cat /etc/os-release | head -2\"'\n```\n\nIf the machine is not reachable, stop and ask where to run the 32-bit validation.\nYou need: an SSH-accessible 32-bit ARM Linux machine with a ponyc checkout, GCC,\ncmake ≥ 3.25, and Python 3.\n\n## Step 2 — update the repo\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\ngit fetch origin\ngit checkout main\ngit pull origin main\ngit submodule update --init lib/llvm/src\ngit log --oneline -3\ngit -C lib/llvm/src rev-parse HEAD\nENDSSH\n```\n\nConfirm the log shows the expected HEAD of main, and the submodule hash matches\n`LLVM_DESIRED_HASH` in `lib/CMakeLists.txt`.\n\n## Step 3 — ensure LLVM is built and current\n\nLLVM is \"current\" when all three of these are true:\n\n1. The submodule at `lib/llvm/src` is at the commit specified by `LLVM_DESIRED_HASH`\n   in `lib/CMakeLists.txt`.\n2. The patches in `lib/llvm/patches/` have the hash specified by\n   `PATCHES_DESIRED_HASH` in `lib/CMakeLists.txt`.\n3. `build/libs/` exists and contains the LLVM built from that source with those\n   patches applied (subdirectories `lib/`, `bin/`, `include/`).\n\n### Check the submodule hash\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\necho \"desired: $(grep 'set(LLVM_DESIRED_HASH' lib/CMakeLists.txt | grep -oP '(?<=\")[^\"]+(?=\")')\"\necho \"actual:  $(git -C lib/llvm/src rev-parse HEAD)\"\nENDSSH\n```\n\nThese must match. If they don't, `git submodule update --init` in step 2 didn't work\n— re-run it, or investigate why the submodule is at the wrong commit.\n\n### Check whether build artifacts exist\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"ls /home/pi/code/ponylang/ponyc/build/libs/lib/ 2>/dev/null | head -3 || echo NO_LIBS\"'\n```\n\nIf `NO_LIBS`, a full rebuild is needed (8+ hours on the RPi — see \"Full rebuild\"\nbelow).\n\n### Validate the built LLVM against current source and patches\n\nEven when `build/libs/` exists and the submodule hash matches, the built LLVM might\nbe stale — the patches could have changed, or the build could be left over from a\ndifferent branch. Run `lib/build-libs.cmake` to validate everything. It checks both\n`LLVM_DESIRED_HASH` and `PATCHES_DESIRED_HASH` internally, resets the submodule\nsource tree, applies the current patches, and does an incremental build. If the source\nand patches haven't changed, the build step is a no-op (minutes, not hours).\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\ncmake -DJOBS=2 -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ -P lib/build-libs.cmake 2>&1\nENDSSH\n```\n\nThree outcomes:\n\n- **Completes with no errors** → LLVM is current, proceed to step 4.\n- **Fails with a hash mismatch** (`Patch hash actual '...' does not match\n  desired '...'`) → the patches changed since the last build. Do a full rebuild\n  (see below).\n- **Fails with a submodule hash error** → the submodule is wrong. Re-run\n  `git submodule update --init lib/llvm/src` in step 2 and try again.\n\nIf this step takes more than a few minutes, it is doing a real rebuild — detach it\n(see \"Full rebuild\" below) and poll.\n\n### Full rebuild\n\nWhen a clean LLVM build is needed (no `build/libs/`, or a hash mismatch), remove\nthe old build artifacts and rebuild from scratch. This takes 8+ hours on the RPi.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-libs-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\nrm -rf build/libs build/build_libs\ncmake -DJOBS=2 -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ -P lib/build-libs.cmake > /home/pi/libs-build.log 2>&1\necho \"LIBS_BUILD_EXIT_CODE=$?\" >> /home/pi/libs-build.log\nSCRIPT\nchmod +x /home/pi/run-libs-build.sh\nnohup /home/pi/run-libs-build.sh > /dev/null 2>&1 &\necho \"libs build started pid $!\"\nENDSSH\n```\n\nPoll until done:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -3 /home/pi/libs-build.log\"'\n```\n\nWait for `LIBS_BUILD_EXIT_CODE=0`. A nonzero exit code means the build failed —\nread the log above the exit code line for the error.\n\n## Test matrix\n\nTwo runtime builds (debug and release), each running the tests twice: once with\n`PONY_DEBUG=1` (debug codegen) and once without (release codegen). The env var\ncontrols whether ponyc compiles Pony sources with `-d`.\n\nEach runtime build runs these tests in both codegen modes:\n\n**ci-core minus stdlib** (run via ctest, excludes the monolithic stdlib test):\n\n- **check-version** — ponyc `--version` exits successfully\n- **libponyc.tests** — compiler C/C++ unit tests (GTest)\n- **libponyrt.tests** — runtime C/C++ unit tests (GTest)\n- **full-programs** — compile-and-run integration tests\n- **full-program-runner-rejects-broken-config** — verifies the test runner rejects bad config\n- **validate-grammar** — `pony.g` validated against the compiler\n\n**stdlib per-package** (run via ponyc directly, one package at a time):\n\nEach stdlib package is compiled and tested individually to stay within the\nILP32 address space limit. The full list of testable packages is derived from\n`packages/stdlib/_test.pony`.\n\n**tools** (all 5 tool test suites):\n\n- **pony-compiler-tests** — self-hosted compiler tool tests\n- **pony-dep-tests** — dependency tool tests\n- **pony-doc-tests** — documentation tool tests\n- **pony-lint-tests** — linter tool tests\n- **pony-lsp-tests** — language server tool tests\n\n## Step 4 — build and test with debug runtime\n\n### 4a. Configure\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\nrm -rf build/build_debug\ncmake --preset debug \\\n  -DCMAKE_C_COMPILER=gcc \\\n  -DCMAKE_CXX_COMPILER=g++ \\\n  \"-DCMAKE_C_FLAGS=-march=native\" \\\n  \"-DCMAKE_CXX_FLAGS=-march=native\"\nENDSSH\n```\n\nVerify \"Configuring done\" and \"Build files have been written\" in the output.\n\n### 4b. Build ponyc (detached)\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset debug > /home/pi/debug-build.log 2>&1\necho \"DEBUG_BUILD_EXIT_CODE=$?\" >> /home/pi/debug-build.log\nSCRIPT\nchmod +x /home/pi/run-debug-build.sh\nnohup /home/pi/run-debug-build.sh > /dev/null 2>&1 &\necho \"debug build started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -3 /home/pi/debug-build.log\"'\n```\n\nWait for `DEBUG_BUILD_EXIT_CODE=0`. Then verify all binaries built:\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc/build/debug\nfor bin in ponyc pony-compiler pony-dep pony-doc pony-lint pony-lsp; do\n  test -x \"$bin\" && echo \"$bin: OK\" || echo \"$bin: MISSING\"\ndone\nENDSSH\n```\n\nAll six must show OK.\n\n### 4c. Run stdlib tests per-package (debug runtime)\n\nDetect the SSL flag, then compile and run each testable package individually\nin both codegen modes. Run detached — this iterates over ~30 packages.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-stdlib-perpkg.sh << 'SCRIPT'\n#!/bin/bash\nset -u\ncd /home/pi/code/ponylang/ponyc\n\nPONYC=./build/debug/ponyc\nOUTDIR=./build/debug\nPKGDIR=./packages\n\n# Detect OpenSSL version for the SSL flag\nssl_ver=$(openssl version 2>/dev/null | head -1)\ncase \"$ssl_ver\" in\n  OpenSSL\\ 3.*) SSL_FLAG=-Dopenssl_3.0.x ;;\n  OpenSSL\\ 1.1.*) SSL_FLAG=-Dopenssl_1.1.x ;;\n  LibreSSL*) SSL_FLAG=-Dlibressl ;;\n  *) echo \"ERROR: cannot detect OpenSSL version: $ssl_ver\" >&2; exit 1 ;;\nesac\necho \"Detected SSL: $ssl_ver -> $SSL_FLAG\"\n\n# Packages with tests, from packages/stdlib/_test.pony\nPACKAGES=(\n  actor_pinning\n  encode/base64\n  buffered\n  builtin_test\n  bureaucracy\n  cli\n  collections\n  collections/persistent\n  constrained_types\n  crypto\n  files\n  format\n  http_client\n  ini\n  iregex\n  itertools\n  json\n  math\n  net\n  pony_test\n  process\n  promises\n  random\n  runtime_info\n  signals\n  strings\n  term\n  time\n  uri\n)\n\nLOGFILE=/home/pi/debug-stdlib-perpkg.log\n> \"$LOGFILE\"\noverall_rc=0\n\nfor codegen in debug release; do\n  echo \"=== $codegen codegen ===\" >> \"$LOGFILE\"\n  for pkg in \"${PACKAGES[@]}\"; do\n    name=$(echo \"$pkg\" | tr '/' '_')\n    debug_flag=\"\"\n    if [ \"$codegen\" = \"debug\" ]; then\n      debug_flag=\"-d\"\n    fi\n\n    echo -n \"  $pkg ($codegen): \" >> \"$LOGFILE\"\n\n    # Compile\n    $PONYC $debug_flag -b stdlib_test --checktree $SSL_FLAG --pic \\\n      \"$PKGDIR/$pkg\" -o \"$OUTDIR\" >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"COMPILE FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    # Run\n    \"$OUTDIR/stdlib_test\" --sequential >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"TEST FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    echo \"PASS\" >> \"$LOGFILE\"\n    rm -f \"$OUTDIR/stdlib_test\"\n  done\ndone\n\necho \"DEBUG_STDLIB_PERPKG_EXIT_CODE=$overall_rc\" >> \"$LOGFILE\"\nSCRIPT\nchmod +x /home/pi/run-debug-stdlib-perpkg.sh\nnohup /home/pi/run-debug-stdlib-perpkg.sh > /dev/null 2>&1 &\necho \"debug per-package stdlib tests started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -5 /home/pi/debug-stdlib-perpkg.log\"'\n```\n\nWait for `DEBUG_STDLIB_PERPKG_EXIT_CODE=0`.\n\n### 4d. Run ci-core tests minus stdlib (debug runtime)\n\nRun the ci-core tests excluding the monolithic stdlib, in both codegen modes.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-ci-core.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\necho \"=== debug codegen ===\" > /home/pi/debug-ci-core.log\nPONY_DEBUG=1 ctest --preset debug -L ci-core -E \"^stdlib$\" >> /home/pi/debug-ci-core.log 2>&1\nrc1=$?\necho \"=== release codegen ===\" >> /home/pi/debug-ci-core.log\nctest --preset debug -R \"^full-programs$\" >> /home/pi/debug-ci-core.log 2>&1\nrc2=$?\n\nif [ $rc1 -eq 0 ] && [ $rc2 -eq 0 ]; then\n  echo \"DEBUG_CI_CORE_EXIT_CODE=0\" >> /home/pi/debug-ci-core.log\nelse\n  echo \"DEBUG_CI_CORE_EXIT_CODE=1 (debug_codegen=$rc1 release_codegen=$rc2)\" >> /home/pi/debug-ci-core.log\nfi\nSCRIPT\nchmod +x /home/pi/run-debug-ci-core.sh\nnohup /home/pi/run-debug-ci-core.sh > /dev/null 2>&1 &\necho \"ci-core tests started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -5 /home/pi/debug-ci-core.log\"'\n```\n\nWait for `DEBUG_CI_CORE_EXIT_CODE=0`.\n\n### 4e. Build tool test binaries (after ponyc build completes)\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-tool-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset debug --target tool-tests > /home/pi/debug-tool-build.log 2>&1\necho \"TOOL_BUILD_EXIT_CODE=$?\" >> /home/pi/debug-tool-build.log\nSCRIPT\nchmod +x /home/pi/run-debug-tool-build.sh\nnohup /home/pi/run-debug-tool-build.sh > /dev/null 2>&1 &\necho \"tool build started pid $!\"\nENDSSH\n```\n\nPoll and wait for `TOOL_BUILD_EXIT_CODE=0`.\n\n### 4f. Run tool tests (debug runtime)\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-tools.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\nrc=0\nctest --preset debug -L tools >> /home/pi/debug-tools.log 2>&1\nrc=$?\necho \"DEBUG_TOOLS_EXIT_CODE=$rc\" >> /home/pi/debug-tools.log\nSCRIPT\nchmod +x /home/pi/run-debug-tools.sh\nnohup /home/pi/run-debug-tools.sh > /dev/null 2>&1 &\necho \"tool tests started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -5 /home/pi/debug-tools.log\"'\n```\n\nWait for `DEBUG_TOOLS_EXIT_CODE=0`.\n\n## Step 5 — build and test with release runtime\n\nRepeat the full test matrix with a release-built runtime and compiler.\n\n### 5a. Configure\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\nrm -rf build/build_release\ncmake --preset release \\\n  -DCMAKE_C_COMPILER=gcc \\\n  -DCMAKE_CXX_COMPILER=g++ \\\n  \"-DCMAKE_C_FLAGS=-march=native\" \\\n  \"-DCMAKE_CXX_FLAGS=-march=native\"\nENDSSH\n```\n\n### 5b. Build ponyc (detached)\n\nSame as step 4b but using the release preset.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset release > /home/pi/release-build.log 2>&1\necho \"RELEASE_BUILD_EXIT_CODE=$?\" >> /home/pi/release-build.log\nSCRIPT\nchmod +x /home/pi/run-release-build.sh\nnohup /home/pi/run-release-build.sh > /dev/null 2>&1 &\necho \"release build started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_BUILD_EXIT_CODE=0`. Verify binaries as in 4b\n(using `build/release` instead of `build/debug`).\n\n### 5c. Run stdlib tests per-package (release runtime)\n\nSame as step 4c but using the release ponyc.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-stdlib-perpkg.sh << 'SCRIPT'\n#!/bin/bash\nset -u\ncd /home/pi/code/ponylang/ponyc\n\nPONYC=./build/release/ponyc\nOUTDIR=./build/release\nPKGDIR=./packages\n\n# Detect OpenSSL version for the SSL flag\nssl_ver=$(openssl version 2>/dev/null | head -1)\ncase \"$ssl_ver\" in\n  OpenSSL\\ 3.*) SSL_FLAG=-Dopenssl_3.0.x ;;\n  OpenSSL\\ 1.1.*) SSL_FLAG=-Dopenssl_1.1.x ;;\n  LibreSSL*) SSL_FLAG=-Dlibressl ;;\n  *) echo \"ERROR: cannot detect OpenSSL version: $ssl_ver\" >&2; exit 1 ;;\nesac\necho \"Detected SSL: $ssl_ver -> $SSL_FLAG\"\n\nPACKAGES=(\n  actor_pinning\n  encode/base64\n  buffered\n  builtin_test\n  bureaucracy\n  cli\n  collections\n  collections/persistent\n  constrained_types\n  crypto\n  files\n  format\n  http_client\n  ini\n  iregex\n  itertools\n  json\n  math\n  net\n  pony_test\n  process\n  promises\n  random\n  runtime_info\n  signals\n  strings\n  term\n  time\n  uri\n)\n\nLOGFILE=/home/pi/release-stdlib-perpkg.log\n> \"$LOGFILE\"\noverall_rc=0\n\nfor codegen in debug release; do\n  echo \"=== $codegen codegen ===\" >> \"$LOGFILE\"\n  for pkg in \"${PACKAGES[@]}\"; do\n    name=$(echo \"$pkg\" | tr '/' '_')\n    debug_flag=\"\"\n    if [ \"$codegen\" = \"debug\" ]; then\n      debug_flag=\"-d\"\n    fi\n\n    echo -n \"  $pkg ($codegen): \" >> \"$LOGFILE\"\n\n    $PONYC $debug_flag -b stdlib_test --checktree $SSL_FLAG --pic \\\n      \"$PKGDIR/$pkg\" -o \"$OUTDIR\" >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"COMPILE FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    \"$OUTDIR/stdlib_test\" --sequential >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"TEST FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    echo \"PASS\" >> \"$LOGFILE\"\n    rm -f \"$OUTDIR/stdlib_test\"\n  done\ndone\n\necho \"RELEASE_STDLIB_PERPKG_EXIT_CODE=$overall_rc\" >> \"$LOGFILE\"\nSCRIPT\nchmod +x /home/pi/run-release-stdlib-perpkg.sh\nnohup /home/pi/run-release-stdlib-perpkg.sh > /dev/null 2>&1 &\necho \"release per-package stdlib tests started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_STDLIB_PERPKG_EXIT_CODE=0`.\n\n### 5d. Run ci-core tests minus stdlib (release runtime)\n\nSame as step 4d but using the release preset.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-ci-core.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\necho \"=== debug codegen ===\" > /home/pi/release-ci-core.log\nPONY_DEBUG=1 ctest --preset release -L ci-core -E \"^stdlib$\" >> /home/pi/release-ci-core.log 2>&1\nrc1=$?\necho \"=== release codegen ===\" >> /home/pi/release-ci-core.log\nctest --preset release -R \"^full-programs$\" >> /home/pi/release-ci-core.log 2>&1\nrc2=$?\n\nif [ $rc1 -eq 0 ] && [ $rc2 -eq 0 ]; then\n  echo \"RELEASE_CI_CORE_EXIT_CODE=0\" >> /home/pi/release-ci-core.log\nelse\n  echo \"RELEASE_CI_CORE_EXIT_CODE=1 (debug_codegen=$rc1 release_codegen=$rc2)\" >> /home/pi/release-ci-core.log\nfi\nSCRIPT\nchmod +x /home/pi/run-release-ci-core.sh\nnohup /home/pi/run-release-ci-core.sh > /dev/null 2>&1 &\necho \"ci-core tests started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_CI_CORE_EXIT_CODE=0`.\n\n### 5e. Build and run tool tests (release runtime)\n\nSame as steps 4e/4f but using the release preset.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-tool-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset release --target tool-tests > /home/pi/release-tool-build.log 2>&1\necho \"TOOL_BUILD_EXIT_CODE=$?\" >> /home/pi/release-tool-build.log\nSCRIPT\nchmod +x /home/pi/run-release-tool-build.sh\nnohup /home/pi/run-release-tool-build.sh > /dev/null 2>&1 &\necho \"tool build started pid $!\"\nENDSSH\n```\n\nPoll and wait for `TOOL_BUILD_EXIT_CODE=0`.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-tools.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\nctest --preset release -L tools > /home/pi/release-tools.log 2>&1\nrc=$?\necho \"RELEASE_TOOLS_EXIT_CODE=$rc\" >> /home/pi/release-tools.log\nSCRIPT\nchmod +x /home/pi/run-release-tools.sh\nnohup /home/pi/run-release-tools.sh > /dev/null 2>&1 &\necho \"tool tests started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_TOOLS_EXIT_CODE=0`.\n\n## Step 6 — report results\n\nSummarize which steps passed and which failed. Report each result individually:\n\n- LLVM version and whether it needed rebuilding\n- Debug runtime build: pass/fail (ponyc, pony-compiler, pony-dep, pony-doc,\n  pony-lint, pony-lsp built)\n- Debug runtime — ci-core minus stdlib (debug codegen): pass/fail\n- Debug runtime — ci-core minus stdlib (release codegen): pass/fail\n- Debug runtime — stdlib per-package (debug codegen): pass/fail per package\n- Debug runtime — stdlib per-package (release codegen): pass/fail per package\n- Debug runtime — tool tests: pass/fail\n- Release runtime build: pass/fail\n- Release runtime — ci-core minus stdlib (debug codegen): pass/fail\n- Release runtime — ci-core minus stdlib (release codegen): pass/fail\n- Release runtime — stdlib per-package (debug codegen): pass/fail per package\n- Release runtime — stdlib per-package (release codegen): pass/fail per package\n- Release runtime — tool tests: pass/fail\n\n## Cleanup\n\nKill any orphaned build processes and remove log files when done:\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\npkill -f \"cmake --build\" 2>/dev/null\npkill -f \"ctest --preset\" 2>/dev/null\npkill -f \"run-.*\\.sh\" 2>/dev/null\nrm -f /home/pi/run-*.sh /home/pi/*-build.log /home/pi/*-tool-build.log /home/pi/*-ci-core.log /home/pi/*-tools.log /home/pi/*-stdlib-perpkg.log\nENDSSH\n```\n\nDo not remove the `build/` directory — it holds the built LLVM and ponyc, which\npersist for the next validation run.\n",".claude/skills/add-linux-release-target/SKILL.md":"---\nname: add-linux-release-target\ndescription: Load when adding a new Linux distro/version (e.g. Ubuntu 26.04, Alpine 3.24) as a ponyc release target. Covers builder Dockerfile creation, image build dispatch and tag discovery, workflow updates, docs, release notes, and the post-merge Last Week in Pony announcement.\ndisable-model-invocation: false\n---\n\n# Adding a New Linux Distro/Version as a ponyc Release Target\n\n## Scope\n\nUse this skill when adding a new distro/version (e.g. Ubuntu 26.04) so ponyc produces release and nightly artifacts users can install via ponyup.\n\nThe procedure encodes the **multi-platform builder pattern** — a single `.ci-dockerfiles/<distro><version>-builder/` directory containing one Dockerfile and one `build-and-push.bash` that builds and publishes a multi-arch image (linux/amd64 + linux/arm64) under `ghcr.io/ponylang/ponyc-ci-<distro><version>-builder`. This is the pattern used by Alpine 3.23+. New release-target adds — including new Ubuntu versions — should use it. Older builders still use a per-arch pattern (separate `x86-64-unknown-linux-...-builder/` and `arm64-unknown-linux-...-builder/` directories); leave those alone, don't migrate them as part of an add.\n\n**Out of scope:**\n- CI test-only workflows: `pr.yml`, `ponyc-tier2.yml`, `ponyc-weekly-checks.yml`, `stress-test-*.yml`. These reference Linux images for testing ponyc itself, not for producing release artifacts. Don't touch them.\n- `pr-docker-image-validation.yml` validates the user-facing `.dockerfiles/{nightly,release}/Dockerfile` images (Alpine-based). Not affected by adding a release target.\n- Dropping older releases. We support only the two newest releases of a distro, so adding a newer one eventually means dropping the now-oldest — but that drop is a separate, by-hand procedure, not covered here.\n- Migrating an existing per-arch builder to the multi-platform pattern. Different procedure, not covered here.\n\n## Guiding facts\n\n- **Adds only; the policy is newest-two.** This skill only adds a target. Our policy is to support the two newest releases of a distro, so adding a newer release usually pairs with dropping the now-oldest — but that drop is a separate, by-hand procedure (not covered here). Don't drop anything as part of this work.\n- **Both architectures by default.** Multi-platform images build linux/amd64 and linux/arm64 from one Dockerfile. Don't restrict arches without a concrete reason.\n- **GHCR builder packages are repo-scoped.** No manual \"make public\" step is needed for new packages — same-repo CI pulls them automatically.\n- **Mental model.** One multi-arch builder image gets pushed to GHCR with a date-stamp tag. Workflow files (`release.yml`, `nightlies.yml`, `update-lib-cache.yml`) reference that one image by exact tag from both their x86_64-linux and arm64-linux matrices — Docker pulls the matching arch on each runner. Adding a new release target = create the Dockerfile, build/push the image, then thread the resulting tag into every workflow that consumes it, plus update the user-facing docs.\n\n## File inventory\n\nNew files (a single builder directory with two files):\n- `.ci-dockerfiles/<distro><version>-builder/Dockerfile`\n- `.ci-dockerfiles/<distro><version>-builder/build-and-push.bash`\n\nExisting files to edit:\n- `.github/workflows/build-builder-image.yml` — one dropdown option + one job block\n- `.github/workflows/release.yml` — `x86_64-linux` and `arm64-linux` matrices\n- `.github/workflows/nightlies.yml` — same matrices\n- `.github/workflows/update-lib-cache.yml` — same matrices (simpler shape: just `image:`)\n- `INSTALL.md` — supported-distro list + PLATFORM string table\n- `RELEASE_PROCESS.md` — package names list\n\nNew release notes file:\n- `.release-notes/add-<distro>-<version>.md`\n\n`BUILD.md` and `README.md` need no changes — neither contains version-specific content for this kind of add.\n\n## Before you begin\n\n- **Branch base:** start from a fresh `main`. `git checkout main && git pull`.\n- **Tools:** Docker daemon running (`docker info` succeeds), `gh` authenticated, `git` configured to push to origin.\n- **`gh` token scopes needed:** `workflow` (for dispatching builds) and `read:packages` (for querying GHCR tags). Check with `gh auth status`. If missing: `gh auth refresh -s workflow,read:packages`.\n- **Placeholders in this document:** every `<distro>`, `<version>`, `<tag>`, etc. is a placeholder for substitution. The angle brackets are not part of any command. Example for Ubuntu 26.04: `<distro>` → `ubuntu`, `<version>` → `26.04`.\n\n## Procedure\n\nThe **structural exemplar** — directory layout, `build-and-push.bash`, `build-builder-image.yml` job block, workflow matrix entries — is the most recent Alpine multi-platform builder. As of writing, that's `alpine3.24-builder/`. Before starting, glance at `.ci-dockerfiles/` and pick whichever Alpine multi-platform builder dir is newest (`alpineX.Y-builder/`, no arch prefix); use it in place of `alpine3.24-builder` throughout this document.\n\nThe **Dockerfile-content exemplar** is the most recent same-distro builder, regardless of pattern (per-arch is fine — only the package list and base image are reused). For Ubuntu adds at the time this skill was written, that's `x86-64-unknown-linux-ubuntu24.04-builder/Dockerfile`.\n\n### 1. Branch\n\n```bash\ngit checkout main && git pull\ngit checkout -b add-<distro>-<version>-release-target\n```\n\n### 2. Create the builder Dockerfile and build-and-push script\n\n```bash\nmkdir -p .ci-dockerfiles/<distro><version>-builder\n```\n\n**Dockerfile.** Copy the body from the most recent same-distro builder's Dockerfile (for Ubuntu, the per-arch x86-64 and arm64 Ubuntu 24.04 Dockerfiles are byte-identical, so either is fine), change the `FROM` line to the new version. The base image (e.g. `ubuntu:26.04`) must be a multi-arch manifest — it already is for the official `ubuntu` and `alpine` images. If adding a different distro, verify with:\n\n```bash\ndocker manifest inspect <distro>:<version> | jq '.manifests[].platform'\n```\n\nBoth `linux/amd64` and `linux/arm64` must appear.\n\n**build-and-push.bash.** Copy from the structural-exemplar multi-platform builder (e.g. `alpine3.24-builder/build-and-push.bash`) and change only the `NAME` and the `BUILDER` prefix. The shape:\n\n```bash\n#!/bin/bash\n\nset -o errexit\nset -o nounset\n\n#\n# *** You should already be logged in to GHCR when you run this ***\n#\n\nNAME=\"ghcr.io/ponylang/ponyc-ci-<distro><version>-builder\"\nTODAY=$(date +%Y%m%d)\nDOCKERFILE_DIR=\"$(dirname \"$0\")\"\nBUILDER=\"<distro><version>-builder-$(date +%s)\"\n\ndocker buildx create --use --name \"${BUILDER}\"\ndocker buildx build --provenance false --sbom false --platform linux/arm64,linux/amd64 --pull --push -t \"${NAME}:${TODAY}\" \"${DOCKERFILE_DIR}\"\ndocker buildx rm \"${BUILDER}\"\n```\n\nDon't modify the package list, the `LABEL`, the user setup, or anything else without a concrete reason. If a package name has changed in the new distro version, the local smoke test in step 3 will surface it — don't pre-emptively edit.\n\n### 3. Local smoke test\n\nBefore pushing or dispatching anything, build the Dockerfile locally for amd64 explicitly:\n\n```bash\ndocker build --pull --platform linux/amd64 .ci-dockerfiles/<distro><version>-builder/\n```\n\n`--platform linux/amd64` makes the smoke test deterministic regardless of host architecture (matters on Apple Silicon, where host-default would be `linux/arm64` and use a different package set). `--pull` forces Docker to fetch the latest base image rather than use a stale local cache. This is a smoke test only — don't run `build-and-push.bash` locally (that would push to GHCR with today's date stamp).\n\nIf the build fails because Docker isn't running or the package manager can't reach the network, that's an environment problem — fix it and retry, don't escalate. If the build fails inside the image (package gone, repo URL invalid for the new distro, base image not yet published, etc.): **STOP**. Surface the error to the human. Don't retry. Don't \"fix\" by tweaking the package list without confirmation — that's a design discussion.\n\nThe remote multi-platform build in step 6 cross-builds arm64 from x86-64 via QEMU. Local x86-64 success is a strong signal but not a guarantee for arm64; the GHA dispatch is the real arm64 verification.\n\n### 4. Update `build-builder-image.yml`\n\nTwo edits, both mechanical:\n\n**a. Add one dropdown option** (alphabetical across the whole list):\n\n```yaml\noptions:\n  ...\n  - <distro><version>-builder\n  ...\n```\n\n**b. Add one job block** by copying the structural-exemplar job (e.g. `alpine3_24-builder`) and substituting the version. Place the new job alphabetically among the sibling jobs.\n\nThe job map key replaces `.` with `_` (YAML map keys can't contain `.`): a version of `26.04` becomes `26_04` in the job key only — the `if:` filter value, the `name:` field, and the `bash` script path all keep the dot form. Reference shape:\n\n```yaml\n<distro><version_underscore>-builder:\n  if: ${{ github.event.inputs.builder-name == '<distro><version>-builder' }}\n  runs-on: ubuntu-latest\n\n  name: <distro><version>-builder\n  steps:\n    - name: Checkout\n      uses: actions/checkout@v6.0.2\n    - name: Set up Docker Buildx\n      uses: docker/setup-buildx-action@v4\n      with:\n        version: v0.23.0\n    - name: Login to GitHub Container Registry\n      uses: docker/login-action@v4\n      with:\n        registry: ghcr.io\n        username: ${{ github.repository_owner }}\n        password: ${{ secrets.GITHUB_TOKEN }}\n    - name: Build and push\n      run: bash .ci-dockerfiles/<distro><version>-builder/build-and-push.bash\n```\n\n`runs-on: ubuntu-latest` is correct — the multi-platform build cross-compiles arm64 via QEMU on an x86-64 runner. Don't change it.\n\n### 5. Push branch (WIP commit is fine)\n\n```bash\ngit add .ci-dockerfiles .github/workflows/build-builder-image.yml\ngit commit -m \"WIP: add <distro> <version> builder\"\ngit push -u origin add-<distro>-<version>-release-target\n```\n\n### 6. Dispatch the builder workflow from the branch\n\nGHA `workflow_dispatch` runs the workflow file from the chosen ref, so the new dropdown option becomes available when targeting the branch.\n\n```bash\ngh workflow run build-builder-image.yml \\\n  --ref add-<distro>-<version>-release-target \\\n  -f builder-name=<distro><version>-builder\n```\n\nIf dispatch returns 403, the `gh` token lacks `workflow` scope — see \"Before you begin.\"\n\n### 7. Wait for the build to finish\n\nThe multi-platform build takes ~20–40 minutes (arm64 via QEMU is the slow leg). Capture the run id, then watch it:\n\n```bash\nRUN_ID=$(gh run list --workflow=build-builder-image.yml \\\n  --branch=add-<distro>-<version>-release-target \\\n  --limit=1 --json databaseId --jq '.[].databaseId')\n\ngh run watch \"$RUN_ID\" --exit-status\n```\n\nIf the build fails: **STOP**. Read the failed logs with `gh run view \"$RUN_ID\" --log-failed` and surface the failure to the human. A common cause is a package or repo change for the new distro — that's a discussion, not something to silently work around.\n\n### 8. Discover the published tag\n\nThe successful build pushes `ghcr.io/ponylang/ponyc-ci-<distro><version>-builder:YYYYMMDD`. Query GHCR:\n\n```bash\ngh api -X GET /orgs/ponylang/packages/container/ponyc-ci-<distro><version>-builder/versions \\\n  --jq '[.[].metadata.container.tags[]] | map(select(test(\"^[0-9]{8}$\"))) | max'\n```\n\nThe `select(test(\"^[0-9]{8}$\"))` filter restricts to date-form tags (rejects `latest` or other future tags); `max` picks the most recent.\n\nIf the API returns 403, the `gh` token lacks `read:packages` — see \"Before you begin.\"\n\n### 9. Apply the tag to workflow files\n\nAdd new matrix entries alongside the existing entries. The same image string goes into both the `x86_64-linux` and `arm64-linux` matrices — Docker pulls the matching arch on each runner.\n\n**Use the structural-exemplar entry (Alpine 3.24) from the same file/matrix as your template.** Existing same-distro entries are inconsistent (per-arch builders use different image-name shapes; some files have `triple-vendor: unknown` on arm64, others omit it). Avoid that ambiguity: copy the alpine3.24 entry from the file/matrix you're editing, then substitute `image:`, `name:`, and `triple-os:` with the new distro/version values. Keep all other fields (including `triple-vendor`'s presence or absence) exactly as alpine3.24 has them in that location.\n\nFiles and matrices to edit:\n- `.github/workflows/release.yml` — `x86_64-linux` and `arm64-linux` matrices.\n- `.github/workflows/nightlies.yml` — `x86_64-linux` and `arm64-linux` matrices.\n- `.github/workflows/update-lib-cache.yml` — `x86_64-linux` and `arm64-linux` matrices (simpler shape: just `image:`).\n\nWorked example for Ubuntu 26.04, x86_64-linux matrices in `release.yml` and `nightlies.yml`:\n\n```yaml\n- image: ghcr.io/ponylang/ponyc-ci-ubuntu26.04-builder:<tag>\n  name: x86-64-unknown-linux-ubuntu26.04\n  triple-os: linux-ubuntu26.04\n  triple-vendor: unknown\n```\n\nThe arm64-linux matrix entry in those files uses the **same** `image:` string but `name: arm64-unknown-linux-ubuntu26.04`. Because the structural-exemplar (Alpine 3.24) arm64-linux entries omit `triple-vendor`, this entry omits it too.\n\n(In `release.yml` the arm64-linux job uses `docker run` directly rather than a `container:` job; ignore the surrounding job structure — the matrix entry shape is what matters.)\n\n### 10. Update docs\n\n`INSTALL.md`:\n- \"Supported Glibc distributions\" list (only relevant for distros listed there; Alpine adds use a different section): extend the existing comma-separated Ubuntu line in place. Add the new version to that line, don't add a new bullet.\n- If a corresponding Pop!_OS or Linux Mint release based on this Ubuntu version exists at this time, add it to its line and to the PLATFORM string table too. If it doesn't exist yet, skip — those rows can be added in a later PR when they ship.\n- PLATFORM string table: add two rows (amd64 and arm64). The existing rows for arm64 Ubuntu use `arm-linux-` (not `arm64-linux-`) — match that historical convention; don't \"fix\" it. PLATFORM strings (in INSTALL.md) and package names (in RELEASE_PROCESS.md) intentionally use different arch prefixes — match existing rows in each file rather than imposing consistency between them.\n\n`RELEASE_PROCESS.md`:\n- \"Package names will be:\" list: add two lines (alphabetical). Artifact naming always uses the arch-prefixed form, regardless of how the builder image is named:\n  - `* ponyc-arm64-unknown-linux-<distro><version>.tar.gz`\n  - `* ponyc-x86-64-unknown-linux-<distro><version>.tar.gz`\n\n### 11. Add release note\n\nCreate `.release-notes/add-<distro>-<version>.md`:\n\n```markdown\n## <Distro> <Version> added as a supported platform\n\nWe've added arm64 and amd64 builds for <Distro> <Version>. We build ponyc releases for the two newest <Distro> releases, so we'll keep building for <Version> until two newer <Distro> releases have shipped, at which point we'll stop.\n```\n\nThis combined-arch form matches the precedent for fresh adds (e.g. `.release-notes/0.60.4.md`). The per-arch form (\"X on arm64 added as a supported platform\") is for the historical case of adding an arch to an already-supported version — not what this skill covers.\n\nAfter this step, sanity-check coverage:\n\n```bash\ngit diff --stat main\ngrep -rn \"<distro><version>\" .github/workflows/ INSTALL.md RELEASE_PROCESS.md .ci-dockerfiles/ .release-notes/\n```\n\n`git diff --stat` should show changes only in the files listed in the \"File inventory\" section above (plus the two new Dockerfile/script files and the new release-notes file) — investigate any unexpected location. The `grep` confirms the placeholder was substituted; note that `build-builder-image.yml` job-key `<distro><version_underscore>` (e.g. `ubuntu26_04`) won't match the dotted form, so check that file separately.\n\n### 12. Squash, push, open PR\n\nThe PR title must exactly match the release-note title from step 11 (`<Distro> <Version> added as a supported platform`) — both end up as public-facing CHANGELOG content, so they must agree. The commit message matches too (it becomes the squash-merge commit subject).\n\nThe PR also needs the `changelog - added` label so release-notes aggregation picks up the entry under the right CHANGELOG section.\n\n```bash\ngit fetch origin main\ngit reset --soft origin/main\ngit add -A\ngit commit -m \"<Distro> <Version> added as a supported platform\"\ngit push --force-with-lease\n\nPR_URL=$(gh pr create \\\n  --title \"<Distro> <Version> added as a supported platform\" \\\n  --label \"changelog - added\" \\\n  --body \"$(cat <<'EOF'\nAdds <Distro> <Version> as a supported release target. Builder image, release/nightly/lib-cache workflow entries, INSTALL/RELEASE_PROCESS docs, and a release note are all included.\nEOF\n)\")\nPR_NUM=${PR_URL##*/}\n```\n\n### 13. Wait for CI, then squash-merge\n\nBlock until all PR checks complete, then merge if they pass:\n\n```bash\ngh pr checks \"$PR_NUM\" --watch --exit-status\ngh pr merge \"$PR_NUM\" --squash --delete-branch\ngit checkout main && git pull\n```\n\n`--watch --exit-status` blocks until checks finish and exits non-zero if any failed. If checks fail: **STOP**. Read the failure with `gh pr checks \"$PR_NUM\"` to see which check, drill into its logs, and surface to the human. Don't retry the merge.\n\n`--squash --delete-branch` matches the ponylang merge convention (repos under the ponylang and seantallen-org GitHub orgs only allow squash merges) and removes both the remote and local branch. The `git checkout main && git pull` afterward returns you to an up-to-date main.\n\n### 14. Post-merge: Last Week in Pony comment\n\nAfter the PR merges, find the open LWIP issue and post a comment.\n\n```bash\nLWIP=$(gh issue list -R ponylang/ponylang-website \\\n  --label last-week-in-pony --state open --limit 1 --json number --jq '.[0].number // empty')\n\nif [ -z \"$LWIP\" ]; then\n  echo \"No open LWIP issue. Wait until the next one is created (typically within a week) and post then. Don't open a new LWIP issue yourself.\"\nelse\n  gh issue comment \"$LWIP\" -R ponylang/ponylang-website --body \"$(cat <<'EOF'\n<Distro> <Version> has been added as a supported platform for ponyc. Prebuilt binaries are available via [ponyup](https://github.com/ponylang/ponyup). We build releases for the two newest <Distro> releases, so we'll keep building for <Version> until two newer <Distro> releases ship.\nEOF\n)\"\nfi\n```\n\nThe comment substance matches the release note: announcing the new platform and our policy of supporting the two newest releases. Format is short, single-paragraph prose matching existing LWIP comments.\n\nAfter this step, the add-a-release-target work is complete.\n\n## Failure modes — when to stop and ask\n\n- **Local Dockerfile build fails inside the image.** Package gone, repo URL invalid, base image not yet published. Don't tweak the package list or \"fix forward\" without confirmation.\n- **GHA builder dispatch fails.** Read run logs with `gh run view <id> --log-failed`. Most causes are the same as above.\n- **GHCR tag query returns no result.** Build may not have pushed (auth failure, network error). Check the workflow run logs.\n- **arm64 build fails but amd64 succeeds (or vice versa).** Multi-platform `docker buildx` produces a single manifest covering both arches; if either platform fails, the whole build fails and nothing gets pushed. Triage from the failed-platform logs.\n- **PR CI checks fail.** Don't retry the merge. Read the failed check's logs, surface to the human. Possible causes range from a flaky test (rare on a docs/config-only PR like this) to a real interaction with workflows you didn't expect to touch.\n\n## Anti-patterns\n\n- **Don't conflate this with CI test workflows.** PR validation, tier 2, weekly checks, and stress tests are separate concerns. They may also need updating for new distros, but not as part of \"add a release target.\"\n- **Don't migrate existing per-arch builders to multi-platform as a side effect.** That's a separate change.\n- **Don't skip the smoke test to save time.** A failed GHA multi-platform build is a 30-minute round-trip plus the human-attention cost of triaging it. A failed local build is 30 seconds.\n- **Don't run `build-and-push.bash` locally.** It pushes. The smoke test is `docker build` only.\n",".claude/skills/create-dragonfly-dev-env/SKILL.md":"---\nname: create-dragonfly-dev-env\ndescription: Load when you need to reproduce or validate a gcc-specific or DragonFly-specific ponyc issue on a local VM, or to stand up a local DragonFly BSD development VM that matches ponyc tier-3 CI. Covers the prerequisite check, the sudo-free QEMU/KVM setup, the reused console automation, the in-VM gcc13 build env, and the gotchas (csh, screendump paths, boot timing, detached builds, per-block shells) that make hand-rolling one error-prone.\ndisable-model-invocation: false\n---\n\n# Create a DragonFly BSD dev/test VM for ponyc\n\nStand up a local DragonFly BSD VM that matches the ponyc tier-3 CI job, so you can\nbuild and test ponyc on DragonFly without GitHub Actions.\n\nDragonFly only builds ponyc with gcc (the `gcc13` package), so it is the authoritative\nlocal environment for **gcc-specific** ponyc issues — the ones clang-based CI never\nsees. The setup is fiddly and timing-sensitive; the steps below are the known-good\nsequence. Follow them in order.\n\nRun the repo-relative commands (`.ci-scripts/bsd/...`, `rsync` of the source) from your\nponyc checkout root. The VM itself lives in a separate directory (`$VMDIR`).\n\n**Each fenced `sh` block below is self-contained.** It re-sets `VMDIR`/`DFLY_VER` (and,\nwhere needed, the `SSH` command) at its top, because an agent runs each block as a fresh\nshell — environment variables and the working directory do NOT carry from one block to\nthe next. Do not \"optimize\" by setting them once and dropping them from later blocks: an\nempty `$SSH` would make a `$SSH /bin/sh <<'EOF' ... EOF` heredoc run **on your host**\n(silently, exit 0) — and Step 7 contains `rm -rf /usr/local`. Keep every block whole.\nPick your `VMDIR`/`DFLY_VER` once and use the same values in every block.\n\n## Gotchas that will bite you (read first)\n\nEach of these cost real debugging. Internalize them before you start — they recur at\nseveral steps:\n\n- **Per-block fresh shells.** See above: re-establish `VMDIR`/`DFLY_VER`/`SSH` in every\n  block; never let `$SSH` be empty in front of a `/bin/sh` heredoc.\n- **Root's login shell is csh, not sh.** Any remote command with `$(...)`, `export`, or\n  a here-string MUST go through an explicit `/bin/sh`:\n  `ssh ... root@localhost /bin/sh <<'EOF' ... EOF`. A bare\n  `ssh ... root@localhost 'cmd with $(...)'` fails with `Illegal variable name.`\n- **Boot timing is handled by the script.** `dfly_configure_vm.py` takes periodic VGA\n  screendumps and waits for the screen to stabilize before attempting login. It then types\n  three short commands via sendkey (login, mount the seed ISO, run the setup script) and\n  verifies SSH becomes reachable. If something goes wrong, check the diagnostic screendump\n  the script saves, or use the manual screendump helper (Step 5) to see the VM state.\n- **A daemonized QEMU `chdir`s to `/`.** The monitor `screendump` (and any relative path\n  the daemon writes) needs an **absolute** path, or it fails `Permission denied`.\n- **No `sudo` needed (and often unavailable).** CI loop-mounts the ISO with `sudo` to get\n  `/usr/include`; locally, extract it with `bsdtar` instead — same result, no privilege.\n- **Detach long in-VM builds.** The libs build (LLVM) takes hours; run it\n  `nohup … > /build/x.log 2>&1 &` and poll the log, so an ssh drop doesn't kill it.\n- **Reuse the CI console script verbatim.** `.ci-scripts/bsd/dfly_configure_vm.py`\n  types login + mount + setup via QEMU sendkey, then verifies SSH is reachable. The setup\n  script lives on the seed ISO created in Step 4. Copy the Python script, don't reimplement\n  the bootstrap or setup.\n\n## Step 0 — verify prerequisites (do NOT assume they're installed)\n\nThis skill needs, on the host:\n\n- an existing ponyc checkout (you run the repo-relative commands from it)\n- hardware-accelerated virtualization for QEMU (KVM on Linux, HVF on macOS)\n- enough free disk for the VM's sparse qcow2 disks (provisioned at 60 GB + 50 GB nominal;\n  they grow only as used, and the libs build plus a build fill a large chunk of the data\n  disk), ~2 GB for the image/ISO files, and network access to mirror-master.dragonflybsd.org\n- `qemu-system-x86_64` and `qemu-img`\n- `bsdtar` (a libarchive tar that reads ISO9660; the default `tar` on macOS, FreeBSD, and DragonFly)\n- `genisoimage` (or `mkisofs`) for creating the seed ISO\n- `bunzip2`, `rsync`, `git`, `curl`, `python3`\n- an OpenSSH client (`ssh`, `scp`, `ssh-keygen`)\n- a PPM-to-PNG converter: any one of magick/convert, ffmpeg, or pnmtopng\n\nCheck that every required tool is present. Do not assume the environment has them, and do\nnot install anything yourself — installing them is the user's call (it needs privilege,\nand the package names vary by OS, so naming a specific package would be wrong on half the\nplatforms this runs on). If anything is missing, **stop, tell the user which\ntools/capabilities are missing, and ask them to install them with their platform's\npackage manager**, then re-run the check before proceeding.\n\n```sh\nmissing=\"\"\nfor t in qemu-system-x86_64 qemu-img bsdtar bunzip2 rsync git ssh scp ssh-keygen curl python3 genisoimage; do\n  command -v \"$t\" >/dev/null 2>&1 || missing=\"$missing $t\"\ndone\ncommand -v magick >/dev/null 2>&1 || command -v convert >/dev/null 2>&1 \\\n  || command -v ffmpeg >/dev/null 2>&1 || command -v pnmtopng >/dev/null 2>&1 \\\n  || missing=\"$missing ppm-converter(magick|ffmpeg|pnmtopng)\"\n# Hardware acceleration is host-specific — check the right accelerator for this OS.\ncase \"$(uname -s)\" in\n  Linux)  [ -w /dev/kvm ] || missing=\"$missing kvm(/dev/kvm-not-writable)\" ;;\n  Darwin) qemu-system-x86_64 -accel help 2>&1 | grep -q hvf || missing=\"$missing hvf-accelerator\" ;;\n  *)      echo \"NOTE: confirm this host has a QEMU hardware accelerator and adjust the boot accel= below\" ;;\nesac\n[ -n \"$missing\" ] && echo \"MISSING:$missing\" || echo \"all prerequisites present\"\n```\n\nIf the output is anything but `all prerequisites present`, name the missing\ntools/capabilities to the user and ask them to install them however their OS does — do\nNOT guess a distro package name. (`bsdtar` is the libarchive tar; on macOS/FreeBSD/\nDragonFly the system `tar` is already bsdtar, elsewhere it comes from libarchive.) A\nmissing hardware accelerator — no writable `/dev/kvm` on Linux, no HVF on macOS — is a\nhost/BIOS/virtualization fix only the user can make; the VM is unusably slow without it,\nso don't fall back to TCG emulation.\n\nThis flow is verified on Linux+KVM. The guest side is OS-agnostic, so macOS goes through\nthe same steps with the HVF accelerator (`accel=kvm:hvf` in Step 4 selects it).\n\n## Setup\n\nPick a persistent VM directory and a DragonFly version that **matches ponyc CI** (check\nthe image URL in `.ci-scripts/bsd/dragonfly-provision.bash`; currently 6.4.2). Use the\nsame two values in every block below.\n\n### 1. Download the image + ISO (one time; keep the `.bz2` to avoid re-downloading)\n\nThe raw `.img` boots; the `.iso` is only mined for system headers (the raw image ships\nwithout `/usr/include`).\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; DFLY_VER=6.4.2\nmkdir -p \"$VMDIR\" && cd \"$VMDIR\"\nbase=\"https://mirror-master.dragonflybsd.org/iso-images\"\ncurl -fL --retry 3 -o dfly.img.bz2 \"$base/dfly-x86_64-${DFLY_VER}_REL.img.bz2\"\ncurl -fL --retry 3 -o dfly.iso.bz2 \"$base/dfly-x86_64-${DFLY_VER}_REL.iso.bz2\"\n```\n\n### 2. Build the disks\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\nbunzip2 -k -f dfly.img.bz2 dfly.iso.bz2          # -k keeps the .bz2 originals\nqemu-img convert -f raw -O qcow2 dfly.img dfly.qcow2\nqemu-img resize dfly.qcow2 60G\nqemu-img create -f qcow2 dfly-data.qcow2 50G     # build workspace; root part is ~1.8G\nrm -f dfly.img                                    # keep dfly.qcow2 + the .bz2\n```\n\n### 3. Extract system headers from the ISO — no sudo (replaces CI's `mount -o loop`)\n\n`bsdtar` reads ISO9660 directly. The tar must have `include/` as its top entry so the\nin-VM extraction matches CI. (Both the extract and the repack use `bsdtar`, so no\nseparate `tar` is needed on the host.)\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\nrm -rf iso-stage && mkdir iso-stage\nbsdtar -xf dfly.iso -C iso-stage usr/include\nbsdtar -cf dfly-include.tar -C iso-stage/usr include\nrm -rf iso-stage\n```\n\n### 4. Key + seed ISO + boot (daemonized, persistent)\n\nCreate the SSH key, a seed ISO with the key and a setup script, then boot with the ISO\nattached as a CD-ROM. The setup script on the ISO configures networking, sshd, and SSH\nkeys — the sendkey bootstrap only needs to type three short commands (login, mount, run\nscript) instead of piping long commands through the fragile VGA console.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; DFLY_VER=6.4.2; cd \"$VMDIR\"\ntest -f vm_key || ssh-keygen -t ed25519 -f vm_key -N \"\" -q\ncat > setup.sh <<'SETUP'\n#!/bin/sh\nset -e\nif pgrep -x sshd >/dev/null 2>&1; then\n  exit 0\nfi\ndhclient vtnet0\necho \"PermitRootLogin yes\" >> /etc/ssh/sshd_config\necho \"PermitEmptyPasswords yes\" >> /etc/ssh/sshd_config\nmkdir -p /root/.ssh && chmod 700 /root/.ssh\ncp /mnt/authorized_keys /root/.ssh/authorized_keys\nchmod 600 /root/.ssh/authorized_keys\nssh-keygen -A\n/usr/sbin/sshd\nSETUP\ncp vm_key.pub authorized_keys\ngenisoimage -output seed.iso -volid CIDATA -joliet -rock setup.sh authorized_keys\nqemu-system-x86_64 \\\n  -name dragonfly-${DFLY_VER} \\\n  -machine pc,accel=kvm:hvf -cpu host -smp 8 -m 12G \\\n  -drive file=dfly.qcow2,format=qcow2,if=virtio \\\n  -drive file=dfly-data.qcow2,format=qcow2,if=virtio \\\n  -drive file=seed.iso,media=cdrom \\\n  -netdev user,id=net0,hostfwd=tcp::2222-:22 \\\n  -device virtio-net-pci,netdev=net0 \\\n  -object rng-random,id=rng0,filename=/dev/urandom \\\n  -device virtio-rng-pci,rng=rng0 \\\n  -monitor unix:dfly-monitor.sock,server,nowait \\\n  -serial unix:dfly-serial.sock,server,nowait \\\n  -display none -pidfile dfly.pid -daemonize\n```\n\n(`accel=kvm:hvf` picks KVM on Linux or HVF on macOS and errors if neither is available —\nit never silently falls back to slow TCG. `-smp`/`-m` are speed knobs; CI uses 4 CPUs /\n12G. `hostfwd 2222->22` is the ssh port.)\n\n### 5. Run the console setup script\n\nThe CI console script takes periodic VGA screendumps to detect when boot finishes, then\ntypes three short commands via sendkey (login as root, mount the seed ISO, run the setup\nscript). It verifies SSH becomes reachable after each attempt, retrying if needed.\n\nThe script lives in the repo at `.ci-scripts/bsd/dfly_configure_vm.py`. Copy it into\n`$VMDIR` (it connects to `dfly-monitor.sock` in its working directory) and run it there.\nSet `DFLY_ARTIFACTS_DIR` to the VM directory so the script can write screendumps for boot\ndetection and diagnostics:\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\ncp \"$(git rev-parse --show-toplevel)/.ci-scripts/bsd/dfly_configure_vm.py\" \"$VMDIR/\"\nexport DFLY_ARTIFACTS_DIR=\"$VMDIR\"\npython3 dfly_configure_vm.py\n```\n\nA `monitor_cmd.py` helper is still useful for ad-hoc screendumps when debugging boot\nproblems. Create one if needed:\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\ncat > monitor_cmd.py <<'PY'\nimport socket, sys, time\ns = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.connect('dfly-monitor.sock')\ntime.sleep(0.4); s.recv(65536)\ns.sendall((sys.argv[1] + '\\n').encode()); time.sleep(0.5); s.settimeout(1.0)\ntry:\n    while True:\n        d = s.recv(65536)\n        if not d: break\n        sys.stdout.write(d.decode(errors='replace'))\nexcept socket.timeout: pass\ns.close()\nPY\nto_png() {  # convert PPM->PNG with whichever converter is installed\n  if   command -v magick   >/dev/null 2>&1; then magick \"$1\" \"$2\"\n  elif command -v convert  >/dev/null 2>&1; then convert \"$1\" \"$2\"\n  elif command -v ffmpeg   >/dev/null 2>&1; then ffmpeg -y -loglevel error -i \"$1\" \"$2\"\n  else pnmtopng \"$1\" > \"$2\"; fi\n}\npython3 monitor_cmd.py \"screendump $VMDIR/console1.ppm\"   # absolute path is required\nto_png \"$VMDIR/console1.ppm\" \"$VMDIR/console1.png\"\n```\n\n### 6. Wait for ssh\n\nStep 5 already verifies SSH is reachable, so this is a belt-and-suspenders check. If it\nfails, re-screendump to check the console state, and re-run Step 5.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\nup=\"\"\nfor i in $(seq 1 150); do   # ~5 min, matching CI's timeout\n  if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=2 -i vm_key -p 2222 \\\n       root@localhost true 2>/dev/null; then up=1; echo \"SSH up\"; break; fi\n  sleep 2\ndone\n[ -n \"$up\" ] || echo \"SSH NEVER CAME UP — check the console screendump (Step 5) and rerun if needed\"\n```\n\nFrom here, **always** drive the VM through `/bin/sh` (root's shell is csh — see Gotchas),\nand define `$SSH` in the same block you use it (it does not carry between blocks):\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nuname -a\nEOF\n```\n\n### 7. Format and mount the build disk\n\nThe root partition is only ~1.8G — not enough for the build or packages. Format the\nsecond disk and mount it at `/build`.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\nnewfs /dev/vbd1\nmkdir -p /build\nmount /dev/vbd1 /build\nEOF\n```\n\n### 8. Move `/usr/local` to the build disk, install headers, install deps\n\n`gcc13` alone is ~418M, so package installs must land on `/build`.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncpdup /usr/local /build/usr_local\nrm -rf /usr/local\nln -s /build/usr_local /usr/local\nEOF\n\nscp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i \"$VMDIR/vm_key\" -P 2222 \"$VMDIR/dfly-include.tar\" root@localhost:/build/\n$SSH /bin/sh <<'EOF'\nset -e\ntar xf /build/dfly-include.tar -C /build\nln -s /build/include /usr/include\nrm /build/dfly-include.tar\nEOF\n\n$SSH /bin/sh <<'EOF'\nset -e\npkg install -y cmake gmake git python3 cxx_atomics rsync gcc13 ca_root_nss\npkg clean -ay\ngit config --global --add safe.directory /build/ponyc\nEOF\n```\n\n### 9. Rsync the ponyc checkout in (exclude the host `build/`)\n\nRun from your ponyc checkout. Exclude the top-level `build/` (host artifacts; the VM\nbuilds its own). Keep `.git` (CMake runs `git rev-parse`). The vendored LLVM submodule\nunder `lib/llvm/src` IS transferred — the VM needs it for the libs build.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nrsync -az --exclude='/build' \\\n  -e \"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222\" \\\n  \"$(git rev-parse --show-toplevel)/\" root@localhost:/build/ponyc/\n```\n\n## Using the VM\n\nAlways set the gcc13 build env first (matches the tier-3 `dragonflybsd` job). `cmake -P lib/build-libs.cmake`\nbuilds the vendored LLVM and is the multi-hour long pole — run it detached, then poll the\nlog until it ends with `libs DONE rc=0` (a nonzero rc means it failed — read the log\nabove the marker):\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncat > /build/run-libs.sh <<'S'\n#!/bin/sh\nexport CC=/usr/local/bin/gcc13 CXX=/usr/local/bin/g++13\nexport LD_LIBRARY_PATH=/usr/local/lib/gcc13\nexport SSL_CERT_FILE=/usr/local/share/certs/ca-root-nss.crt\ncd /build/ponyc && cmake -DJOBS=4 -DCMAKE_C_COMPILER=/usr/local/bin/gcc13 -DCMAKE_CXX_COMPILER=/usr/local/bin/g++13 -P lib/build-libs.cmake\necho \"libs DONE rc=$?\"\nS\nchmod +x /build/run-libs.sh\nnohup /build/run-libs.sh > /build/libs-build.log 2>&1 &\necho \"libs building pid $!\"\nEOF\n# poll until done: $SSH /bin/sh -c 'tail -3 /build/libs-build.log'  -> wait for \"libs DONE rc=0\"\n```\n\nOnce `libs` is built (it persists in the VM), build and test with the same env:\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\nexport CC=/usr/local/bin/gcc13 CXX=/usr/local/bin/g++13\nexport LD_LIBRARY_PATH=/usr/local/lib/gcc13\nexport SSL_CERT_FILE=/usr/local/share/certs/ca-root-nss.crt\ncd /build/ponyc\ncmake --preset debug -DCMAKE_C_COMPILER=/usr/local/bin/gcc13 -DCMAKE_CXX_COMPILER=/usr/local/bin/g++13\ncmake --build --preset debug\nctest --preset debug -L ci-core\nEOF\n```\n\nTo iterate on a fix: edit on the host, re-run Step 9's `rsync` (it's incremental — only\nchanged files are sent), then re-run the build block above (long rebuilds should also be\ndetached + polled). For exact CI parity, the tier-3 `dragonflybsd` job\n(`.github/workflows/ponyc-tier3.yml`) runs more than this — the reject-unsupported-builds\ncheck, a `--static` link smoke, and the coverage smoke — so consult it when validating a\nCI-matching issue.\n\n## Lifecycle\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\npgrep -af \"qemu-system-x86_64 -name dragonfly\"        # status\nssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 root@localhost   # interactive shell\n# stop: DragonFly's default install has no ACPI shutdown handler, so system_powerdown\n# usually does nothing — kill the process instead. Disks persist for next time.\nkill \"$(cat dfly.pid)\" 2>/dev/null || pkill -f \"qemu-system-x86_64 -name dragonfly\"\n```\n\nTo restart later, re-run Steps 4–7 (the disks and installed deps persist; the key and ISO\ncreation are idempotent, and Steps 5–7 re-establish sshd and the `/build` mount, which\nare not persisted across a guest reboot). For a long-lived VM, make `sshd_enable`\npermanent in `/etc/rc.conf` and add the `/build` mount to `/etc/fstab`.\n\n## How this relates to CI (don't re-add the CI-only steps)\n\nThis mirrors `.ci-scripts/bsd/dragonfly-provision.bash` (the source of truth — keep this\nskill in sync if it changes) and **reuses `.ci-scripts/bsd/dfly_configure_vm.py`\nunchanged**. The local setup deliberately differs from CI, and each difference is safe:\n\n- Skips CI host-bootstrap (free disk space, install qemu, make `/dev/kvm` writable) —\n  your host already has qemu and a usable hardware accelerator (Step 0 verified it).\n- `bsdtar` instead of `sudo mount -o loop` for the ISO headers — no privilege needed.\n- Persistent VM instead of built-fresh-and-killed per run.\n- ssh adds `-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR` to quiet the host-key\n  warning: a persistent VM reused on `localhost:2222` collides with the stale\n  `known_hosts` entry from a prior VM. CI's ephemeral runners never reuse the port, so\n  `dragonfly-provision.bash` doesn't bother.\n- `-smp 8` for build speed (CI uses 4).\n- Both use the seed ISO + sendkey approach with screendump-based boot detection; locally\n  you can also use the manual screendump helper for ad-hoc debugging.\n- No GHCR libs cache (that's token-gated CI plumbing) — you just run `cmake -P lib/build-libs.cmake` once.\n\nThe FreeBSD and OpenBSD CI VMs follow the same shape\n(`.ci-scripts/bsd/{freebsd,openbsd}-provision.bash`) and each has its own skill:\n`create-freebsd-dev-env` (takes a `FREEBSD_VERSION`) and `create-openbsd-dev-env` (its\nstatic-link checks expect static-PIE, and it rejects the unsupported `use=` options). This\nskill is DragonFly-only; load a sibling if you need one of those.\n",".claude/skills/create-freebsd-dev-env/SKILL.md":"---\nname: create-freebsd-dev-env\ndescription: Load when you need to reproduce or validate a FreeBSD-specific ponyc issue on a local VM, or to stand up a local FreeBSD development VM that matches ponyc tier-3 CI. Covers the cloud-init seed, the sudo-free QEMU/KVM boot, the one-time expect/su root bootstrap, the clang build env, and the gotchas (freebsd-user login, no bash in base, growfs auto-resize, detached builds, per-block shells) that make hand-rolling one error-prone.\ndisable-model-invocation: false\n---\n\n# Create a FreeBSD dev/test VM for ponyc\n\nStand up a local FreeBSD VM that matches the ponyc tier-3 CI job, so you can build\nand test ponyc on FreeBSD without GitHub Actions.\n\nFreeBSD's tier-3 job exercises paths the clang-on-Linux jobs never do: the embedded-LLD\n`--static` link recipe, the native sanitizer link path, and FreeBSD's base\nlibc++/libunwind. So this VM is the authoritative local\nenvironment for **FreeBSD-platform** ponyc issues. (It is clang-based — FreeBSD's base\ncompiler is clang, not gcc. For **gcc-specific** issues use the DragonFly skill instead.)\n\nRun the repo-relative commands (`rsync` of the source) from your ponyc checkout root.\nThe VM itself lives in a separate directory (`$VMDIR`).\n\n**Each fenced `sh` block below is self-contained.** It re-sets `VMDIR`/`FREEBSD_VERSION`\n(and, where needed, the `SSH` command) at its top, because an agent runs each block as a\nfresh shell — environment variables and the working directory do NOT carry from one\nblock to the next. Do not \"optimize\" by setting them once and dropping them from later\nblocks: an empty `$SSH` would make a `$SSH /bin/sh <<'EOF' ... EOF` heredoc run **on your\nhost** (silently) instead of in the VM. Keep every block whole. Pick your `VMDIR`/\n`FREEBSD_VERSION` once and use the same values in every block.\n\n## Gotchas that will bite you (read first)\n\nFreeBSD is much simpler to bring up than DragonFly (cloud-init injects the ssh key, so\nthere is no VGA-console automation), but these still matter:\n\n- **Per-block fresh shells.** See above: re-establish `VMDIR`/`FREEBSD_VERSION`/`SSH` in\n  every block; never let `$SSH` be empty in front of a `/bin/sh` heredoc.\n- **You log in as `freebsd`, not `root`.** The cloud-init seed installs your key for the\n  `freebsd` user and sets root's password to `ciroot`. Build and test run **as\n  `freebsd`** (no root needed). Root is only needed twice during setup — the one-time\n  `expect`/`su` bootstrap below installs the packages and configures passwordless `doas`,\n  after which the rare root operation goes through `doas`.\n- **Cloud-init means no console driving.** Unlike the DragonFly skill there is no QEMU\n  `sendkey`/screendump/monitor dance — just boot and wait for ssh. But the **first** boot\n  runs `nuageinit` (key install) and `growfs` (root-fs auto-resize), so ssh is not\n  instant; poll for it (Step 4).\n- **The root fs auto-grows; there is no growfs command to run.** `qemu-img resize ... 60G`\n  enlarges the disk and the image's first boot grows the root fs to fill it. You do not\n  run `growfs` yourself — the wait-for-ssh loop is what accounts for it.\n- **FreeBSD's base system has no `bash`.** In-VM scripts must be POSIX `sh` (the two CI\n  smoke scripts already are). Don't reach for bashisms in a `$SSH /bin/sh` heredoc.\n- **Detach long in-VM builds.** The libs build (LLVM) takes hours; run it\n  `nohup … > /…/x.log 2>&1 &` and poll the log, so an ssh drop doesn't kill it.\n- **The one-time root bootstrap needs `expect`.** FreeBSD's `nuageinit` supports only\n  `ssh_authorized_keys` + `chpasswd` (not cloud-init's `write_files`/`runcmd`), so the\n  package install and `doas.conf` setup are driven through `su -m root` with `expect`,\n  exactly as CI does it. After that, `doas` is on disk and persists across reboots.\n\n## Step 0 — verify prerequisites (do NOT assume they're installed)\n\nThis skill needs, on the host:\n\n- an existing ponyc checkout (you run the `rsync` from it)\n- hardware-accelerated virtualization for QEMU (KVM on Linux, HVF on macOS)\n- enough free disk for the VM's sparse qcow2 disk (provisioned at 60 GB nominal; it grows\n  only as used — the libs build plus a debug build take the qcow2 to ~12 GB, and a release\n  build on top adds more), ~1 GB for the compressed image, and network access to\n  download.freebsd.org\n- `qemu-system-x86_64` and `qemu-img`\n- `cloud-localds` (from cloud-image-utils) or `genisoimage` to build the cloud-init\n  seed image\n- `expect` (drives the one-time `su` root bootstrap)\n- `xz`, `rsync`, `git`, `curl`, `python3`\n- an OpenSSH client (`ssh`, `scp`, `ssh-keygen`)\n\nNote this list is shorter than the DragonFly skill's: FreeBSD needs no `bsdtar`/`bunzip2`\n(no ISO header mining) and no PPM-to-PNG converter (no console screendumps).\n\nCheck that every required tool is present. Do not assume the environment has them, and do\nnot install anything yourself — installing them is the user's call (it needs privilege,\nand the package names vary by OS). If anything is missing, **stop, tell the user which\ntools/capabilities are missing, and ask them to install them with their platform's\npackage manager**, then re-run the check before proceeding.\n\n```sh\nmissing=\"\"\nfor t in qemu-system-x86_64 qemu-img expect xz rsync git ssh scp ssh-keygen curl python3; do\n  command -v \"$t\" >/dev/null 2>&1 || missing=\"$missing $t\"\ndone\ncommand -v cloud-localds >/dev/null 2>&1 || command -v genisoimage >/dev/null 2>&1 \\\n  || missing=\"$missing seed-builder(cloud-localds|genisoimage)\"\n# Hardware acceleration is host-specific — check the right accelerator for this OS.\ncase \"$(uname -s)\" in\n  Linux)  [ -w /dev/kvm ] || missing=\"$missing kvm(/dev/kvm-not-writable)\" ;;\n  Darwin) qemu-system-x86_64 -accel help 2>&1 | grep -q hvf || missing=\"$missing hvf-accelerator\" ;;\n  *)      echo \"NOTE: confirm this host has a QEMU hardware accelerator and adjust the boot accel= below\" ;;\nesac\n[ -n \"$missing\" ] && echo \"MISSING:$missing\" || echo \"all prerequisites present\"\n```\n\nIf the output is anything but `all prerequisites present`, name the missing\ntools/capabilities to the user and ask them to install them however their OS does — do\nNOT guess a distro package name. (`cloud-localds` ships in `cloud-image-utils` on\nDebian/Ubuntu; if it is unavailable, the cidata seed can also be built with\n`genisoimage -output seed.img -volid cidata -joliet -rock user-data meta-data`, which is\nwhat the OpenBSD CI path uses.) A missing hardware accelerator — no writable `/dev/kvm`\non Linux, no HVF on macOS — is a host/BIOS/virtualization fix only the user can make; the\nVM is unusably slow without it, so don't fall back to TCG emulation.\n\nThis flow is verified on Linux+KVM through the libs build and a debug build. The guest side\nis OS-agnostic, so macOS goes through the same steps with the HVF accelerator\n(`accel=kvm:hvf` in Step 3 selects it).\n\n## Setup\n\nPick a persistent VM directory and a FreeBSD version that **matches ponyc CI** (the tier-3\n`freebsd` job in `.github/workflows/ponyc-tier3.yml` tests a single version — currently\n15.1; the image URL is in `.ci-scripts/bsd/freebsd-provision.bash`). Use the same value\nin every block below.\n\n### 1. Download the image (one time; keep the `.xz` to avoid re-downloading)\n\nThe BASIC-CLOUDINIT image boots straight to a cloud-init-configured system — no installer,\nand it ships the full base system including `/usr/include`.\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1\nmkdir -p \"$VMDIR\" && cd \"$VMDIR\"\nbase=\"https://download.freebsd.org/releases/VM-IMAGES/${FREEBSD_VERSION}-RELEASE/amd64/Latest\"\ncurl -fL --retry 3 -o freebsd.qcow2.xz \\\n  \"$base/FreeBSD-${FREEBSD_VERSION}-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz\"\nxz -dk freebsd.qcow2.xz                 # -k keeps the .xz original\nqemu-img resize freebsd.qcow2 60G       # first boot grows the root fs to fill this\n```\n\n### 2. Key + cloud-init seed\n\n`nuageinit` reads a cidata seed image at boot: it installs your ssh public key for the\n`freebsd` user and sets root's password (so the Step 5 `su` bootstrap can reach root).\n\n```sh\nVMDIR=~/vms/freebsd-15.1; cd \"$VMDIR\"\ntest -f vm_key || ssh-keygen -t ed25519 -f vm_key -N \"\" -q\ncat > user-data <<USERDATA\n#cloud-config\nssh_authorized_keys:\n  - $(cat vm_key.pub)\nchpasswd:\n  expire: false\n  list:\n    - root:ciroot\nUSERDATA\ncat > meta-data <<'METADATA'\ninstance-id: freebsd-dev\nlocal-hostname: freebsd-dev\nMETADATA\ncloud-localds seed.img user-data meta-data\n```\n\n### 3. Boot (daemonized, persistent)\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1; cd \"$VMDIR\"\nqemu-system-x86_64 \\\n  -name freebsd-${FREEBSD_VERSION} \\\n  -machine pc,accel=kvm:hvf -cpu host -smp 8 -m 12G \\\n  -drive file=freebsd.qcow2,format=qcow2,if=virtio \\\n  -drive file=seed.img,format=raw,if=virtio \\\n  -netdev user,id=net0,hostfwd=tcp::2222-:22 \\\n  -device virtio-net-pci,netdev=net0 \\\n  -display none -pidfile freebsd.pid -daemonize\n```\n\n(`accel=kvm:hvf` picks KVM on Linux or HVF on macOS and errors if neither is available —\nit never silently falls back to slow TCG. `-smp`/`-m` are speed knobs; CI uses 4 CPUs /\n12G. `hostfwd 2222->22` is the ssh port — to run two FreeBSD VMs at once (e.g. CI's 15.1\nplus another version you want to compare against), give each its own `$VMDIR` and a\ndistinct hostfwd port, and pass that port to every ssh/scp/rsync `-p`. The seed drive stays attached — `nuageinit` skips it on\nlater boots once the instance-id is seen. This boot command is adapted from\n`.ci-scripts/bsd/freebsd-provision.bash`: it adds `-pidfile`/`-smp 8` and drops the CI\nhost-bootstrap; CI has no rng or monitor device for FreeBSD and neither does this.)\n\n### 4. Wait for ssh (first boot runs nuageinit + growfs)\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1; cd \"$VMDIR\"\nup=\"\"\nfor i in $(seq 1 150); do   # ~5 min, matching CI's timeout\n  if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=2 -i vm_key -p 2222 \\\n       freebsd@localhost true 2>/dev/null; then up=1; echo \"SSH up\"; break; fi\n  sleep 2\ndone\n[ -n \"$up\" ] || echo \"SSH NEVER CAME UP — see recovery below.\"\n```\n\nIf ssh never comes up, this VM has no VGA console to look at (it booted headless), so\ndiagnose in this order:\n\n- **Is the process alive?** `pgrep -af \"qemu-system-x86_64 -name freebsd-${FREEBSD_VERSION}\"`.\n  If it's gone, the boot died early (usually a bad/incomplete image from Step 1) — re-check\n  the Step 1 download/resize and re-boot.\n- **Watch the boot.** The image logs to the serial console, so re-boot with a serial log\n  added — `kill \"$(cat freebsd.pid)\"`, then re-run the Step 3 boot command with\n  `-serial file:$VMDIR/console.log` appended — and read `console.log` to see how far it got.\n- **ssh actively *refuses* the key (vs. just timing out)?** That means `nuageinit` never\n  installed it — the seed didn't apply. Confirm `seed.img` is attached as a drive in the\n  boot command (Step 3) and was built in Step 2, then re-boot.\n\n### 5. One-time root bootstrap (install deps + configure doas) via expect\n\nFreeBSD's `nuageinit` can't run `write_files`/`runcmd`, so — exactly as CI does — drive\n`su -m root` with `expect` to install the build dependencies and grant the `freebsd` user\npasswordless `doas`. This runs once; `doas.conf` persists on disk for the VM's life. (The\nexpect block is adapted from the one embedded in `freebsd-provision.bash`.)\n\n```sh\nVMDIR=~/vms/freebsd-15.1; cd \"$VMDIR\"\nexpect <<'EXPECT'\nset timeout 600\nspawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 -t freebsd@localhost su -m root\nexpect {\n  \"Password:\" { send \"ciroot\\r\" }\n  timeout { puts \"Timeout waiting for password prompt\"; exit 1 }\n}\nexpect {\n  \"su: Sorry\" { puts \"su failed - wrong password or nuageinit didn't set it\"; exit 1 }\n  \"#\" {}\n  timeout { puts \"Timeout waiting for root shell\"; exit 1 }\n}\nsend \"pkg install -y cmake gmake libunwind git python3 rsync doas\\r\"\nexpect {\n  \"#\" {}\n  timeout { puts \"Timeout during pkg install\"; exit 1 }\n}\nsend \"echo 'permit nopass freebsd' > /usr/local/etc/doas.conf\\r\"\nexpect {\n  \"#\" {}\n  timeout { puts \"Timeout writing doas.conf\"; exit 1 }\n}\nsend \"exit\\r\"\nexpect eof\nEXPECT\n```\n\nIf `expect` prints one of its `Timeout …` messages, it's safe to re-run this block — the\n`pkg install -y` and the clobbering `>` write to `doas.conf` are both idempotent. The one\nexception is `su failed`: that means the seed never set root's password, so root was never\nreachable. Re-running won't fix it — that's a boot/seed problem, so work through the Step 4\nrecovery (is the VM up? did `seed.img` apply?) first.\n\n### 6. Rsync the ponyc checkout in (exclude the host `build/`)\n\nRun from your ponyc checkout. Exclude the top-level `build/` (host artifacts; the VM\nbuilds its own). Keep `.git` (CMake runs `git rev-parse`). The vendored LLVM submodule\nunder `lib/llvm/src` IS transferred — the VM needs it for the libs build.\n\n```sh\nVMDIR=~/vms/freebsd-15.1\nrsync -az --exclude='/build' \\\n  -e \"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222\" \\\n  \"$(git rev-parse --show-toplevel)/\" freebsd@localhost:/home/freebsd/ponyc/\n```\n\n## Using the VM\n\nThe build uses FreeBSD's default clang — no `CC`/`CXX`/`LD_LIBRARY_PATH` exports (that is\nthe DragonFly-only gcc13 dance). `cmake -P lib/build-libs.cmake` builds the vendored LLVM and is the multi-hour\nlong pole — run it detached, then poll the log until it ends with `libs DONE rc=0` (a\nnonzero rc means it failed — read the log above the marker):\n\n```sh\nVMDIR=~/vms/freebsd-15.1\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 freebsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncat > /home/freebsd/run-libs.sh <<'S'\n#!/bin/sh\ncd /home/freebsd/ponyc && cmake -DJOBS=4 -P lib/build-libs.cmake\necho \"libs DONE rc=$?\"\nS\nchmod +x /home/freebsd/run-libs.sh\nnohup /home/freebsd/run-libs.sh > /home/freebsd/libs-build.log 2>&1 &\necho \"libs building pid $!\"\nEOF\n# poll until done: $SSH /bin/sh -c 'tail -3 /home/freebsd/libs-build.log'  -> wait for \"libs DONE rc=0\"\n```\n\nOnce `libs` is built (it persists in the VM), build and test with the same shell. Do not\nrun this block until the poll shows `libs DONE rc=0` — `cmake --build` against a half-built\n`build/libs` fails:\n\n```sh\nVMDIR=~/vms/freebsd-15.1\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 freebsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncd /home/freebsd/ponyc\ncmake --preset debug\ncmake --build --preset debug\nctest --preset debug -L ci-core\nEOF\n```\n\nTo iterate on a fix: edit on the host, re-run Step 6's `rsync` (it's incremental — only\nchanged files are sent), then re-run the build block above (long rebuilds should also be\ndetached + polled). For exact CI parity, the tier-3 `freebsd` job\n(`.github/workflows/ponyc-tier3.yml`) runs more than this — a `--static` embedded-LLD link\nsmoke, the self-hosted tool tests (`pony-doc-tests`/`pony-lint-tests`/`pony-lsp-tests`), a\nrelease build + the `ci-core` suite, and two extra smokes:\n`.ci-scripts/freebsd-sanitizer-smoke.sh`. Consult that job when validating a\nCI-matching issue.\n\n## Lifecycle\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1; cd \"$VMDIR\"\npgrep -af \"qemu-system-x86_64 -name freebsd-${FREEBSD_VERSION}\"        # status\nssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 freebsd@localhost   # interactive shell\n# stop: CI stops this VM by killing the process; do the same. Disks persist for next time.\n# The pgrep/pkill are scoped to this version so a second FreeBSD VM (e.g. another\n# version you're comparing against) isn't matched; the pidfile path is per-$VMDIR anyway.\nkill \"$(cat freebsd.pid)\" 2>/dev/null || pkill -f \"qemu-system-x86_64 -name freebsd-${FREEBSD_VERSION}\"\n```\n\nTo restart later, re-run the step-3 boot command (the disk and installed deps persist; you\ndo not redo setup). On a reboot, ssh-as-`freebsd` works immediately (the key was written\nto disk on first boot) and `doas` still works (`doas.conf` persisted) — there is no\nre-bootstrap, unlike the DragonFly VM.\n\n## How this relates to CI (don't re-add the CI-only steps)\n\nThis mirrors `.ci-scripts/bsd/freebsd-provision.bash` (the source of truth — keep this\nskill in sync if it changes). The local setup deliberately differs from CI, and each\ndifference is safe:\n\n- Skips CI host-bootstrap (free disk space, `apt-get` qemu, make `/dev/kvm` writable) —\n  your host already has qemu and a usable hardware accelerator (Step 0 verified it).\n- Persistent VM instead of built-fresh-and-killed per run.\n- ssh adds `-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR` to quiet the host-key\n  warning: a persistent VM reused on `localhost:2222` collides with the stale\n  `known_hosts` entry from a prior VM. CI's ephemeral runners never reuse the port, so\n  `freebsd-provision.bash` doesn't bother.\n- `-smp 8` for build speed (CI uses 4), and a `-pidfile` for the lifecycle commands.\n- No GHCR libs cache (that's token-gated CI plumbing) — you just run `cmake -P lib/build-libs.cmake` once.\n\nThe DragonFly and OpenBSD CI VMs follow the same shape\n(`.ci-scripts/bsd/{dragonfly,openbsd}-provision.bash`) and each has its own skill:\n`create-dragonfly-dev-env` (gcc-only, and it drives a VGA console) and\n`create-openbsd-dev-env` (also cloud-init, but with static-PIE static-link checks, a raised\n`datasize` limit, and the source under a `/build` disk). This skill is FreeBSD-only; load a\nsibling if you need one of those.\n",".claude/skills/create-openbsd-dev-env/SKILL.md":"---\nname: create-openbsd-dev-env\ndescription: Load when you need to reproduce or validate an OpenBSD-specific ponyc issue on a local VM, or to stand up a local OpenBSD development VM that matches ponyc tier-3 CI. Covers the cloud-init seed (cdrom), the sudo-free QEMU/KVM boot, the doas-via-cloud-init setup (no expect bootstrap), the dedicated /build data disk, the datasize limit, the clang build env, and the gotchas (openbsd-user login, disklabel build disk, raised datasize, static-PIE static linking, pkg_add naming, detached builds, per-block shells) that make hand-rolling one error-prone.\ndisable-model-invocation: false\n---\n\n# Create an OpenBSD dev/test VM for ponyc\n\nStand up a local OpenBSD VM that matches the ponyc tier-3 CI job, so you can build\nand test ponyc on OpenBSD without GitHub Actions.\n\nOpenBSD's tier-3 job exercises paths the Linux jobs never do: the static-PIE `--static`\nembedded-LLD link recipe, OpenBSD's base clang/libc++, and the per-process `datasize`\nlimit that the LLVM build pushes against. It also asserts the `use=` build options that\ncan't work there are rejected. So this VM is the authoritative local environment for\n**OpenBSD-platform** ponyc issues. (It is clang-based — OpenBSD's base compiler is clang,\nnot gcc. For **gcc-specific** issues use the DragonFly skill instead.)\n\nRun the repo-relative commands (`rsync` of the source) from your ponyc checkout root. The\nVM itself lives in a separate directory (`$VMDIR`).\n\n**Each fenced `sh` block below is self-contained.** It re-sets `VMDIR`/`OPENBSD_VERSION`\n(and, where needed, the `SSH` command) at its top, because an agent runs each block as a\nfresh shell — environment variables and the working directory do NOT carry from one block\nto the next. Do not \"optimize\" by setting them once and dropping them from later blocks:\nan empty `$SSH` would make a `$SSH /bin/sh <<'EOF' ... EOF` heredoc run **on your host**\n(silently) instead of in the VM — and Step 5 contains `newfs` and Step 6 edits\n`/etc/login.conf`. Keep every block whole. Pick your `VMDIR`/`OPENBSD_VERSION` once and use\nthe same values in every block.\n\n## Gotchas that will bite you (read first)\n\nOpenBSD is a hybrid of the other two BSD skills: like FreeBSD it boots from a cloud-init\nseed and is driven entirely over ssh (no VGA-console automation), but like DragonFly the\nbuild lives on a dedicated data disk. These matter:\n\n- **Per-block fresh shells.** See above: re-establish `VMDIR`/`OPENBSD_VERSION`/`SSH` in\n  every block; never let `$SSH` be empty in front of a `/bin/sh` heredoc.\n- **You log in as `openbsd`, not `root`.** The cloud-init seed creates the `openbsd` user\n  in the `wheel` group and writes `/etc/doas.conf` (`permit nopass keepenv :wheel`). Build\n  and test run **as `openbsd`**; the few root operations (disk setup, package install,\n  editing `login.conf`) go through `doas`, which is configured before first login and\n  persists on disk. There is **no** `expect`/`su` bootstrap — OpenBSD's cloud-init runs\n  `write_files`, so `doas` is in place from the first boot (simpler than FreeBSD).\n- **`doas` is in the base system.** Nothing to install; cloud-init just drops\n  `/etc/doas.conf`. Keep its `0600 root:wheel` permissions exactly — `doas` refuses to run\n  with a group- or world-writable config.\n- **Cloud-init means no console driving.** Unlike the DragonFly skill there is no QEMU\n  `sendkey`/screendump/monitor dance — just boot and wait for ssh. But the **first** boot\n  runs cloud-init (user + key + `doas.conf`), so ssh is not instant; poll for it (Step 4).\n- **Build on the `/build` data disk, not `/home`.** OpenBSD's default disklabel confines\n  writable space to `/home` (~10.8G), which the LLVM 22 build overflows. A second qcow2 is\n  partitioned, `newfs`'d, and mounted at `/build`, and the source plus the build live\n  there — mirroring the DragonFly job. The disklabel setup is **one-time and not\n  idempotent** (`newfs` reformats), so run Step 5 exactly once.\n- **The `/build` mount is not persisted across reboot.** `disklabel`/`newfs`/`mount` are\n  manual one-time steps with no `/etc/fstab` entry; after a guest reboot, remount\n  `/dev/sd1a` at `/build`. `/etc/login.conf` and `/etc/doas.conf` are on the root disk and\n  **do** persist.\n- **Raise the `datasize` limit.** OpenBSD caps a process's data segment in\n  `/etc/login.conf` (1536M on the stock 7.9 image); the LLVM build needs more, so Step 6\n  raises `datasize-max`/`-cur` to at least 8192M. Without it the libs build dies with\n  allocation failures partway through. The edit is on the root disk and persists.\n- **OpenBSD's base system has no `bash`.** `/bin/sh` is a POSIX `ksh`; in-VM scripts must\n  be POSIX `sh`. Don't reach for bashisms in a `$SSH /bin/sh` heredoc.\n- **Two `pkg_add` names look like typos but aren't:** `python%3` (the `%3` selects the 3.x\n  branch) and `rsync--` (the trailing `--` picks the no-flavor build). Copy them verbatim.\n- **Detach long in-VM builds.** The libs build (LLVM) takes hours; run it\n  `nohup … > /build/x.log 2>&1 &` and poll the log, so an ssh drop doesn't kill it.\n\n## Step 0 — verify prerequisites (do NOT assume they're installed)\n\nThis skill needs, on the host:\n\n- an existing ponyc checkout (you run the `rsync` from it)\n- hardware-accelerated virtualization for QEMU (KVM on Linux, HVF on macOS)\n- enough free disk for the VM's two sparse qcow2 disks (the shipped root image plus a 50 GB\n  nominal data disk; they grow only as used — the libs build plus a debug build take the data\n  disk to several GB), network access to github.com (the image is a GitHub release asset)\n- `qemu-system-x86_64` and `qemu-img`\n- `genisoimage` (builds the cidata seed CD-ROM; `cloud-localds` can also produce an\n  equivalent cidata image)\n- `rsync`, `git`, `curl`\n- an OpenSSH client (`ssh`, `scp`, `ssh-keygen`)\n\nNote this is the shortest prerequisite list of the three BSD skills: OpenBSD needs no\n`expect` (cloud-init configures `doas` directly), no `xz` (the image ships as a plain\nqcow2 — no decompress, no root-fs resize), and no `bsdtar`/PPM-converter (it is headless\nwith no ISO header mining and no console screendumps).\n\nCheck that every required tool is present. Do not assume the environment has them, and do\nnot install anything yourself — installing them is the user's call (it needs privilege, and\nthe package names vary by OS). If anything is missing, **stop, tell the user which\ntools/capabilities are missing, and ask them to install them with their platform's package\nmanager**, then re-run the check before proceeding.\n\n```sh\nmissing=\"\"\nfor t in qemu-system-x86_64 qemu-img rsync git ssh scp ssh-keygen curl; do\n  command -v \"$t\" >/dev/null 2>&1 || missing=\"$missing $t\"\ndone\ncommand -v genisoimage >/dev/null 2>&1 || command -v cloud-localds >/dev/null 2>&1 \\\n  || missing=\"$missing seed-builder(genisoimage|cloud-localds)\"\n# Hardware acceleration is host-specific — check the right accelerator for this OS.\ncase \"$(uname -s)\" in\n  Linux)  [ -w /dev/kvm ] || missing=\"$missing kvm(/dev/kvm-not-writable)\" ;;\n  Darwin) qemu-system-x86_64 -accel help 2>&1 | grep -q hvf || missing=\"$missing hvf-accelerator\" ;;\n  *)      echo \"NOTE: confirm this host has a QEMU hardware accelerator and adjust the boot accel= below\" ;;\nesac\n[ -n \"$missing\" ] && echo \"MISSING:$missing\" || echo \"all prerequisites present\"\n```\n\nIf the output is anything but `all prerequisites present`, name the missing\ntools/capabilities to the user and ask them to install them however their OS does — do NOT\nguess a distro package name. (`genisoimage` ships in `genisoimage` or `cdrkit` on\nDebian/Ubuntu; CI uses `genisoimage`. If only `cloud-localds` is available it can build the\nsame cidata image — attach it as the cdrom in Step 3.) A missing hardware accelerator — no\nwritable `/dev/kvm` on Linux, no HVF on macOS — is a host/BIOS/virtualization fix only the\nuser can make; the VM is unusably slow without it, so don't fall back to TCG emulation.\n\nThis flow is verified on Linux+KVM through VM bring-up, the `/build` disk plus\n`doas`/`datasize` setup, dependency install, the source rsync, and the unsupported-build\nrejection smoke (`.ci-scripts/openbsd-reject-unsupported-builds.sh`, which runs `cmake …\n-DPONY_USES=…` and so needs no LLVM). The LLVM build (`cmake -P lib/build-libs.cmake`), the subsequent\n`cmake --preset debug`/build — which require the built LLVM (CMake's `find_package(LLVM)`\nfails without it) — and the test suites were **not** run locally; those steps mirror\n`.ci-scripts/bsd/openbsd-provision.bash` and the tier-3 `openbsd` job, which CI exercises\nend-to-end and keeps green. The guest side is OS-agnostic, so macOS goes through the same\nsteps with the HVF accelerator (`accel=kvm:hvf` in Step 3 selects it).\n\n## Setup\n\nPick a persistent VM directory and an OpenBSD version that **matches ponyc CI** (the tier-3\n`openbsd` job in `.github/workflows/ponyc-tier3.yml` runs OpenBSD 7.9; the exact image URL,\nincluding the dated build tag, is in `.ci-scripts/bsd/openbsd-provision.bash`). Use the same\ntwo values in every block below.\n\n### 1. Download the image + create the build disk (one time)\n\nOpenBSD ships no official cloud image, so CI uses the community\n`hcartiaux/openbsd-cloud-image` GitHub release — a plain qcow2 that boots straight to a\ncloud-init-configured system. Pin the **full dated release tag** that\n`openbsd-provision.bash` references (a bare `v7.9` is not a valid asset path). The root\nimage is left at its shipped size; the build does not live on it.\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9\nmkdir -p \"$VMDIR\" && cd \"$VMDIR\"\ncurl -fL --retry 3 -o openbsd.qcow2 \\\n  \"https://github.com/hcartiaux/openbsd-cloud-image/releases/download/v7.9_2026-06-03-20-46/openbsd-generic.qcow2\"\n# Build-workspace disk. OpenBSD's default disklabel confines builds to /home\n# (~10.8G), which the LLVM 22 build overflows, so build on a dedicated disk.\nqemu-img create -f qcow2 openbsd-data.qcow2 50G\n```\n\n### 2. Key + cloud-init seed\n\nOpenBSD's cloud-init reads a cidata seed at boot: it creates the `openbsd` user (in\n`wheel`) with your ssh public key, and writes `/etc/doas.conf` so the `openbsd` user gets\npasswordless `doas`. Build the seed as an ISO and attach it as a CD-ROM (Step 3).\n\n```sh\nVMDIR=~/vms/openbsd-7.9; cd \"$VMDIR\"\ntest -f vm_key || ssh-keygen -t ed25519 -f vm_key -N \"\" -q\ncat > user-data <<USERDATA\n#cloud-config\nusers:\n- name: openbsd\n  groups: wheel\n  ssh_authorized_keys:\n  - $(cat vm_key.pub)\nwrite_files:\n- path: /etc/doas.conf\n  content: |\n    permit nopass keepenv :wheel\n  owner: root:wheel\n  permissions: '0600'\nUSERDATA\ncat > meta-data <<'METADATA'\ninstance-id: openbsd-dev\nlocal-hostname: openbsd-dev\nMETADATA\ngenisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data\n```\n\n### 3. Boot (daemonized, persistent)\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9; cd \"$VMDIR\"\nqemu-system-x86_64 \\\n  -name openbsd-${OPENBSD_VERSION} \\\n  -machine pc,accel=kvm:hvf -cpu host -smp 8 -m 12G \\\n  -drive file=openbsd.qcow2,format=qcow2,if=virtio \\\n  -drive file=openbsd-data.qcow2,format=qcow2,if=virtio \\\n  -drive file=seed.iso,media=cdrom \\\n  -netdev user,id=net0,hostfwd=tcp::2222-:22 \\\n  -device virtio-net-pci,netdev=net0 \\\n  -display none -pidfile openbsd.pid -daemonize\n```\n\n(`accel=kvm:hvf` picks KVM on Linux or HVF on macOS and errors if neither is available — it\nnever silently falls back to slow TCG; CI uses bare `accel=kvm` because it always runs on\nLinux. `-smp`/`-m` are speed knobs; CI uses 4 CPUs / 12G. The build is capped by the 8192M\n`datasize` limit per process, not by total RAM. `hostfwd 2222->22` is the ssh port — to run\ntwo OpenBSD VMs at once, give each its own `$VMDIR` and a distinct hostfwd port, and pass\nthat port to every ssh/scp/rsync `-p`. The seed is attached as a **cdrom** — that is how\nOpenBSD's cloud-init finds the cidata volume; do not copy FreeBSD's raw-virtio-disk seed\nform here. This boot command is adapted from `.ci-scripts/bsd/openbsd-provision.bash`: it\nadds `-pidfile`/`-smp 8`, drops the CI host-bootstrap, and like CI uses no rng or monitor\ndevice.)\n\n### 4. Wait for ssh (first boot runs cloud-init)\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9; cd \"$VMDIR\"\nup=\"\"\nfor i in $(seq 1 150); do   # ~5 min, matching CI's timeout\n  if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=2 -i vm_key -p 2222 \\\n       openbsd@localhost true 2>/dev/null; then up=1; echo \"SSH up\"; break; fi\n  sleep 2\ndone\n[ -n \"$up\" ] || echo \"SSH NEVER CAME UP — see recovery below.\"\n```\n\nIf ssh never comes up, this VM has no VGA console to look at (it booted headless), so\ndiagnose in this order:\n\n- **Is the process alive?** `pgrep -af \"qemu-system-x86_64 -name openbsd-${OPENBSD_VERSION}\"`.\n  If it's gone, the boot died early (usually a bad/incomplete image from Step 1) — re-check\n  the Step 1 download and re-boot.\n- **Watch the boot.** The image logs to the serial console, so re-boot with a serial log\n  added — `kill \"$(cat openbsd.pid)\"`, then re-run the Step 3 boot command with\n  `-serial file:$VMDIR/console.log` appended — and read `console.log` to see how far it got.\n- **ssh actively *refuses* the key (vs. just timing out)?** That means cloud-init never\n  applied the seed. Confirm `seed.iso` is attached as a **cdrom** drive in the boot command\n  (Step 3) and was built in Step 2, then re-boot.\n\n### 5. One-time build-disk setup (disklabel, newfs, mount)\n\nOpenBSD sees the second virtio disk as `sd1`. Give it a single 4.2BSD partition spanning\nthe disk, `newfs` it, mount it at `/build`, and `chown` it to `openbsd` so the later rsync\nand build run without root. The `printf` drives the interactive `disklabel -E` editor (`a a`\nadd partition, then default offset/size/type, `w` write, `q` quit) — it is fiddly and copied\nverbatim from CI. **Run this once:** `newfs` reformats, so re-running destroys whatever is\non `/build`.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\nprintf 'a a\\n\\n\\n\\nw\\nq\\n' | doas disklabel -E sd1\ndoas newfs /dev/rsd1a\ndoas mkdir -p /build\ndoas mount /dev/sd1a /build\ndoas chown openbsd:wheel /build\ndf -h /build\nEOF\n```\n\nIf the `doas` commands here fail (permission denied, or `doas` prompts for a password),\ncloud-init's `write_files` never created `/etc/doas.conf` — the seed didn't apply. Don't\nretype the disk commands; nothing here works until `doas` does, so work through the Step 4\nrecovery (is the VM up? is `seed.iso` attached as a **cdrom**?) first.\n\n### 6. Install deps + raise the datasize limit\n\n`doas pkg_add` installs the build tools (the `python%3`/`rsync--` spellings are correct —\nsee Gotchas). Then raise the per-process `datasize` ceiling for the `openbsd` user — and\ntreat this as intent, not a fixed substitution. OpenBSD's `/etc/login.conf` caps each\nprocess's data segment in the `default` login class (the class the `openbsd` user falls\nunder), and the LLVM build needs roughly 4 GB, so both `datasize-cur` and `datasize-max`\nmust end up at least `8192M` (what CI sets). On the stock OpenBSD 7.9 image both default to\n`1536M`, so the two `sed`s below do it. But the goal is the `8192M` ceiling, not the literal\n`1536M`: if a future image ships a different default the `sed`s match nothing, so read the\ncurrent values (the trailing `grep` prints them) and, if either is below `8192M`, raise it\nto `8192M` directly — adjust the `sed` pattern or edit `/etc/login.conf`. Confirm both read\n`8192M` before continuing; too low and the libs build dies with allocation failures partway\nthrough, far from this step. The edit persists on the root disk and re-running is a no-op.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ndoas pkg_add -u && doas pkg_add cmake gmake git python%3 rsync--\n# Raise the openbsd user's datasize cap to 8192M. The stock 7.9 image\n# defaults both to 1536M; if a future image differs, the sed matches\n# nothing — read the grep below and set both to 8192M however fits.\ndoas sed -i 's/datasize-max=1536M/datasize-max=8192M/' /etc/login.conf\ndoas sed -i 's/datasize-cur=1536M/datasize-cur=8192M/' /etc/login.conf\ngrep -E 'datasize-(cur|max)=' /etc/login.conf   # confirm both now read 8192M\nEOF\n```\n\n### 7. Rsync the ponyc checkout in (exclude the host `build/`)\n\nRun from your ponyc checkout. Exclude the top-level `build/` (host artifacts; the VM builds\nits own — this `--exclude` is a deliberate divergence from CI, whose fresh checkout has none\nto skip). Keep `.git` (CMake runs `git rev-parse`). The vendored LLVM submodule under\n`lib/llvm/src` IS transferred — the VM needs it for the libs build.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nrsync -az --exclude='/build' \\\n  -e \"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222\" \\\n  \"$(git rev-parse --show-toplevel)/\" openbsd@localhost:/build/ponyc/\n```\n\n## Using the VM\n\nThe build uses OpenBSD's base clang — no `CC`/`CXX`/`LD_LIBRARY_PATH` exports (that is the\nDragonFly-only gcc13 dance). `cmake -P lib/build-libs.cmake` builds the vendored LLVM and is the multi-hour long\npole — run it detached, then poll the log until it ends with `libs DONE rc=0` (a nonzero rc\nmeans it failed — read the log above the marker). Keep `-DJOBS=4` (what CI uses):\neach parallel compile is bounded by the 8192M `datasize` cap, so higher parallelism is the\nfirst thing to suspect on a libs out-of-memory.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncat > /build/run-libs.sh <<'S'\n#!/bin/sh\ncd /build/ponyc && cmake -DJOBS=4 -P lib/build-libs.cmake\necho \"libs DONE rc=$?\"\nS\nchmod +x /build/run-libs.sh\nnohup /build/run-libs.sh > /build/libs-build.log 2>&1 &\necho \"libs building pid $!\"\nEOF\n# poll until done: $SSH /bin/sh -c 'tail -3 /build/libs-build.log'  -> wait for \"libs DONE rc=0\"\n```\n\nOnce `libs` is built (it persists in the VM), build and test with the same shell. Do not run\nthis block until the poll shows `libs DONE rc=0` — even `cmake --preset debug` needs\nthe built LLVM (CMake's `find_package(LLVM)` fails without it), so it cannot run before\nthe libs build:\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncd /build/ponyc\ncmake --preset debug\ncmake --build --preset debug\nctest --preset debug -L ci-core\nEOF\n```\n\nTo iterate on a fix: edit on the host, re-run Step 7's `rsync` (it's incremental — only\nchanged files are sent), then re-run the build block above (long rebuilds should also be\ndetached + polled). For exact CI parity, the tier-3 `openbsd` job\n(`.github/workflows/ponyc-tier3.yml`) runs more than this. In order: an\nunsupported-build-rejection smoke that runs **before** the libs build (it needs no LLVM —\n`.ci-scripts/openbsd-reject-unsupported-builds.sh` asserts `use=address_sanitizer`,\n`thread_sanitizer`, `undefined_behavior_sanitizer`, `coverage`, and `valgrind` are\neach rejected with \"not supported on OpenBSD\"); then debug configure/build; a `--static`\nembedded-LLD link smoke (OpenBSD static is static-PIE, so the binary's `file` output reads\n\"shared object\" — the smoke instead asserts `readelf -d` shows **no** `(NEEDED)` entries);\nthe debug `ci-core` suite; the self-hosted tool tests (`pony-doc-tests`/`pony-lint-tests`/\n`pony-lsp-tests`); and a release build + the `ci-core` suite. Consult that job when validating a\nCI-matching issue.\n\n## Lifecycle\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9; cd \"$VMDIR\"\npgrep -af \"qemu-system-x86_64 -name openbsd-${OPENBSD_VERSION}\"        # status\nssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 openbsd@localhost   # interactive shell\n# stop: kill the process. Disks persist for next time. The pgrep/pkill are scoped to this\n# version so a second OpenBSD VM isn't matched; the pidfile path is per-$VMDIR anyway.\nkill \"$(cat openbsd.pid)\" 2>/dev/null || pkill -f \"qemu-system-x86_64 -name openbsd-${OPENBSD_VERSION}\"\n```\n\nTo restart later, re-run the step-3 boot command (the disks and installed deps persist; you\ndo not redo setup). On a reboot, ssh-as-`openbsd` works immediately and `doas` still works\n(`/etc/doas.conf` persisted), and the raised `datasize` limit is still in `/etc/login.conf`\n— but the `/build` data disk is **not** auto-mounted (there is no `/etc/fstab` entry), so\nremount it before building: `doas mount /dev/sd1a /build`. The source under `/build/ponyc`\nis preserved on the data disk across the remount. For a long-lived VM, add the mount to\n`/etc/fstab` so it survives reboots.\n\n## How this relates to CI (don't re-add the CI-only steps)\n\nThis mirrors `.ci-scripts/bsd/openbsd-provision.bash` (the source of truth — keep this skill\nin sync if it changes). The local setup deliberately differs from CI, and each difference is\nsafe:\n\n- Skips CI host-bootstrap (free disk space, `apt-get` qemu, make `/dev/kvm` writable) — your\n  host already has qemu and a usable hardware accelerator (Step 0 verified it).\n- Persistent VM instead of built-fresh-and-killed per run.\n- ssh adds `-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR` to quiet the host-key\n  warning: a persistent VM reused on `localhost:2222` collides with the stale `known_hosts`\n  entry from a prior VM. CI's ephemeral runners never reuse the port, so\n  `openbsd-provision.bash` doesn't bother.\n- `-smp 8` / `-m 12G` for build speed (CI uses 4 / 12G), and a `-pidfile` for the lifecycle\n  commands.\n- `rsync --exclude='/build'` to skip the host's build artifacts (CI's fresh checkout has\n  none).\n- No GHCR libs cache (that's token-gated CI plumbing) — you just run `cmake -P lib/build-libs.cmake` once.\n\nThe FreeBSD and DragonFly CI VMs follow the same shape\n(`.ci-scripts/bsd/{freebsd,dragonfly}-provision.bash`) and each has its own skill:\n`create-freebsd-dev-env` (also cloud-init, but base libc++, an `expect`/`su` root\nbootstrap, and the source under `/home/freebsd` rather than a `/build` disk) and\n`create-dragonfly-dev-env` (gcc-only, and it drives a VGA console). This skill is\nOpenBSD-only; load a sibling if you need one of those.\n",".claude/skills/update-ssl-builders/SKILL.md":"---\nname: update-ssl-builders\ndescription: Twice-yearly workflow for bumping ponyc CI SSL builder images (OpenSSL, LibreSSL) to their latest series releases. Invoked via /update-ssl-builders.\ndisable-model-invocation: false\n---\n\n# Update SSL Builders\n\nponyc CI tests `packages/net/` and `packages/crypto/` against multiple SSL implementations. Each has its own Docker builder image under `.ci-dockerfiles/ubuntu26.04-builder-with-<library>-<version>/`. This skill walks through bumping those builders to the latest upstream releases.\n\n## Background\n\n**One version per series**: These are internal CI builders. Each series keeps exactly one version — an update replaces the old version with the new one in a single PR.\n\n**OpenSSL 1.1.1 is EOL**: The `openssl-1.1.1w` builder exists for backward-compatibility testing. This skill does not update or drop it.\n\n**Builders are amd64-only**: Each builder directory contains two files: `Dockerfile` and `build-and-push.bash`. No multiplatform build, no `combine-images.bash`, no `README.md`.\n\n**SSL flag mapping**: The `ssl-backends-test` matrix in `pr.yml` maps each SSL library to a Pony compiler flag:\n\n| Library series | Pony flag |\n|---|---|\n| `libressl-*` | `-Dlibressl` |\n| `openssl-3.*` | `-Dopenssl_3.0.x` |\n| `openssl-4.*` | `-Dopenssl_4.0.x` |\n\nIf a new major version line appears upstream (e.g., OpenSSL 5.x) that has no existing Pony compiler flag, do not add a builder. Open a task issue on this repo to add support for testing with the new version, noting the upstream release.\n\n**Three workflow files reference SSL builders**:\n\n- `rebuild-ssl-builder-images.yml` — `matrix.ssl` list; rebuilds all builders when the base image changes.\n- `build-builder-image.yml` — `inputs.builder-name` dropdown and per-builder job blocks; builds a single chosen builder on demand.\n- `pr.yml` — `ssl-backends-test.strategy.matrix.include`; runs `net` and `crypto` tests against each builder image.\n\n## Phase 1: Inventory\n\nList all `ubuntu26.04-builder-with-*` directories under `.ci-dockerfiles/` and group them by library + series. Ignore `openssl-1.1.1w`.\n\n## Phase 2: Fetch upstream releases\n\n- LibreSSL: https://www.libressl.org/releases.html\n- OpenSSL: https://openssl-library.org/source/\n\nFor each library, find the highest version number available across all currently-supported upstream branches.\n\n## Phase 3: Compute updates and confirm\n\n**Series definition (deliberate project policy)**: One major version line = one series. LibreSSL 3.x, LibreSSL 4.x, OpenSSL 3.x, OpenSSL 4.x are each a single series. This collapses OpenSSL's concurrently-supported LTS branches (e.g., 3.0/3.4/3.5/3.6) into one series — a new release in any 3.x branch counts as \"the latest of OpenSSL 3.x.\"\n\nFor each series (excluding OpenSSL 1.x):\n- If the upstream latest is newer than what we have, plan to **replace** the current version with the new one.\n- If upstream is not newer, no changes for that series.\n\nPresent the plan to the user and wait for explicit confirmation before making any changes.\n\n## Phase 4: Per-update workflow (one PR per series)\n\nEach series update gets its own PR. The PR replaces the old builder with the new one.\n\n1. Create a new branch.\n2. Create a new directory `.ci-dockerfiles/ubuntu26.04-builder-with-<library>-<new-version>/`.\n3. Copy the two files from the current version in the same series: `Dockerfile` and `build-and-push.bash`.\n4. Bump version references in each file:\n   - **Dockerfile**: The version ARG (`OPENSSL_VERSION` or `LIBRESSL_VERSION`). The download URL pattern is stable within a major version line and should not need changing.\n   - **build-and-push.bash**: The `NAME` variable (full GHCR image path including the version) and the `BUILDER` variable (strip dots and dashes from the version, e.g., `ssl-openssl372`, `ssl-libressl430`).\n5. Delete the old directory `.ci-dockerfiles/ubuntu26.04-builder-with-<library>-<old-version>/`.\n6. Edit `.github/workflows/rebuild-ssl-builder-images.yml`: replace the old `<library>-<old-version>` entry in `matrix.ssl` with `<library>-<new-version>`.\n7. Edit `.github/workflows/build-builder-image.yml`:\n   - Replace the old builder name with the new one in the `options` list.\n   - Replace the old job block with a new one. The job key uses underscores for dots (e.g., `ubuntu26_04-builder-with-openssl-3_7_0`). Copy the structure from the old job, updating the `if:` condition, `name:` field, and `run:` step to reference the new directory.\n8. Commit, push, open PR. Do **not** update `pr.yml` yet — there is no image to reference.\n9. Dispatch the build workflow on the branch to build and push the new image to GHCR:\n   ```\n   gh workflow run build-builder-image.yml --ref <branch> -f ref=<branch> -f builder-name=ubuntu26.04-builder-with-<library>-<new-version>\n   ```\n   `--ref <branch>` is required — the new builder name only exists in the branch's `choice` input list. The image is tagged with today's date (`YYYYMMDD` from `build-and-push.bash`).\n10. Wait for the workflow run to complete successfully. Verify the image exists on GHCR:\n    ```\n    gh api orgs/ponylang/packages/container/ponyc-ci-ubuntu26.04-builder-with-<library>-<new-version>/versions --jq '.[0].metadata.container.tags'\n    ```\n11. Edit `.github/workflows/pr.yml`: in `ssl-backends-test.strategy.matrix.include`, replace the old entry with the new one:\n    ```yaml\n    - ssl-name: <library>-<new-version>\n      ssl-flag: '<flag from mapping table>'\n      image: ghcr.io/ponylang/ponyc-ci-ubuntu26.04-builder-with-<library>-<new-version>:<YYYYMMDD>\n    ```\n12. Commit and push.\n13. Wait for PR CI to pass. The `ssl-backends-test` suite should now include the new builder and its `net`/`crypto` tests should pass.\n14. Squash-merge the PR.\n\nExisting tagged images for the old version on GHCR are left intact — they simply stop receiving updates because they are no longer in the rebuild workflow's matrix.\n",".claude/skills/upgrade-llvm/SKILL.md":"---\nname: upgrade-llvm\ndescription: Load when upgrading the vendored LLVM submodule in ponyc. Covers the per-version commit strategy, submodule mechanics, patch handling, hash updates, and common API migration patterns.\ndisable-model-invocation: false\n---\n\n# Upgrading the vendored LLVM in ponyc\n\nponyc vendors LLVM as a submodule at `lib/llvm/src`, pinned by hash in\n`lib/CMakeLists.txt` and patched from `lib/llvm/patches/`. Upgrading it means\nmoving the pin, re-checking the patches, fixing whatever LLVM removed or\nchanged under `src/libponyc/codegen/`, and rebuilding LLVM from source.\n\nRun every command from the repo root.\n\n## Gotchas that will bite you (read first)\n\n- **The submodule is a shallow clone.** `lib/CMakeLists.txt` clones it with\n  `--depth 1` and fetches no tags. A plain `git fetch origin tag llvmorg-XX.Y.Z`\n  deepens it to the whole of llvm-project's history up to that tag. Pass\n  `--depth 1`.\n- **A patch that fails the forward check has not necessarily been upstreamed.**\n  The forward check also fails when the patch is already applied to the tree,\n  and when `git apply` cannot read the tree at all. Work through step 4 before\n  you delete anything.\n- **Don't `rm -rf build`.** That directory holds every configured build tree,\n  not just LLVM's. The vendored LLVM builds in `build/build_libs` and installs\n  to `build/libs`; remove only those two.\n- **Stage the submodule pointer before you build.** `cmake -P\n  lib/build-libs.cmake` runs `git submodule update --init`, which resets\n  `lib/llvm/src` to the recorded pointer. A pointer change that exists only in\n  the working tree is lost.\n- **ponyc still builds when an intrinsic's signature changes.** The failure\n  comes later, when ponyc compiles a Pony program, so run the tests and not\n  just the build.\n\n## Strategy\n\nUpgrade one major version at a time, and give each version its own commit. One\nPR can carry more than one version. Separate commits show which LLVM version\neach source change came from, and let you bisect the branch when something\nbreaks. ponylang repos squash-merge, so those commits become one commit on\n`main` — you can only bisect them while the branch is open.\n\n## Steps\n\nSteps 1, 8, and 9 run once for the PR. Steps 2 through 7 repeat for each major\nversion you move through.\n\n### 1. Branch\n\nBranch off `main`.\n\n### 2. Update the submodule\n\n```bash\n# Discard any patches applied to the tree\ngit -C lib/llvm/src checkout -- .\n# Fetch and check out the new tag, keeping the clone shallow\ngit -C lib/llvm/src fetch --depth 1 origin tag llvmorg-XX.Y.Z\ngit -C lib/llvm/src checkout llvmorg-XX.Y.Z\n```\n\n`git checkout -- .` throws away every modification to a tracked file in the\nsubmodule, including a patch you rebased by hand. Save that work outside the\ntree first.\n\n### 3. Update `LLVM_DESIRED_HASH`\n\nGet the new commit hash:\n\n```bash\ngit -C lib/llvm/src rev-parse HEAD\n```\n\nSet `LLVM_DESIRED_HASH` in `lib/CMakeLists.txt` to this value.\n\n### 4. Check the patches\n\nThe build applies every `.diff` in `lib/llvm/patches/`, so check each one.\nRun the same check the build runs, with the same flags:\n\n```bash\ngit -C lib/llvm/src apply --check -p 1 --ignore-whitespace --whitespace=nowarn \\\n  ../patches/<patch>.diff\n```\n\nIf that fails, run the reverse check before concluding anything:\n\n```bash\ngit -C lib/llvm/src apply --reverse --check -p 1 --ignore-whitespace \\\n  --whitespace=nowarn ../patches/<patch>.diff\n```\n\n- Forward check passes: the patch still applies. Keep it.\n- Forward fails, reverse passes: the change is already in the tree. On a tree\n  you reset in step 2, LLVM upstreamed it, so delete the patch file. On a tree\n  you did not reset, an earlier build applied it — reset and check again.\n- Both fail: either LLVM moved the code the patch touches, or `git apply` could\n  not read the tree. In the second case `git apply` reports `fatal: not a git\n  repository`, which comes from a dangling submodule gitlink — a `.git` file\n  pointing at a `.git/modules/...` target that isn't there, left behind when\n  the tree was copied or rsynced without the parent's `.git/modules`.\n  Otherwise, rebase the patch onto the new source rather than deleting it.\n\n### 5. Commit the submodule pointer\n\n```bash\ngit add lib/llvm/src lib/CMakeLists.txt\ngit commit -m \"Upgrade LLVM AA.B.C -> XX.Y.Z\"\n```\n\n`cmake -P lib/build-libs.cmake` resets the submodule to the pointer recorded in\nthe index, so stage this before step 6 or the new pointer is lost. Amend this\ncommit as the next two steps produce changes.\n\n### 6. Build, and fix what breaks\n\n```bash\nrm -rf build/libs build/build_libs         # Drop the old LLVM; the next command rebuilds it\ncmake -DJOBS=<n> -P lib/build-libs.cmake   # Build LLVM; see BUILD.md for picking <n>\ncmake --preset release                     # Configure the ponyc build\ncmake --build --preset release             # Build ponyc\n```\n\n`JOBS` defaults to 4. BUILD.md says to pass your own core count and keep it\nmodest, because LLVM is memory-hungry. On Windows use the Windows presets\n(BUILD.md).\n\nTwo kinds of failure come out of this step.\n\n**A patch-hash mismatch**, if you added, removed, or edited a patch file in\nstep 4. The lib configure step prints both hashes and stops:\n\n```\nPatch hash actual '<actual>' does not match desired '<desired>'\n```\n\nCopy `<actual>` into `PATCHES_DESIRED_HASH` in `lib/CMakeLists.txt`. The hash\nis a SHA256 over the seed string `needed_if_no_patches` and the SHA256 of each\npatch file's text with newlines squashed to spaces, which is awkward to\nreproduce in a shell — let CMake compute it. If no patches remain at all, the\nvalue is `SHA256(\"needed_if_no_patches\")` =\n`3e16c097794cb669a8f6a0bd7600b440205ac5c29a6135750c2e83263eb16a95`.\n\n**Compile errors**, from LLVM APIs that were removed or changed. See \"Common\nmigration patterns\" below. Fix them, amend the commit, and build again.\n\n### 7. Run the tests\n\n```bash\nctest --preset release -L ci-core\n```\n\nThe `ci-core` label covers `libponyc.tests`, where the codegen assertions live,\nalong with the full-program, stdlib, and examples tests.\n\nThis is narrower than what the PR will run: CI runs `ci-core` in both debug and\nrelease on Linux, macOS, and Windows, and separately builds and runs\n`pony-compiler-tests`. A failure that only shows up in one config, or on one\nplatform, will not appear here.\n\nTo reproduce a tier-3 BSD failure, the `create-dragonfly-dev-env`,\n`create-freebsd-dev-env`, and `create-openbsd-dev-env` skills stand up a local\nVM matching that CI job.\n\n### 8. Add a release note\n\nAn LLVM upgrade is user-facing. Add `.release-notes/update-llvm-to-XX.md`:\n\n```markdown\n## Update to LLVM XX.Y.Z\n\nWe've updated the LLVM version used to build Pony from AA.B.C to XX.Y.Z.\n```\n\nDo not edit `next-release.md`; CI aggregates the individual files.\n\n### 9. Push and open the PR\n\nTitle the PR `Update to LLVM XX.Y.Z`, matching the release note. Before merging,\na Pony team member adds a `changelog - changed` label, which puts a line built\nfrom the PR title into that CHANGELOG section. That is a separate mechanism from\nthe release note file, which reaches `next-release.md` on its own.\n\n## Common migration patterns\n\n### `LLVMConst*` to `LLVMBuild*` (constant expression removal)\n\nLLVM removed a number of constant expression functions. The `LLVMBuild*`\nequivalents constant-fold when given constant operands, so they compute the\nsame thing; they take a builder and a name as well:\n\n```c\n// Before:\nreturn LLVMConstShl(l_value, r_value);\n// After:\nreturn LLVMBuildShl(c->builder, l_value, r_value, \"\");\n```\n\n`LLVMConstICmp`, `LLVMConstFCmp`, `LLVMConstShl`, and `LLVMConstMul` are gone\nfrom LLVM 22.1.6's `Core.h`. `LLVMConstAdd` and `LLVMConstSub` are still there\nand carry no deprecation, but ponyc calls none of the six.\n\n### Deprecated to newer API variants\n\nOn the clang and gcc builds ponyc's C files compile with `-Werror`, so a\ndeprecated LLVM C function fails the build if its declaration carries\n`LLVM_ATTRIBUTE_C_DEPRECATED`, which produces a compiler warning. The MSVC build\npasses `/wd4996` alongside `/WX`, so the same call compiles clean on Windows.\n\nA function marked deprecated only in a doc comment produces no warning\nanywhere. The headers use two spellings for that, `@deprecated` and a plain\n`Deprecated: use X instead`, so grep for both when you want to know what ponyc\ncalls that upstream has marked. The list below is a starting point, not a\ncomplete one.\n\n`LLVMGetMDKindID` carried the attribute rather than a doc comment, so it did\nfail the build. That migration is done; `src/` now calls\n`LLVMGetMDKindIDInContext`. `LLVMBuildNUWNeg` also carries the attribute — its\nreplacement is `LLVMBuildNeg` followed by `LLVMSetNUW`, which ponyc wraps as\n`LLVMSetNoUnsignedWrap` in `host.cc`. `LLVMBuildNSWNeg` is not deprecated.\n\n### A new link-time dependency\n\nAn LLVM upgrade can make ponyc need a system library it did not need before,\nand the need can be Windows-only, so a local Linux build will not show it. The\nLLVM 21 upgrade (`22e84c4a1`) added `ntdll.lib` to the Windows link line in\n`src/libponyc/platform/vcvars.c` and to three `CMakeLists.txt` files.\n\n### Intrinsic signature changes\n\nLLVM can change an intrinsic's signature in a major version. These do not show\nup as compile errors; ponyc builds clean. They surface as a module verification\nfailure when ponyc compiles a Pony program (`Incorrect number of arguments\npassed to called function!`), which is why step 7 runs the tests.\n\n- **LLVM 22**: `llvm.lifetime.start` and `llvm.lifetime.end` dropped their\n  leading `i64 size` argument and now take a single pointer. ponyc emits these\n  in `gencall_lifetime_start` and `gencall_lifetime_end` (`gencall.c`). Drop\n  the size operand, drop the `type` parameter the size was computed from, and\n  call with one argument:\n\n```c\n// Before (2 args: size, ptr):\nLLVMValueRef args[2];\nargs[0] = LLVMConstInt(c->i64, size, false);\nargs[1] = ptr;\nLLVMBuildCall2(c->builder, func_type, func, args, 2, \"\");\n// After (1 arg: ptr):\nLLVMBuildCall2(c->builder, func_type, func, &ptr, 1, \"\");\n```\n\n`CodegenTest.LifetimeIntrinsicsHaveSinglePointerArgument` in\n`test/libponyc/codegen.cc` asserts the emitted argument count. When a later\nLLVM changes another intrinsic's signature, assert its argument count the same\nway.\n\n### C++ API changes\n\nponyc also embeds LLD (`genexe.cc`) and clang's frontend (`gencshim.cc`). Those\nC++ APIs carry no stability guarantee and break more often than LLVM's C API.\n\n- **LLVM 19**: `DIBuilder::insertDeclare` returns `DbgInstPtr` (PointerUnion)\n  instead of `Instruction*`. If callers don't use the return value, change the\n  wrapper's return type to `void`.\n- **LLVM 20**: `Intrinsic::getDeclaration` to\n  `Intrinsic::getOrInsertDeclaration`.\n- **LLVM 20**: Optimizer extension point callbacks\n  (`registerOptimizerEarlyEPCallback`, `registerOptimizerLastEPCallback`) now\n  take a `ThinOrFullLTOPhase` parameter in the lambda.\n- **LLVM 21**: `Attribute::NoCapture` to\n  `Attribute::getWithCaptureInfo(ctx, CaptureInfo::none())`.\n- **LLVM 21**: `LintPass()` requires a `bool AbortOnError` parameter:\n  `LintPass(false)`.\n- **LLVM 22**: `createTargetMachine` takes a `const Triple&` instead of a\n  `StringRef`; wrap with `Triple(opt->triple)`. ponyc made this change under\n  LLVM 21, in `3eae0a119`, while the `StringRef` overload was still there and\n  deprecated.\n\n## APIs to check on every upgrade\n\nAPIs ponyc calls that a future LLVM may break:\n\n| API | Location | Likely replacement |\n|-----|----------|-------------------|\n| `LLVMArrayType` | codegen.c, gendesc.c, genident.c | `LLVMArrayType2` (`uint64_t` count) |\n| `LLVMConstArray` | gendesc.c, genident.c | `LLVMConstArray2` (`uint64_t` count) |\n| `LLVMConstStringInContext` | codegen.c | `LLVMConstStringInContext2` (`size_t` length) |\n| `LLVMConstNeg` | genoperator.c | `LLVMBuildNeg` |\n| `LLVMConstNot` | genoperator.c | `LLVMBuildNot` |\n| `LLVMConstXor` | genoperator.c | `LLVMBuildXor` |\n| `LLVMConstTrunc` | genreference.c | `LLVMBuildTrunc` |\n\nThe first three carry an `@deprecated` doc comment; the other four are not\ndeprecated. The build fails once one of them gains the deprecation attribute or\nis removed. Re-check this table against the new headers as part of the upgrade.\n\n## Files typically modified\n\n- `lib/CMakeLists.txt` — `LLVM_DESIRED_HASH`, `PATCHES_DESIRED_HASH`\n- `lib/llvm/patches/` — patch files, added or removed\n- `.release-notes/` — the release note\n- `src/libponyc/codegen/genoperator.c` — constant expression replacements\n- `src/libponyc/codegen/genreference.c` — constant expression replacements\n- `src/libponyc/codegen/gendebug.cc`, `gendebug.h` — debug info API changes\n- `src/libponyc/codegen/host.cc` — target machine, intrinsics\n- `src/libponyc/codegen/genopt.cc` — optimizer pass pipeline\n- `src/libponyc/codegen/gendesc.c` — array and descriptor construction\n- `src/libponyc/codegen/genident.c` — array construction\n- `src/libponyc/codegen/codegen.c` — core codegen types and helpers\n- `src/libponyc/codegen/gencontrol.c` — metadata APIs\n- `src/libponyc/codegen/gencall.c`, `gencall.h` — intrinsic emission\n  (lifetime markers, memcpy and memmove)\n- `src/libponyc/codegen/genexe.cc` — embedded LLD\n- `src/libponyc/codegen/gencshim.cc` — embedded clang frontend\n- `src/libponyc/platform/vcvars.c`, `src/ponyc/CMakeLists.txt`,\n  `test/libponyc/CMakeLists.txt`, `benchmark/libponyc/CMakeLists.txt` — link\n  dependencies\n- `test/libponyc/codegen.cc` — codegen assertions\n\nA bigger LLVM can also push a CI job past its disk limit. During the LLVM 22\nupgrade we moved the OpenBSD tier-3 job onto a dedicated 50 GB data disk; the\nscript that creates it is in `.ci-scripts/bsd/`.\n","AGENTS.md":"# ponyc\n\n<!-- contributor-only -->\n## Contributing with an AI assistant\n\nThis is a Pony project. The ponylang org maintains a set of LLM coding skills. Get set up with them before contributing:\n\n- **Not set up yet?** Install them once:\n\n  ```bash\n  git clone https://github.com/ponylang/llm-skills.git\n  cd llm-skills\n  python install.py\n  ```\n\n- **Already set up?** Make sure you're on the latest. If you installed with the script above, `git pull` in the directory where you cloned `llm-skills` and the symlinked skills update automatically — if you set them up another way, refresh them however that setup expects.\n\nSee the [llm-skills README](https://github.com/ponylang/llm-skills) for details and other harnesses.\n\nWhen you start working on this project, load the `pony-skills` skill — it tells your assistant which Pony skill to use for each task.\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md).\n<!-- /contributor-only -->\n\n## Building\n\nThe build uses CMake presets. See [BUILD.md](BUILD.md) for platform-specific instructions and build options.\n\nBuild ponyc in debug mode:\n\n```bash\ncmake --build --preset debug\n```\n\nThe output goes in `build/debug`. Use `--preset release` for a release build. The vendored LLVM libraries must be built first with `cmake -P lib/build-libs.cmake` — this only needs to run once (or when the LLVM submodule changes).\n\n## Testing\n\nTests are registered with ctest and grouped by label. Two labels matter:\n\n- **`ci-core`** — built by a normal `cmake --build --preset debug`. The C/C++ compiler tests (`libponyc.tests`), the runtime tests (`libponyrt.tests`), the stdlib suite, full-program integration tests, and grammar validation.\n- **`tools`** — **not** built by a normal `cmake --build --preset debug`. The self-hosted tool test suites: pony-compiler, pony-lsp, pony-lint, pony-doc, pony-dep, and pony-agent-server.\n\n### Core tests (ci-core)\n\nRun the full core suite:\n\n```bash\nctest --preset debug -L ci-core\n```\n\nTwo environment variables control Pony compilation features at test time:\n\n- `PONY_DEBUG=1` — compile Pony sources with `-d` (debug codegen)\n- `PONY_THIN_LTO=1` — compile Pony sources with `--thin-lto`\n\nThese apply to stdlib tests, full-program tests, examples, and tool builds. Set them as command-line prefixes (`PONY_DEBUG=1 ctest ...`) or `export` them. CI runs tests in both codegen modes — the first `ctest` pass sets `PONY_DEBUG=1`, the second runs only the Pony-compilation tests (`stdlib`, `full-programs`) without it. PR builds run only debug codegen; release-mode-checks, weekly-checks, tier-3, and rolling-releases run both.\n\nRun individual tests by name:\n\n- `ctest --preset debug -R libponyc.tests` — compiler C/C++ unit tests (GTest)\n- `ctest --preset debug -R libponyrt.tests` — runtime C/C++ unit tests (GTest)\n- `ctest --preset debug -R stdlib` — stdlib test suite\n- `ctest --preset debug -R full-programs` — compile-and-run integration tests\n- `ctest --preset debug -R validate-grammar` — checks `pony.g` against the compiler\n- `ctest --preset debug -R examples` — compiles all examples (not part of `ci-core`; runs locally and in the weekly `build-examples.yml` workflow)\n\n#### Per-module stdlib tests\n\nA single module's tests can be compiled and run without rebuilding the full stdlib suite:\n\n```bash\ncd build/debug && ./ponyc -b stdlib --checktree -Dopenssl_3.0.x --pic ../../packages/collections\n./stdlib --sequential\n```\n\nTo compile with debug codegen, add `-d`: `./ponyc -d -b stdlib ...`. To compile with thin LTO, add `--thin-lto`.\n\nThe SSL flag must match the installed SSL library: `-Dopenssl_3.0.x` for OpenSSL 3.x, `-Dopenssl_1.1.x` for OpenSSL 1.1.x, `-Dlibressl` for LibreSSL. CMake detects this automatically for `ctest` runs; the manual command needs it explicitly.\n\n### Tool tests\n\nTool test binaries must be built explicitly before running. Build one target, then run through ctest:\n\n- `cmake --build --preset debug --target pony-compiler-tests && ctest --preset debug -R pony-compiler-tests`\n- `cmake --build --preset debug --target pony-lsp-tests && ctest --preset debug -R pony-lsp-tests`\n- `cmake --build --preset debug --target pony-lint-tests && ctest --preset debug -R pony-lint-tests`\n- `cmake --build --preset debug --target pony-doc-tests && ctest --preset debug -R pony-doc-tests`\n- `cmake --build --preset debug --target pony-dep-tests && ctest --preset debug -R pony-dep-tests`\n- `cmake --build --preset debug --target pony-agent-server-tests && ctest --preset debug -R pony-agent-server-tests`\n\nBuild all tool test binaries at once with `cmake --build --preset debug --target tool-tests`, then run them with `ctest --preset debug -L tools`.\n\nThe first build of a tool test binary compiles from Pony source (~60s); the binary is not recompiled when nothing under its source tree changed. On Windows, use the `windows-x86-64-debug` preset the same way.\n\n### Linting tool source\n\nRun the `pony-lint` binary (built by a normal `cmake --build --preset debug`) against a tool's directory:\n\n```bash\ncd build/debug && PONYPATH=../../tools/lib/ponylang/pony_compiler ./pony-lint ../../tools/pony-lint/\n```\n\nThe same works for `../../tools/pony-lsp/`, `../../tools/pony-doc/`, `../../tools/pony-dep/`, and `../../tools/pony-agent-server/`.\n\n## Opening PRs\n\nBefore opening a PR, verify:\n\n- **Debug build succeeds** (if compiled code was changed): `cmake --build --preset debug` completes without errors.\n- **Relevant tests pass locally**: Run the tests that cover your change — see the Testing section above for the right commands. Don't skip a test suite because \"CI will catch it.\"\n- **pony-lint passes** (if Pony source was changed): Lint any Pony code you touched. See \"Linting tool source\" above for the command.\n- **Grammar validation passes** (if the parser or `pony.g` was changed): `ctest --preset debug -R validate-grammar`.\n- **Release notes updated** (if the change is user-facing): Load the `pony-release-notes` skill for what needs updating and how.\n\n## Adding threads or locks\n\nNever add a mutex or a new thread unless the idea came from the human operator or a committer expressly approved it. Pony's runtime is built on a deliberate concurrency model, and adding either is an architectural decision, not an implementation detail. If a change looks like it needs one, stop and raise it rather than writing it.\n\n## Declaring runtime FFI functions on Windows\n\nOn the Windows MSVC build, libponyrt's `.c` files compile as C++ (`src/libponyrt/CMakeLists.txt` sets `LANGUAGE CXX` on them). A `PONY_API` function whose definition is not inside a `PONY_EXTERN_C_BEGIN`/`PONY_EXTERN_C_END` region gets a C++-mangled symbol, which the stdlib FFI — referencing the plain C name — can't resolve at link (`lld-link: undefined symbol`). Linux, macOS, and BSD compile `.c` as C, so the failure is Windows-only and won't show up in a local build there. Every runtime `.c` already wraps its definitions in that region (see `lang/stat.c`, `lang/socket.c`); put any new `PONY_API` function inside it. To check a symbol's linkage, run `dumpbin /SYMBOLS <lib> | findstr <name>` from a vcvars64 shell: a C-linkage function shows the plain name, a mangled one shows the C++ form.\n\n## Dispatching workflows on a branch\n\n`gh workflow run <workflow> --ref <branch> -f ref=<branch>` — both flags are needed. `--ref` selects which branch the workflow YAML is read from; `-f ref=` sets the `inputs.ref` that the checkout step uses. Without `-f ref=`, the job checks out main regardless of `--ref`.\n","CLAUDE.md":"@AGENTS.md\n","benchmark/AGENTS.md":"# benchmark/\n\n## Conventions\n\n- Do not add coupling comments in `src/libponyrt/` pointing back to\n  `benchmark/`. Benchmarks depend on runtime constants — the coupling\n  risk is theirs, and the note goes on the benchmark side alone.\n","packages/net/AGENTS.md":"# Net package\n\nThe `net` package provides Pony's networking. It reworks the original stdlib `net` package around a different split: the connection logic lives in a plain `class` (`TCPConnection`, `TCPListener`, `UDPSocket`) that the user's `actor` holds and delegates to, rather than being baked into a single actor.\n\n<!-- contributor-only -->\n## Contributing with an AI assistant\n\nThis is a Pony project. The ponylang org maintains a set of LLM coding skills. Get set up with them before contributing:\n\n- **Not set up yet?** Install them once:\n\n  ```bash\n  git clone https://github.com/ponylang/llm-skills.git\n  cd llm-skills\n  python install.py\n  ```\n\n- **Already set up?** Make sure you're on the latest. If you installed with the script above, `git pull` in the directory where you cloned `llm-skills` and the symlinked skills update automatically — if you set them up another way, refresh them however that setup expects.\n\nSee the [llm-skills README](https://github.com/ponylang/llm-skills) for details and other harnesses.\n\nWhen you start working on this project, load the `pony-skills` skill — it tells your assistant which Pony skill to use for each task.\n\nRead [CONTRIBUTING.md](../../CONTRIBUTING.md).\n<!-- /contributor-only -->\n\n## Architecture\n\nA client connect subscribes one socket per resolved address at once (Happy Eyeballs). Whichever connects first becomes the connection's own event; the rest are stragglers, and each has to be unsubscribed and its fd closed when its event arrives. That is why every state handles `own_event` and `foreign_event` separately, and what `_UnconnectedClosing` drains.\n\n### Connection lifecycle\n\n`TCPConnection` tracks its lifecycle with explicit state objects — the `_ConnectionState` trait and its implementers in `_connection_state.pony` — rather than boolean flags.\n\n```\n_ConnectionNone     → _ClientConnecting\n                    → _Open              (server, plaintext)\n                    → _SSLHandshaking    (server, SSL)\n                    → _Closed            (SSL session creation failed)\n_ClientConnecting   → _Open              (connected, plaintext)\n                    → _SSLHandshaking    (connected, SSL)\n                    → _UnconnectedClosing (close, drain stragglers)\n                    → _Closed            (hard_close / all attempts failed)\n_SSLHandshaking     → _Open              (ssl_handshake_complete)\n                    → _Closed            (hard_close / SSL error)\n_Open               → _Closing           (close)\n                    → _TLSUpgrading      (start_tls)\n                    → _Closed            (hard_close)\n_TLSUpgrading       → _Open              (ssl_handshake_complete)\n                    → _Closed            (hard_close / TLS error)\n_Closing            → _Closed            (drained, or hard_close)\n_UnconnectedClosing → _Closed            (drained, or hard_close)\n```\n\n### UDP lifecycle\n\n`UDPSocket` uses the same state-object pattern with three states.\n\n```\n_UDPNone    → _UDPOpen   (bind succeeded)\n            → _UDPClosed (bind failed, or dispose/init race)\n_UDPOpen    → _UDPClosed (close or error)\n```\n\nThe dispose/init race: `_finish_initialization` is a self→self behavior. If `dispose()` arrives first, `_UDPNone.close()` transitions to `_UDPClosed`, and `_finish_initialization` checks for that and skips the bind.\n\n### Connected UDP lifecycle\n\n`ConnectedUDPSocket` uses the same state-object pattern. Initialization has three outcomes: bind failure, connect failure, or success.\n\n```\n_ConnectedUDPNone    → _ConnectedUDPOpen   (bind and connect succeeded)\n                     → _ConnectedUDPClosed (bind failed, connect failed, or dispose/init race)\n_ConnectedUDPOpen    → _ConnectedUDPClosed (close or error)\n```\n\nThe dispose/init race works the same way as `UDPSocket`. The peer is fixed at creation — there is no disconnect or reconnect.\n\n## Traps\n\n- **One read loop; keep `_on_received` out of `_ssl_poll()`.** An earlier design had `_ssl_poll()` deliver application data from a second loop of its own, so mute and the liveness check had to be written twice. Both shipped as bugs before the second copy existed.\n\n- **`_ssl` is a `_TLSState`, not `(SSL | None)`.** `(SSL | None)` was the earlier design. It records whether a session exists but not whether one may be used, and that ambiguity was a segfault.\n\n- **Mint the send token before the flush.** The flush can close the connection, so a token minted after it loses the terminal callback for a send whose bytes already went out. It is also why `send()` returns `SendAccepted` rather than the token: `_on_sent` is a direct call, so the application has to be holding the token before the flush runs. Keep every error return upstream of the mint, so an errored send burns no token id.\n\n- **`_on_send_failed` is deferred; `_on_sent` is not.** `_on_sent` was deferred too, for the same-looking reason, and it was wrong: a send reaching the OS is its own event, and queueing it let callbacks for later events overtake it.\n\n- **The hard-close reason is the `_HardCloseCause` argument, not a field.** An earlier design set one of three fields (`_connect_timed_out` and the like) right before an argumentless `hard_close()` and dug it back out after. Do not put it back in a field.\n\n- **`close_notify` is deferred to `_Closing.drained()`, not sent in `_Open.close()`.** `ssl.close()` calls `SSL_shutdown`, which makes `SSL_read` return `SSL_ERROR_ZERO_RETURN` — buffered TLS records the read loop has not yet delivered are lost.\n\n- **`_set_unwriteable()` before `_asio.resubscribe_write()`.** The ponyc epoll backend's `pony_asio_event_resubscribe()` only includes `EPOLLOUT` when `!ev->writeable`. After `_dispatch_io_event()` sets writeable, the flag stays true, so a later resubscribe is a no-op for the write side. Clear the flag first.\n\n- **A stale foreign event is dropped once, in `_event_notify`, not in each state.** The check used to sit in each `foreign_event` instead. Three of those copies were removed because the suite still passed on Linux, where the second message does not arrive, and that shipped as a bug reachable only where kqueue is the backend. Do not push the check back down into the states.\n\n## Platform differences\n\nPOSIX and Windows share one readiness-based I/O path: one-shot readiness events (epoll/kqueue; `ProcessSocketNotifications` on Windows), resubscribe, then a synchronous `RuntimeBackend.receive`/`sendv`. Windows uses this path because ponyc removed IOCP; the floor is Windows 11 / Windows Server 2022.\n\nHow many messages one subscription delivers is platform-specific too. kqueue arms read and write as separate one-shot filters and sends a message from each (ponyc's `kqueue.c`), so a single subscribed socket can deliver two readiness messages; epoll and Windows combine both directions into one (`epoll.c`, `sock_notify.c`). Code written on the assumption of one message per subscription is wrong wherever kqueue is the backend — macOS is the one CI covers, but the BSDs use it too.\n\n## Conventions\n\n- `_Unreachable()` in a branch the compiler cannot prove impossible, rather than an empty `else`.\n- The runtime will not exit while an actor holds a live I/O resource, so a test has to dispose any actor that might still hold one when it ends, or CI hangs. That is the client a listener creates in `_on_listening` and every server actor returned from `_on_accept`. Happy Eyeballs can deliver more than one connection per client connect (one per resolved address), so store accepted servers in `let _servers: Array[Type]`, push in `_on_accept`, and iterate in `dispose`/`_on_closed`.\n- Tests bind listeners and sockets to port `\"0\"` (OS-assigned ephemeral port) and retrieve the actual port from `local_address()` in the ready callback (`_on_listening` for TCP, `_on_bound` for UDP, `_on_connected` for connected UDP). This avoids conflicts when multiple test runs execute concurrently on the same machine.\n- `\\nodoc\\` on test classes.\n- A new test goes in the `_test_*.pony` file for its functional area, registered in `Main.tests()` in `_test.pony`, which holds only the test runner.\n- Each example has a file-level docstring saying what it demonstrates, uses the Listener/Server/Client actor structure, and uses a unique port. Adding one means placing it under the appropriate `examples/networking/` subdirectory and adding it to `examples/README.md` under the Networking section.\n","packages/net/CLAUDE.md":"@AGENTS.md\n","tools/lib/README.md":"# tools/lib\n\nVendored library dependencies for the tools in the `tools/` directory.\n","tools/pony-agent-server/README.md":"# pony-agent-server\n\nA query server for AI coding agents that need Pony type information while editing code. It compiles a package and answers questions about types, definitions, capabilities, and structure.\n\nThe query interface is experimental and subject to change.\n\n## Usage\n\n```text\npony-agent-server <package-directory> [-D define]...\n```\n\nThe server compiles the package on startup, then reads one JSON query per line from stdin and writes one JSON response per line to stdout. Diagnostic messages go to stderr.\n\nPass `-D` flags the same way you would pass them to `ponyc`. For example, `-D openssl_3.0.x` for OpenSSL 3.x.\n\nRun `--queries` to print a summary of all queries and their input format.\n\n## Protocol\n\nEvery query is a JSON object with a `\"query\"` field. An optional `\"id\"` field (string or integer) is echoed back in the response.\n\nOn success, the response contains the query-specific fields described below. On error, the response contains an `\"error\"` field with a message.\n\n```json\n{\"query\":\"status\", \"id\":1}\n{\"id\":1, \"status\":\"ready\"}\n```\n\n## Queries\n\n### inspect\n\nType and definition of the AST node at a source position.\n\n```json\n{\"query\":\"inspect\", \"file\":\"/path/to/file.pony\", \"line\":10, \"column\":5}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `type` | string | The node's type as the compiler resolved it |\n| `node_kind` | string | AST node kind (e.g. `\"letref\"`, `\"call\"`) |\n| `line`, `column` | integer | Position of the node |\n| `definition` | object | Where the symbol is defined: `file`, `line`, `column`, `kind`, `declared_type` |\n| `enclosing` | object | Enclosing context: `method`, `receiver_cap`, `type` |\n| `in_recover` | boolean | Whether the position is inside a `recover` block |\n| `sendable` | boolean | Whether the type is sendable (iso, val, or tag) |\n\n### scope\n\nEvery name visible at a source position, with recover-block accessibility.\n\n```json\n{\"query\":\"scope\", \"file\":\"/path/to/file.pony\", \"line\":10, \"column\":5}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `in_recover` | boolean | Whether the position is inside a `recover` block |\n| `entries` | array | Each entry has `name`, `type`, `kind`, `accessible`, and optionally `reason` |\n\nWhen `accessible` is false, `reason` explains why (typically because a non-sendable reference is blocked by a recover expression).\n\n### exports\n\nPublic types exported by a package.\n\n```json\n{\"query\":\"exports\", \"package\":\"collections\"}\n```\n\nThe package can be a qualified name like `\"collections\"` or an absolute path.\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `package_path` | string | Resolved path to the package |\n| `entries` | array | Each entry has `name`, `kind` (class, actor, primitive, etc.), and optionally `docstring` |\n\n### type_api\n\nMethods callable on a type at a given capability. There are two ways to query: by type name with an explicit capability, or by source position (the type and capability are read from the compiler's AST).\n\nBy name:\n\n```json\n{\"query\":\"type_api\", \"type\":\"Array[String]\", \"cap\":\"ref\"}\n```\n\nBy position:\n\n```json\n{\"query\":\"type_api\", \"file\":\"/path/to/file.pony\", \"line\":10, \"column\":5}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `type` | string | The type name or expression |\n| `kind` | string | Entity kind: `\"class\"`, `\"actor\"`, `\"primitive\"`, `\"trait\"`, `\"interface\"`, `\"struct\"`, or `\"union\"` |\n| `callable` | array | Methods callable at this capability |\n| `not_callable` | array | Methods not callable, each with a `method` and `reason` |\n| `constructors` | array | Available constructors |\n| `traits` | array | Traits and interfaces the type provides |\n\nEach method object has: `name`, `receiver` (capability), `params` (array of `{name, type, has_default}`), `return`, `partial` (boolean), `kind` (`\"fun\"`, `\"be\"`, or `\"new\"`), and `source_type` (the type that defined the method).\n\n### check\n\nSubtype check between two types using the compiler's subtype checker.\n\n```json\n{\"query\":\"check\", \"source\":\"Collector ref\", \"target\":\"Describable ref\"}\n```\n\nTypes are a name optionally followed by a capability. Without a capability, the type's default is used (ref for classes, val for primitives, tag for actors).\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `compatible` | boolean | Whether the source is a subtype of the target |\n| `source_type` | string | Source type with resolved capability |\n| `target_type` | string | Target type with resolved capability |\n| `reason` | string | Explanation of the result |\n| `suggestions` | array | When incompatible, suggestions for making it work |\n\n### implementors\n\nTypes that implement a given trait or interface.\n\n```json\n{\"query\":\"implementors\", \"type\":\"Describable\"}\n```\n\nThe target must be a trait or interface.\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `type` | string | The queried trait or interface name |\n| `nominal` | array | Types that explicitly declare the trait/interface in their provides list |\n| `structural` | array | Types that match the interface's method signatures without declaring it (interfaces only; traits require nominal declaration) |\n\n### errors\n\nCompilation errors from the last compile or reload.\n\n```json\n{\"query\":\"errors\"}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `errors` | array | Each error has `message`, and optionally `file`, `line`, `column` |\n\n### reload\n\nRecompile the package. The server recompiles from the same directory it was started with and replaces the in-memory program on success. Queries issued after a failed reload get the previous successful compilation.\n\n```json\n{\"query\":\"reload\"}\n```\n\nThe response is the same as the initial compile: `{\"status\":\"ready\"}` on success, or `{\"status\":\"error\", \"errors\":[...]}` on failure.\n\n### status\n\nWhether the server has a compiled program ready for queries.\n\n```json\n{\"query\":\"status\"}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `status` | string | `\"ready\"` or `\"not_compiled\"` |\n","tools/pony-lsp/README.md":"# pony-lsp - the Pony Language Server\n\n`pony-lsp` implements the [Language Server Protocol] for Pony. It communicates with editors via stdout/stdin, and is built and distributed alongside `ponyc` via `ponyup`.\n\nFor user documentation — installation and editor configuration — see the [pony-lsp documentation] on the Ponylang website.\n\n[Language Server Protocol]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/\n[pony-lsp documentation]: https://www.ponylang.io/use/pony-lsp/\n\n## Feature Support\n\n| **Feature** | **Description** |\n| ------- | ----------- |\n| **[Diagnostics][spec-diagnostics]** (push, pull, refresh and change notifications) | Ponyc errors and related information is reported as LSP diagnostics. |\n| **[Hover][spec-hover]** | Additional information is shown for: entities, methods, fields, local variables and references. |\n| **[Signature Help][spec-signature-help]** | Parameter hints are shown when the cursor is inside a call expression, with the active parameter highlighted. Includes the method's docstring when present. Requires the file to be saved — signature help is not available while the file has unsaved edits. |\n| **[Go To Definition][spec-definition]** | For most language constructs, you can go from a reference to its definition. |\n| **[Go To Declaration][spec-declaration]** | Navigate from a reference to the declaration site of a symbol. |\n| **[Go To Type Definition][spec-type-definition]** | Navigate to the type definition of the symbol under the cursor. |\n| **[Document Symbols][spec-document-symbols]** | pony-lsp provides a list of available symbols for each opened document. |\n| **[Workspace Symbols][spec-workspace-symbols]** | Fuzzy search over all symbols across the entire workspace. |\n| **[Document Highlight][spec-document-highlight]** | All occurrences of the symbol under the cursor are highlighted simultaneously across the document. |\n| **[Inlay Hints][spec-inlay-hints]** | Three kinds of hints are shown inline. For `let`, `var`, and field declarations with no type annotation, the full inferred type appears after the variable name. For annotated types where the capability is omitted — in variable and field declarations, function parameter types, generic type arguments, and union and tuple members — the missing capability keyword appears after the type name. For `fun` declarations, the implicit receiver capability appears before the function name and the implicit return type appears after the closing parenthesis. |\n| **[Find References][spec-references]** | All references to the symbol under the cursor are returned, with optional inclusion of the declaration site. |\n| **[Rename][spec-rename]** | Rename a symbol and all its references across the workspace. |\n| **[Folding Range][spec-folding-range]** | Code folding ranges are provided for blocks, methods, classes, and other structured constructs. |\n| **[Selection Range][spec-selection-range]** | Smart expand/shrink selection based on the AST structure of the document. |\n| **[Type Hierarchy][spec-type-hierarchy]** | Navigate the type hierarchy of Pony entities. `prepareTypeHierarchy` identifies the entity under the cursor; `supertypes` returns the types in its `is` clause; `subtypes` returns all types across the compiled module that directly provide it. |\n| **[Call Hierarchy][spec-call-hierarchy]** | Navigate the call hierarchy of Pony methods. `prepareCallHierarchy` identifies the method under the cursor; `incomingCalls` returns all callers across the workspace with their call sites; `outgoingCalls` returns all callees with their call sites within the method. |\n\n[spec-diagnostics]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#pull-diagnostics\n[spec-hover]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#hover-request-leftwards_arrow_with_hook\n[spec-signature-help]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_signatureHelp\n[spec-definition]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#goto-definition-request-leftwards_arrow_with_hook\n[spec-declaration]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#goto-declaration-request-leftwards_arrow_with_hook\n[spec-type-definition]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#goto-type-definition-request-leftwards_arrow_with_hook\n[spec-document-symbols]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#document-symbols-request-leftwards_arrow_with_hook\n[spec-workspace-symbols]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace-symbols-request-leftwards_arrow_with_hook\n[spec-document-highlight]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#document-highlights-request-leftwards_arrow_with_hook\n[spec-inlay-hints]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#inlay-hint-request-leftwards_arrow_with_hook\n[spec-references]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#find-references-request-leftwards_arrow_with_hook\n[spec-rename]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#rename-request-leftwards_arrow_with_hook\n[spec-folding-range]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#folding-range-request-leftwards_arrow_with_hook\n[spec-selection-range]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#selection-range-request-leftwards_arrow_with_hook\n[spec-type-hierarchy]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareTypeHierarchy\n[spec-call-hierarchy]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareCallHierarchy\n\nNew features are actively being added. Contributions are welcome — we are happy to provide help and guidance.\n\n## Settings\n\n`pony-lsp` supports settings via the [`workspace/configuration`] request and [`workspace/didChangeConfiguration`] notification.\n\n[`workspace/configuration`]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_configuration\n[`workspace/didChangeConfiguration`]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeConfiguration\n\n| **Setting** | **Type** | **Example** | **Description** |\n| ------- | ---- | ------- | ----------- |\n| `defines` | `Array[String]` | `[\"FOO\", \"BAR\"]` | Defines active during compilation, equivalent to the `-D` flag in `ponyc` |\n| `ponypath` | `Array[String]` | `[\"/path/to/pony/module\"]` | Additional module search paths |\n\nExample settings in JSON:\n\n```json\n{\n  \"defines\": [\"FOO\", \"BAR\"],\n  \"ponypath\": [\"/path/to/pony/module\", \"/another/path\"]\n}\n```\n\n## Building from source\n\nFirst, follow the [ponyc build documentation] and make sure you have everything to build `ponyc`. Then building `pony-lsp` is as easy as:\n\n```bash\nmake pony-lsp\n```\n\n[ponyc build documentation]: ../../BUILD.md\n\n## Reporting Issues\n\nIf `pony-lsp` is not working as expected, please [open an issue]. Include a minimal Pony program that reproduces the problem, the `pony-lsp` version, and the LSP client/editor you are using.\n\n[open an issue]: https://github.com/ponylang/ponyc/issues/new\n"},"files":{".ci-scripts/AGENTS.md":"# .ci-scripts\n\nHelper scripts that CI workflows call (Python and shell). Not part of the ponyc\nbuild, the runtime, or the stdlib — these only run in GitHub Actions (and locally\nwhen you want to exercise them).\n\n## Python conventions\n\n- **Stdlib only.** CI runners have no `pip install` step, so scripts must not\n  import third-party packages. Use `urllib`, `json`, `subprocess`, `tarfile`,\n  etc. — never `requests`, `pyyaml`, `pytest`, …\n- **ruff lints everything here** (`.github/workflows/lint-python.yml`,\n  which now only lints — the tests live in `test-ci-scripts.yml` and\n  `test-rt-stress.yml`). There is no ruff config file, so the defaults apply. A\n  `lint` job runs `ruff check .ci-scripts`; a separate `lint-rt-stress` job runs\n  `ruff check test/rt-stress` — these conventions also cover `test/rt-stress/`,\n  whose generative stress harness carries Python (an orchestrator) under the same\n  rules even though it lives with a test rather than in `.ci-scripts/`.\n- **Tests are `*_test.py` files, self-contained, no pytest.** Each is runnable as\n  `python3 <path>/<name>_test.py`, exits 0 on pass / 1 on fail, and prints a\n  one-line summary (see `pr_changed_suites_test.py` for the shape).\n  **`test-ci-scripts.yml` discovers every `*_test.py` under `.ci-scripts/` (via\n  `find … -name '*_test.py'`) and runs each one; `test-rt-stress.yml` does the\n  same for `test/rt-stress/`, on Linux and Windows** — so a new `*_test.py` is\n  picked up automatically — you do not register it anywhere. When you add or\n  change a script, add or update its `*_test.py`; it will run in CI on the next\n  PR.\n- After writing a test, break each assertion once to confirm it actually fires\n  (counterfactual check) before trusting it.\n\n## Subdirectory and import conventions\n\n- Related scripts are grouped in a subdirectory (e.g. `libs-cache/`). A script\n  imports its **siblings by module name** (`import cache_arch`); this resolves\n  because the scripts are invoked by full path, so `sys.path[0]` is the script's\n  own directory. **Move a script together with its siblings and its importers**,\n  or the imports break.\n- A subdirectory mixes **entry scripts** (runnable by full path, each with a\n  `main` and a CLI) with **support-library modules** (import-only, no `main`). In\n  `libs-cache/` the library modules are `common.py` (die/info/`require_env`/the\n  orchestrator helpers, plus the shared entry-script path constants\n  `MAIN_CACHE`/`BRANCH_CACHE`/`PROMOTE`), `registry.py` (the GHCR registry-v2\n  push/pull client, including `copy` — the cross-namespace promote the warmer uses\n  to reuse a branch build), `ghpackages.py` (the GitHub-packages REST client), and\n  `cache_arch.py` (arch normalization); the entry scripts (`*_libs_cache.py`,\n  `resolve_libs_cache.py`) are thin layers over them — `promote_libs_cache.py`\n  resolves the branch (source) and main (destination) names and calls\n  `registry.copy`. Shared logic belongs in a library module, not copied across\n  entry scripts.\n- A module that derives the **repo root** from `__file__` (e.g.\n  `libs-cache/registry.py`'s `repo_root()`) hardcodes the directory depth with a\n  fixed number of `os.path.dirname` calls. If you move it to a different depth,\n  update that count — `libs-cache/repo_root_test.py` pins it, so a wrong depth\n  fails the test job rather than only surfacing at runtime in CI.\n\n## Pointers\n\n- The libs cache (the `libs-cache/` scripts: warmer, consumers, branch cache,\n  retention, clear) is documented in `libs-cache/README.md`.\n- The `bsd/` scripts provision the BSD CI VMs and run the in-VM libs handling,\n  both shared by `update-lib-cache.yml` and `ponyc-tier3.yml`, one script per\n  platform: `{freebsd,openbsd,dragonfly}-provision.bash` (+ `dfly_configure_vm.py`)\n  boot and set up the VM, and `{freebsd,openbsd,dragonfly}-libs-cache.bash\n  <operation>` restores or builds-and-pushes the libs cache over ssh. See\n  `libs-cache/README.md`.\n- `windows-install-deps.ps1` installs the pinned msys2 lldb and the libraries it\n  loads against. It holds the only copy of that pin, and every Windows job\n  needing lldb calls it. Don't inline the `pacman` block into a workflow: the\n  PR job that checks lldb's behavior has to install the same lldb the\n  scheduled lanes do, or it guards a version nothing runs. The script's header\n  carries the pin history.\n",".ci-scripts/CLAUDE.md":"@AGENTS.md\n",".claude/skills/32-bit-support-validation/SKILL.md":"---\nname: 32-bit-support-validation\ndescription: Load when validating ponyc 32-bit support. Covers syncing the test machine, building LLVM and ponyc, and running the full test suite on a 32-bit ARM RPi4.\ndisable-model-invocation: false\n---\n\n# Validating 32-bit support\n\nRun the test suite on a 32-bit ARM machine to verify nothing is broken.\nThe default test machine is `pony-rpi4-32` (user `pi`, checkout at\n`/home/pi/code/ponylang/ponyc`). It has a persistent checkout and a built LLVM\nthat only needs rebuilding when the vendored LLVM changes.\n\nThis is slow — LLVM builds take many hours, ponyc builds take roughly an hour each,\nand the test suites add more on top. Run long steps detached and poll their logs.\n\n## Known ILP32 failures\n\nThe 32-bit address space (~3 GB user) constrains what can be compiled and\nlinked as a single binary. These are current failures, not accepted\nlimitations — they need to be fixed.\n\n- **The monolithic stdlib test** (`ctest -R stdlib`) compiles every stdlib\n  package into one binary. LLVM runs out of memory during code generation\n  (the \"Function prototypes\" phase), regardless of LTO mode, jemalloc, or\n  debug/release codegen. Stdlib tests are run per-package instead\n  (step 4c / 5c) as a workaround.\n\n## Gotchas (read first)\n\n- **Check the login shell.** If the machine's login shell is not bash (e.g. fish),\n  every SSH command must go through bash explicitly — either\n  `ssh pi@pony-rpi4-32 bash << 'ENDSSH'` for heredocs or\n  `ssh pi@pony-rpi4-32 bash -c '...'` for one-liners.\n- **Check the compiler.** The cmake presets default to `clang`/`clang++`. If the\n  machine only has GCC, override on every cmake configure:\n  `-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++`.\n- **`-mtune=generic` is invalid on ARM GCC.** The `native` preset sets\n  `-march=native -mtune=generic`, but GCC on ARM rejects `generic`. Override:\n  `\"-DCMAKE_C_FLAGS=-march=native\" \"-DCMAKE_CXX_FLAGS=-march=native\"`.\n  The LLVM build (`lib/build-libs.cmake`) does not set these flags, so it is not\n  affected.\n- **Don't run concurrent builds to the same build directory.** If an SSH session\n  times out mid-build, kill the orphaned cmake process before starting another —\n  two builds writing to the same directory corrupt it.\n- **Detach long builds.** Write a bash script to the machine, run it with `nohup`,\n  and poll the log file. Don't use `tee` over SSH — it keeps the connection alive\n  and the SSH session will time out. Redirect to a file instead.\n- **OpenSSL version matters for per-package tests.** cmake auto-detects the SSL\n  library for ctest, but per-package compilation (steps 4c/5c) needs the flag\n  passed explicitly. The scripts detect it via `openssl version`.\n- **LLVM build parallelism.** LLVM is memory-hungry. On machines with limited RAM,\n  use `-DJOBS=2` (or lower) to avoid OOM during the LLVM build.\n\n\n## Step 1 — verify the machine is reachable\n\n```bash\nssh -o ConnectTimeout=5 pi@pony-rpi4-32 bash -c '\"uname -m && cat /etc/os-release | head -2\"'\n```\n\nIf the machine is not reachable, stop and ask where to run the 32-bit validation.\nYou need: an SSH-accessible 32-bit ARM Linux machine with a ponyc checkout, GCC,\ncmake ≥ 3.25, and Python 3.\n\n## Step 2 — update the repo\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\ngit fetch origin\ngit checkout main\ngit pull origin main\ngit submodule update --init lib/llvm/src\ngit log --oneline -3\ngit -C lib/llvm/src rev-parse HEAD\nENDSSH\n```\n\nConfirm the log shows the expected HEAD of main, and the submodule hash matches\n`LLVM_DESIRED_HASH` in `lib/CMakeLists.txt`.\n\n## Step 3 — ensure LLVM is built and current\n\nLLVM is \"current\" when all three of these are true:\n\n1. The submodule at `lib/llvm/src` is at the commit specified by `LLVM_DESIRED_HASH`\n   in `lib/CMakeLists.txt`.\n2. The patches in `lib/llvm/patches/` have the hash specified by\n   `PATCHES_DESIRED_HASH` in `lib/CMakeLists.txt`.\n3. `build/libs/` exists and contains the LLVM built from that source with those\n   patches applied (subdirectories `lib/`, `bin/`, `include/`).\n\n### Check the submodule hash\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\necho \"desired: $(grep 'set(LLVM_DESIRED_HASH' lib/CMakeLists.txt | grep -oP '(?<=\")[^\"]+(?=\")')\"\necho \"actual:  $(git -C lib/llvm/src rev-parse HEAD)\"\nENDSSH\n```\n\nThese must match. If they don't, `git submodule update --init` in step 2 didn't work\n— re-run it, or investigate why the submodule is at the wrong commit.\n\n### Check whether build artifacts exist\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"ls /home/pi/code/ponylang/ponyc/build/libs/lib/ 2>/dev/null | head -3 || echo NO_LIBS\"'\n```\n\nIf `NO_LIBS`, a full rebuild is needed (8+ hours on the RPi — see \"Full rebuild\"\nbelow).\n\n### Validate the built LLVM against current source and patches\n\nEven when `build/libs/` exists and the submodule hash matches, the built LLVM might\nbe stale — the patches could have changed, or the build could be left over from a\ndifferent branch. Run `lib/build-libs.cmake` to validate everything. It checks both\n`LLVM_DESIRED_HASH` and `PATCHES_DESIRED_HASH` internally, resets the submodule\nsource tree, applies the current patches, and does an incremental build. If the source\nand patches haven't changed, the build step is a no-op (minutes, not hours).\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\ncmake -DJOBS=2 -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ -P lib/build-libs.cmake 2>&1\nENDSSH\n```\n\nThree outcomes:\n\n- **Completes with no errors** → LLVM is current, proceed to step 4.\n- **Fails with a hash mismatch** (`Patch hash actual '...' does not match\n  desired '...'`) → the patches changed since the last build. Do a full rebuild\n  (see below).\n- **Fails with a submodule hash error** → the submodule is wrong. Re-run\n  `git submodule update --init lib/llvm/src` in step 2 and try again.\n\nIf this step takes more than a few minutes, it is doing a real rebuild — detach it\n(see \"Full rebuild\" below) and poll.\n\n### Full rebuild\n\nWhen a clean LLVM build is needed (no `build/libs/`, or a hash mismatch), remove\nthe old build artifacts and rebuild from scratch. This takes 8+ hours on the RPi.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-libs-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\nrm -rf build/libs build/build_libs\ncmake -DJOBS=2 -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ -P lib/build-libs.cmake > /home/pi/libs-build.log 2>&1\necho \"LIBS_BUILD_EXIT_CODE=$?\" >> /home/pi/libs-build.log\nSCRIPT\nchmod +x /home/pi/run-libs-build.sh\nnohup /home/pi/run-libs-build.sh > /dev/null 2>&1 &\necho \"libs build started pid $!\"\nENDSSH\n```\n\nPoll until done:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -3 /home/pi/libs-build.log\"'\n```\n\nWait for `LIBS_BUILD_EXIT_CODE=0`. A nonzero exit code means the build failed —\nread the log above the exit code line for the error.\n\n## Test matrix\n\nTwo runtime builds (debug and release), each running the tests twice: once with\n`PONY_DEBUG=1` (debug codegen) and once without (release codegen). The env var\ncontrols whether ponyc compiles Pony sources with `-d`.\n\nEach runtime build runs these tests in both codegen modes:\n\n**ci-core minus stdlib** (run via ctest, excludes the monolithic stdlib test):\n\n- **check-version** — ponyc `--version` exits successfully\n- **libponyc.tests** — compiler C/C++ unit tests (GTest)\n- **libponyrt.tests** — runtime C/C++ unit tests (GTest)\n- **full-programs** — compile-and-run integration tests\n- **full-program-runner-rejects-broken-config** — verifies the test runner rejects bad config\n- **validate-grammar** — `pony.g` validated against the compiler\n\n**stdlib per-package** (run via ponyc directly, one package at a time):\n\nEach stdlib package is compiled and tested individually to stay within the\nILP32 address space limit. The full list of testable packages is derived from\n`packages/stdlib/_test.pony`.\n\n**tools** (all 5 tool test suites):\n\n- **pony-compiler-tests** — self-hosted compiler tool tests\n- **pony-dep-tests** — dependency tool tests\n- **pony-doc-tests** — documentation tool tests\n- **pony-lint-tests** — linter tool tests\n- **pony-lsp-tests** — language server tool tests\n\n## Step 4 — build and test with debug runtime\n\n### 4a. Configure\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\nrm -rf build/build_debug\ncmake --preset debug \\\n  -DCMAKE_C_COMPILER=gcc \\\n  -DCMAKE_CXX_COMPILER=g++ \\\n  \"-DCMAKE_C_FLAGS=-march=native\" \\\n  \"-DCMAKE_CXX_FLAGS=-march=native\"\nENDSSH\n```\n\nVerify \"Configuring done\" and \"Build files have been written\" in the output.\n\n### 4b. Build ponyc (detached)\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset debug > /home/pi/debug-build.log 2>&1\necho \"DEBUG_BUILD_EXIT_CODE=$?\" >> /home/pi/debug-build.log\nSCRIPT\nchmod +x /home/pi/run-debug-build.sh\nnohup /home/pi/run-debug-build.sh > /dev/null 2>&1 &\necho \"debug build started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -3 /home/pi/debug-build.log\"'\n```\n\nWait for `DEBUG_BUILD_EXIT_CODE=0`. Then verify all binaries built:\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc/build/debug\nfor bin in ponyc pony-compiler pony-dep pony-doc pony-lint pony-lsp; do\n  test -x \"$bin\" && echo \"$bin: OK\" || echo \"$bin: MISSING\"\ndone\nENDSSH\n```\n\nAll six must show OK.\n\n### 4c. Run stdlib tests per-package (debug runtime)\n\nDetect the SSL flag, then compile and run each testable package individually\nin both codegen modes. Run detached — this iterates over ~30 packages.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-stdlib-perpkg.sh << 'SCRIPT'\n#!/bin/bash\nset -u\ncd /home/pi/code/ponylang/ponyc\n\nPONYC=./build/debug/ponyc\nOUTDIR=./build/debug\nPKGDIR=./packages\n\n# Detect OpenSSL version for the SSL flag\nssl_ver=$(openssl version 2>/dev/null | head -1)\ncase \"$ssl_ver\" in\n  OpenSSL\\ 3.*) SSL_FLAG=-Dopenssl_3.0.x ;;\n  OpenSSL\\ 1.1.*) SSL_FLAG=-Dopenssl_1.1.x ;;\n  LibreSSL*) SSL_FLAG=-Dlibressl ;;\n  *) echo \"ERROR: cannot detect OpenSSL version: $ssl_ver\" >&2; exit 1 ;;\nesac\necho \"Detected SSL: $ssl_ver -> $SSL_FLAG\"\n\n# Packages with tests, from packages/stdlib/_test.pony\nPACKAGES=(\n  actor_pinning\n  encode/base64\n  buffered\n  builtin_test\n  bureaucracy\n  cli\n  collections\n  collections/persistent\n  constrained_types\n  crypto\n  files\n  format\n  http_client\n  ini\n  iregex\n  itertools\n  json\n  math\n  net\n  pony_test\n  process\n  promises\n  random\n  runtime_info\n  signals\n  strings\n  term\n  time\n  uri\n)\n\nLOGFILE=/home/pi/debug-stdlib-perpkg.log\n> \"$LOGFILE\"\noverall_rc=0\n\nfor codegen in debug release; do\n  echo \"=== $codegen codegen ===\" >> \"$LOGFILE\"\n  for pkg in \"${PACKAGES[@]}\"; do\n    name=$(echo \"$pkg\" | tr '/' '_')\n    debug_flag=\"\"\n    if [ \"$codegen\" = \"debug\" ]; then\n      debug_flag=\"-d\"\n    fi\n\n    echo -n \"  $pkg ($codegen): \" >> \"$LOGFILE\"\n\n    # Compile\n    $PONYC $debug_flag -b stdlib_test --checktree $SSL_FLAG --pic \\\n      \"$PKGDIR/$pkg\" -o \"$OUTDIR\" >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"COMPILE FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    # Run\n    \"$OUTDIR/stdlib_test\" --sequential >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"TEST FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    echo \"PASS\" >> \"$LOGFILE\"\n    rm -f \"$OUTDIR/stdlib_test\"\n  done\ndone\n\necho \"DEBUG_STDLIB_PERPKG_EXIT_CODE=$overall_rc\" >> \"$LOGFILE\"\nSCRIPT\nchmod +x /home/pi/run-debug-stdlib-perpkg.sh\nnohup /home/pi/run-debug-stdlib-perpkg.sh > /dev/null 2>&1 &\necho \"debug per-package stdlib tests started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -5 /home/pi/debug-stdlib-perpkg.log\"'\n```\n\nWait for `DEBUG_STDLIB_PERPKG_EXIT_CODE=0`.\n\n### 4d. Run ci-core tests minus stdlib (debug runtime)\n\nRun the ci-core tests excluding the monolithic stdlib, in both codegen modes.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-ci-core.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\necho \"=== debug codegen ===\" > /home/pi/debug-ci-core.log\nPONY_DEBUG=1 ctest --preset debug -L ci-core -E \"^stdlib$\" >> /home/pi/debug-ci-core.log 2>&1\nrc1=$?\necho \"=== release codegen ===\" >> /home/pi/debug-ci-core.log\nctest --preset debug -R \"^full-programs$\" >> /home/pi/debug-ci-core.log 2>&1\nrc2=$?\n\nif [ $rc1 -eq 0 ] && [ $rc2 -eq 0 ]; then\n  echo \"DEBUG_CI_CORE_EXIT_CODE=0\" >> /home/pi/debug-ci-core.log\nelse\n  echo \"DEBUG_CI_CORE_EXIT_CODE=1 (debug_codegen=$rc1 release_codegen=$rc2)\" >> /home/pi/debug-ci-core.log\nfi\nSCRIPT\nchmod +x /home/pi/run-debug-ci-core.sh\nnohup /home/pi/run-debug-ci-core.sh > /dev/null 2>&1 &\necho \"ci-core tests started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -5 /home/pi/debug-ci-core.log\"'\n```\n\nWait for `DEBUG_CI_CORE_EXIT_CODE=0`.\n\n### 4e. Build tool test binaries (after ponyc build completes)\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-tool-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset debug --target tool-tests > /home/pi/debug-tool-build.log 2>&1\necho \"TOOL_BUILD_EXIT_CODE=$?\" >> /home/pi/debug-tool-build.log\nSCRIPT\nchmod +x /home/pi/run-debug-tool-build.sh\nnohup /home/pi/run-debug-tool-build.sh > /dev/null 2>&1 &\necho \"tool build started pid $!\"\nENDSSH\n```\n\nPoll and wait for `TOOL_BUILD_EXIT_CODE=0`.\n\n### 4f. Run tool tests (debug runtime)\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-tools.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\nrc=0\nctest --preset debug -L tools >> /home/pi/debug-tools.log 2>&1\nrc=$?\necho \"DEBUG_TOOLS_EXIT_CODE=$rc\" >> /home/pi/debug-tools.log\nSCRIPT\nchmod +x /home/pi/run-debug-tools.sh\nnohup /home/pi/run-debug-tools.sh > /dev/null 2>&1 &\necho \"tool tests started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -5 /home/pi/debug-tools.log\"'\n```\n\nWait for `DEBUG_TOOLS_EXIT_CODE=0`.\n\n## Step 5 — build and test with release runtime\n\nRepeat the full test matrix with a release-built runtime and compiler.\n\n### 5a. Configure\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\nrm -rf build/build_release\ncmake --preset release \\\n  -DCMAKE_C_COMPILER=gcc \\\n  -DCMAKE_CXX_COMPILER=g++ \\\n  \"-DCMAKE_C_FLAGS=-march=native\" \\\n  \"-DCMAKE_CXX_FLAGS=-march=native\"\nENDSSH\n```\n\n### 5b. Build ponyc (detached)\n\nSame as step 4b but using the release preset.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset release > /home/pi/release-build.log 2>&1\necho \"RELEASE_BUILD_EXIT_CODE=$?\" >> /home/pi/release-build.log\nSCRIPT\nchmod +x /home/pi/run-release-build.sh\nnohup /home/pi/run-release-build.sh > /dev/null 2>&1 &\necho \"release build started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_BUILD_EXIT_CODE=0`. Verify binaries as in 4b\n(using `build/release` instead of `build/debug`).\n\n### 5c. Run stdlib tests per-package (release runtime)\n\nSame as step 4c but using the release ponyc.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-stdlib-perpkg.sh << 'SCRIPT'\n#!/bin/bash\nset -u\ncd /home/pi/code/ponylang/ponyc\n\nPONYC=./build/release/ponyc\nOUTDIR=./build/release\nPKGDIR=./packages\n\n# Detect OpenSSL version for the SSL flag\nssl_ver=$(openssl version 2>/dev/null | head -1)\ncase \"$ssl_ver\" in\n  OpenSSL\\ 3.*) SSL_FLAG=-Dopenssl_3.0.x ;;\n  OpenSSL\\ 1.1.*) SSL_FLAG=-Dopenssl_1.1.x ;;\n  LibreSSL*) SSL_FLAG=-Dlibressl ;;\n  *) echo \"ERROR: cannot detect OpenSSL version: $ssl_ver\" >&2; exit 1 ;;\nesac\necho \"Detected SSL: $ssl_ver -> $SSL_FLAG\"\n\nPACKAGES=(\n  actor_pinning\n  encode/base64\n  buffered\n  builtin_test\n  bureaucracy\n  cli\n  collections\n  collections/persistent\n  constrained_types\n  crypto\n  files\n  format\n  http_client\n  ini\n  iregex\n  itertools\n  json\n  math\n  net\n  pony_test\n  process\n  promises\n  random\n  runtime_info\n  signals\n  strings\n  term\n  time\n  uri\n)\n\nLOGFILE=/home/pi/release-stdlib-perpkg.log\n> \"$LOGFILE\"\noverall_rc=0\n\nfor codegen in debug release; do\n  echo \"=== $codegen codegen ===\" >> \"$LOGFILE\"\n  for pkg in \"${PACKAGES[@]}\"; do\n    name=$(echo \"$pkg\" | tr '/' '_')\n    debug_flag=\"\"\n    if [ \"$codegen\" = \"debug\" ]; then\n      debug_flag=\"-d\"\n    fi\n\n    echo -n \"  $pkg ($codegen): \" >> \"$LOGFILE\"\n\n    $PONYC $debug_flag -b stdlib_test --checktree $SSL_FLAG --pic \\\n      \"$PKGDIR/$pkg\" -o \"$OUTDIR\" >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"COMPILE FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    \"$OUTDIR/stdlib_test\" --sequential >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"TEST FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    echo \"PASS\" >> \"$LOGFILE\"\n    rm -f \"$OUTDIR/stdlib_test\"\n  done\ndone\n\necho \"RELEASE_STDLIB_PERPKG_EXIT_CODE=$overall_rc\" >> \"$LOGFILE\"\nSCRIPT\nchmod +x /home/pi/run-release-stdlib-perpkg.sh\nnohup /home/pi/run-release-stdlib-perpkg.sh > /dev/null 2>&1 &\necho \"release per-package stdlib tests started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_STDLIB_PERPKG_EXIT_CODE=0`.\n\n### 5d. Run ci-core tests minus stdlib (release runtime)\n\nSame as step 4d but using the release preset.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-ci-core.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\necho \"=== debug codegen ===\" > /home/pi/release-ci-core.log\nPONY_DEBUG=1 ctest --preset release -L ci-core -E \"^stdlib$\" >> /home/pi/release-ci-core.log 2>&1\nrc1=$?\necho \"=== release codegen ===\" >> /home/pi/release-ci-core.log\nctest --preset release -R \"^full-programs$\" >> /home/pi/release-ci-core.log 2>&1\nrc2=$?\n\nif [ $rc1 -eq 0 ] && [ $rc2 -eq 0 ]; then\n  echo \"RELEASE_CI_CORE_EXIT_CODE=0\" >> /home/pi/release-ci-core.log\nelse\n  echo \"RELEASE_CI_CORE_EXIT_CODE=1 (debug_codegen=$rc1 release_codegen=$rc2)\" >> /home/pi/release-ci-core.log\nfi\nSCRIPT\nchmod +x /home/pi/run-release-ci-core.sh\nnohup /home/pi/run-release-ci-core.sh > /dev/null 2>&1 &\necho \"ci-core tests started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_CI_CORE_EXIT_CODE=0`.\n\n### 5e. Build and run tool tests (release runtime)\n\nSame as steps 4e/4f but using the release preset.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-tool-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset release --target tool-tests > /home/pi/release-tool-build.log 2>&1\necho \"TOOL_BUILD_EXIT_CODE=$?\" >> /home/pi/release-tool-build.log\nSCRIPT\nchmod +x /home/pi/run-release-tool-build.sh\nnohup /home/pi/run-release-tool-build.sh > /dev/null 2>&1 &\necho \"tool build started pid $!\"\nENDSSH\n```\n\nPoll and wait for `TOOL_BUILD_EXIT_CODE=0`.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-tools.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\nctest --preset release -L tools > /home/pi/release-tools.log 2>&1\nrc=$?\necho \"RELEASE_TOOLS_EXIT_CODE=$rc\" >> /home/pi/release-tools.log\nSCRIPT\nchmod +x /home/pi/run-release-tools.sh\nnohup /home/pi/run-release-tools.sh > /dev/null 2>&1 &\necho \"tool tests started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_TOOLS_EXIT_CODE=0`.\n\n## Step 6 — report results\n\nSummarize which steps passed and which failed. Report each result individually:\n\n- LLVM version and whether it needed rebuilding\n- Debug runtime build: pass/fail (ponyc, pony-compiler, pony-dep, pony-doc,\n  pony-lint, pony-lsp built)\n- Debug runtime — ci-core minus stdlib (debug codegen): pass/fail\n- Debug runtime — ci-core minus stdlib (release codegen): pass/fail\n- Debug runtime — stdlib per-package (debug codegen): pass/fail per package\n- Debug runtime — stdlib per-package (release codegen): pass/fail per package\n- Debug runtime — tool tests: pass/fail\n- Release runtime build: pass/fail\n- Release runtime — ci-core minus stdlib (debug codegen): pass/fail\n- Release runtime — ci-core minus stdlib (release codegen): pass/fail\n- Release runtime — stdlib per-package (debug codegen): pass/fail per package\n- Release runtime — stdlib per-package (release codegen): pass/fail per package\n- Release runtime — tool tests: pass/fail\n\n## Cleanup\n\nKill any orphaned build processes and remove log files when done:\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\npkill -f \"cmake --build\" 2>/dev/null\npkill -f \"ctest --preset\" 2>/dev/null\npkill -f \"run-.*\\.sh\" 2>/dev/null\nrm -f /home/pi/run-*.sh /home/pi/*-build.log /home/pi/*-tool-build.log /home/pi/*-ci-core.log /home/pi/*-tools.log /home/pi/*-stdlib-perpkg.log\nENDSSH\n```\n\nDo not remove the `build/` directory — it holds the built LLVM and ponyc, which\npersist for the next validation run.\n",".claude/skills/add-linux-release-target/SKILL.md":"---\nname: add-linux-release-target\ndescription: Load when adding a new Linux distro/version (e.g. Ubuntu 26.04, Alpine 3.24) as a ponyc release target. Covers builder Dockerfile creation, image build dispatch and tag discovery, workflow updates, docs, release notes, and the post-merge Last Week in Pony announcement.\ndisable-model-invocation: false\n---\n\n# Adding a New Linux Distro/Version as a ponyc Release Target\n\n## Scope\n\nUse this skill when adding a new distro/version (e.g. Ubuntu 26.04) so ponyc produces release and nightly artifacts users can install via ponyup.\n\nThe procedure encodes the **multi-platform builder pattern** — a single `.ci-dockerfiles/<distro><version>-builder/` directory containing one Dockerfile and one `build-and-push.bash` that builds and publishes a multi-arch image (linux/amd64 + linux/arm64) under `ghcr.io/ponylang/ponyc-ci-<distro><version>-builder`. This is the pattern used by Alpine 3.23+. New release-target adds — including new Ubuntu versions — should use it. Older builders still use a per-arch pattern (separate `x86-64-unknown-linux-...-builder/` and `arm64-unknown-linux-...-builder/` directories); leave those alone, don't migrate them as part of an add.\n\n**Out of scope:**\n- CI test-only workflows: `pr.yml`, `ponyc-tier2.yml`, `ponyc-weekly-checks.yml`, `stress-test-*.yml`. These reference Linux images for testing ponyc itself, not for producing release artifacts. Don't touch them.\n- `pr-docker-image-validation.yml` validates the user-facing `.dockerfiles/{nightly,release}/Dockerfile` images (Alpine-based). Not affected by adding a release target.\n- Dropping older releases. We support only the two newest releases of a distro, so adding a newer one eventually means dropping the now-oldest — but that drop is a separate, by-hand procedure, not covered here.\n- Migrating an existing per-arch builder to the multi-platform pattern. Different procedure, not covered here.\n\n## Guiding facts\n\n- **Adds only; the policy is newest-two.** This skill only adds a target. Our policy is to support the two newest releases of a distro, so adding a newer release usually pairs with dropping the now-oldest — but that drop is a separate, by-hand procedure (not covered here). Don't drop anything as part of this work.\n- **Both architectures by default.** Multi-platform images build linux/amd64 and linux/arm64 from one Dockerfile. Don't restrict arches without a concrete reason.\n- **GHCR builder packages are repo-scoped.** No manual \"make public\" step is needed for new packages — same-repo CI pulls them automatically.\n- **Mental model.** One multi-arch builder image gets pushed to GHCR with a date-stamp tag. Workflow files (`release.yml`, `nightlies.yml`, `update-lib-cache.yml`) reference that one image by exact tag from both their x86_64-linux and arm64-linux matrices — Docker pulls the matching arch on each runner. Adding a new release target = create the Dockerfile, build/push the image, then thread the resulting tag into every workflow that consumes it, plus update the user-facing docs.\n\n## File inventory\n\nNew files (a single builder directory with two files):\n- `.ci-dockerfiles/<distro><version>-builder/Dockerfile`\n- `.ci-dockerfiles/<distro><version>-builder/build-and-push.bash`\n\nExisting files to edit:\n- `.github/workflows/build-builder-image.yml` — one dropdown option + one job block\n- `.github/workflows/release.yml` — `x86_64-linux` and `arm64-linux` matrices\n- `.github/workflows/nightlies.yml` — same matrices\n- `.github/workflows/update-lib-cache.yml` — same matrices (simpler shape: just `image:`)\n- `INSTALL.md` — supported-distro list + PLATFORM string table\n- `RELEASE_PROCESS.md` — package names list\n\nNew release notes file:\n- `.release-notes/add-<distro>-<version>.md`\n\n`BUILD.md` and `README.md` need no changes — neither contains version-specific content for this kind of add.\n\n## Before you begin\n\n- **Branch base:** start from a fresh `main`. `git checkout main && git pull`.\n- **Tools:** Docker daemon running (`docker info` succeeds), `gh` authenticated, `git` configured to push to origin.\n- **`gh` token scopes needed:** `workflow` (for dispatching builds) and `read:packages` (for querying GHCR tags). Check with `gh auth status`. If missing: `gh auth refresh -s workflow,read:packages`.\n- **Placeholders in this document:** every `<distro>`, `<version>`, `<tag>`, etc. is a placeholder for substitution. The angle brackets are not part of any command. Example for Ubuntu 26.04: `<distro>` → `ubuntu`, `<version>` → `26.04`.\n\n## Procedure\n\nThe **structural exemplar** — directory layout, `build-and-push.bash`, `build-builder-image.yml` job block, workflow matrix entries — is the most recent Alpine multi-platform builder. As of writing, that's `alpine3.24-builder/`. Before starting, glance at `.ci-dockerfiles/` and pick whichever Alpine multi-platform builder dir is newest (`alpineX.Y-builder/`, no arch prefix); use it in place of `alpine3.24-builder` throughout this document.\n\nThe **Dockerfile-content exemplar** is the most recent same-distro builder, regardless of pattern (per-arch is fine — only the package list and base image are reused). For Ubuntu adds at the time this skill was written, that's `x86-64-unknown-linux-ubuntu24.04-builder/Dockerfile`.\n\n### 1. Branch\n\n```bash\ngit checkout main && git pull\ngit checkout -b add-<distro>-<version>-release-target\n```\n\n### 2. Create the builder Dockerfile and build-and-push script\n\n```bash\nmkdir -p .ci-dockerfiles/<distro><version>-builder\n```\n\n**Dockerfile.** Copy the body from the most recent same-distro builder's Dockerfile (for Ubuntu, the per-arch x86-64 and arm64 Ubuntu 24.04 Dockerfiles are byte-identical, so either is fine), change the `FROM` line to the new version. The base image (e.g. `ubuntu:26.04`) must be a multi-arch manifest — it already is for the official `ubuntu` and `alpine` images. If adding a different distro, verify with:\n\n```bash\ndocker manifest inspect <distro>:<version> | jq '.manifests[].platform'\n```\n\nBoth `linux/amd64` and `linux/arm64` must appear.\n\n**build-and-push.bash.** Copy from the structural-exemplar multi-platform builder (e.g. `alpine3.24-builder/build-and-push.bash`) and change only the `NAME` and the `BUILDER` prefix. The shape:\n\n```bash\n#!/bin/bash\n\nset -o errexit\nset -o nounset\n\n#\n# *** You should already be logged in to GHCR when you run this ***\n#\n\nNAME=\"ghcr.io/ponylang/ponyc-ci-<distro><version>-builder\"\nTODAY=$(date +%Y%m%d)\nDOCKERFILE_DIR=\"$(dirname \"$0\")\"\nBUILDER=\"<distro><version>-builder-$(date +%s)\"\n\ndocker buildx create --use --name \"${BUILDER}\"\ndocker buildx build --provenance false --sbom false --platform linux/arm64,linux/amd64 --pull --push -t \"${NAME}:${TODAY}\" \"${DOCKERFILE_DIR}\"\ndocker buildx rm \"${BUILDER}\"\n```\n\nDon't modify the package list, the `LABEL`, the user setup, or anything else without a concrete reason. If a package name has changed in the new distro version, the local smoke test in step 3 will surface it — don't pre-emptively edit.\n\n### 3. Local smoke test\n\nBefore pushing or dispatching anything, build the Dockerfile locally for amd64 explicitly:\n\n```bash\ndocker build --pull --platform linux/amd64 .ci-dockerfiles/<distro><version>-builder/\n```\n\n`--platform linux/amd64` makes the smoke test deterministic regardless of host architecture (matters on Apple Silicon, where host-default would be `linux/arm64` and use a different package set). `--pull` forces Docker to fetch the latest base image rather than use a stale local cache. This is a smoke test only — don't run `build-and-push.bash` locally (that would push to GHCR with today's date stamp).\n\nIf the build fails because Docker isn't running or the package manager can't reach the network, that's an environment problem — fix it and retry, don't escalate. If the build fails inside the image (package gone, repo URL invalid for the new distro, base image not yet published, etc.): **STOP**. Surface the error to the human. Don't retry. Don't \"fix\" by tweaking the package list without confirmation — that's a design discussion.\n\nThe remote multi-platform build in step 6 cross-builds arm64 from x86-64 via QEMU. Local x86-64 success is a strong signal but not a guarantee for arm64; the GHA dispatch is the real arm64 verification.\n\n### 4. Update `build-builder-image.yml`\n\nTwo edits, both mechanical:\n\n**a. Add one dropdown option** (alphabetical across the whole list):\n\n```yaml\noptions:\n  ...\n  - <distro><version>-builder\n  ...\n```\n\n**b. Add one job block** by copying the structural-exemplar job (e.g. `alpine3_24-builder`) and substituting the version. Place the new job alphabetically among the sibling jobs.\n\nThe job map key replaces `.` with `_` (YAML map keys can't contain `.`): a version of `26.04` becomes `26_04` in the job key only — the `if:` filter value, the `name:` field, and the `bash` script path all keep the dot form. Reference shape:\n\n```yaml\n<distro><version_underscore>-builder:\n  if: ${{ github.event.inputs.builder-name == '<distro><version>-builder' }}\n  runs-on: ubuntu-latest\n\n  name: <distro><version>-builder\n  steps:\n    - name: Checkout\n      uses: actions/checkout@v6.0.2\n    - name: Set up Docker Buildx\n      uses: docker/setup-buildx-action@v4\n      with:\n        version: v0.23.0\n    - name: Login to GitHub Container Registry\n      uses: docker/login-action@v4\n      with:\n        registry: ghcr.io\n        username: ${{ github.repository_owner }}\n        password: ${{ secrets.GITHUB_TOKEN }}\n    - name: Build and push\n      run: bash .ci-dockerfiles/<distro><version>-builder/build-and-push.bash\n```\n\n`runs-on: ubuntu-latest` is correct — the multi-platform build cross-compiles arm64 via QEMU on an x86-64 runner. Don't change it.\n\n### 5. Push branch (WIP commit is fine)\n\n```bash\ngit add .ci-dockerfiles .github/workflows/build-builder-image.yml\ngit commit -m \"WIP: add <distro> <version> builder\"\ngit push -u origin add-<distro>-<version>-release-target\n```\n\n### 6. Dispatch the builder workflow from the branch\n\nGHA `workflow_dispatch` runs the workflow file from the chosen ref, so the new dropdown option becomes available when targeting the branch.\n\n```bash\ngh workflow run build-builder-image.yml \\\n  --ref add-<distro>-<version>-release-target \\\n  -f builder-name=<distro><version>-builder\n```\n\nIf dispatch returns 403, the `gh` token lacks `workflow` scope — see \"Before you begin.\"\n\n### 7. Wait for the build to finish\n\nThe multi-platform build takes ~20–40 minutes (arm64 via QEMU is the slow leg). Capture the run id, then watch it:\n\n```bash\nRUN_ID=$(gh run list --workflow=build-builder-image.yml \\\n  --branch=add-<distro>-<version>-release-target \\\n  --limit=1 --json databaseId --jq '.[].databaseId')\n\ngh run watch \"$RUN_ID\" --exit-status\n```\n\nIf the build fails: **STOP**. Read the failed logs with `gh run view \"$RUN_ID\" --log-failed` and surface the failure to the human. A common cause is a package or repo change for the new distro — that's a discussion, not something to silently work around.\n\n### 8. Discover the published tag\n\nThe successful build pushes `ghcr.io/ponylang/ponyc-ci-<distro><version>-builder:YYYYMMDD`. Query GHCR:\n\n```bash\ngh api -X GET /orgs/ponylang/packages/container/ponyc-ci-<distro><version>-builder/versions \\\n  --jq '[.[].metadata.container.tags[]] | map(select(test(\"^[0-9]{8}$\"))) | max'\n```\n\nThe `select(test(\"^[0-9]{8}$\"))` filter restricts to date-form tags (rejects `latest` or other future tags); `max` picks the most recent.\n\nIf the API returns 403, the `gh` token lacks `read:packages` — see \"Before you begin.\"\n\n### 9. Apply the tag to workflow files\n\nAdd new matrix entries alongside the existing entries. The same image string goes into both the `x86_64-linux` and `arm64-linux` matrices — Docker pulls the matching arch on each runner.\n\n**Use the structural-exemplar entry (Alpine 3.24) from the same file/matrix as your template.** Existing same-distro entries are inconsistent (per-arch builders use different image-name shapes; some files have `triple-vendor: unknown` on arm64, others omit it). Avoid that ambiguity: copy the alpine3.24 entry from the file/matrix you're editing, then substitute `image:`, `name:`, and `triple-os:` with the new distro/version values. Keep all other fields (including `triple-vendor`'s presence or absence) exactly as alpine3.24 has them in that location.\n\nFiles and matrices to edit:\n- `.github/workflows/release.yml` — `x86_64-linux` and `arm64-linux` matrices.\n- `.github/workflows/nightlies.yml` — `x86_64-linux` and `arm64-linux` matrices.\n- `.github/workflows/update-lib-cache.yml` — `x86_64-linux` and `arm64-linux` matrices (simpler shape: just `image:`).\n\nWorked example for Ubuntu 26.04, x86_64-linux matrices in `release.yml` and `nightlies.yml`:\n\n```yaml\n- image: ghcr.io/ponylang/ponyc-ci-ubuntu26.04-builder:<tag>\n  name: x86-64-unknown-linux-ubuntu26.04\n  triple-os: linux-ubuntu26.04\n  triple-vendor: unknown\n```\n\nThe arm64-linux matrix entry in those files uses the **same** `image:` string but `name: arm64-unknown-linux-ubuntu26.04`. Because the structural-exemplar (Alpine 3.24) arm64-linux entries omit `triple-vendor`, this entry omits it too.\n\n(In `release.yml` the arm64-linux job uses `docker run` directly rather than a `container:` job; ignore the surrounding job structure — the matrix entry shape is what matters.)\n\n### 10. Update docs\n\n`INSTALL.md`:\n- \"Supported Glibc distributions\" list (only relevant for distros listed there; Alpine adds use a different section): extend the existing comma-separated Ubuntu line in place. Add the new version to that line, don't add a new bullet.\n- If a corresponding Pop!_OS or Linux Mint release based on this Ubuntu version exists at this time, add it to its line and to the PLATFORM string table too. If it doesn't exist yet, skip — those rows can be added in a later PR when they ship.\n- PLATFORM string table: add two rows (amd64 and arm64). The existing rows for arm64 Ubuntu use `arm-linux-` (not `arm64-linux-`) — match that historical convention; don't \"fix\" it. PLATFORM strings (in INSTALL.md) and package names (in RELEASE_PROCESS.md) intentionally use different arch prefixes — match existing rows in each file rather than imposing consistency between them.\n\n`RELEASE_PROCESS.md`:\n- \"Package names will be:\" list: add two lines (alphabetical). Artifact naming always uses the arch-prefixed form, regardless of how the builder image is named:\n  - `* ponyc-arm64-unknown-linux-<distro><version>.tar.gz`\n  - `* ponyc-x86-64-unknown-linux-<distro><version>.tar.gz`\n\n### 11. Add release note\n\nCreate `.release-notes/add-<distro>-<version>.md`:\n\n```markdown\n## <Distro> <Version> added as a supported platform\n\nWe've added arm64 and amd64 builds for <Distro> <Version>. We build ponyc releases for the two newest <Distro> releases, so we'll keep building for <Version> until two newer <Distro> releases have shipped, at which point we'll stop.\n```\n\nThis combined-arch form matches the precedent for fresh adds (e.g. `.release-notes/0.60.4.md`). The per-arch form (\"X on arm64 added as a supported platform\") is for the historical case of adding an arch to an already-supported version — not what this skill covers.\n\nAfter this step, sanity-check coverage:\n\n```bash\ngit diff --stat main\ngrep -rn \"<distro><version>\" .github/workflows/ INSTALL.md RELEASE_PROCESS.md .ci-dockerfiles/ .release-notes/\n```\n\n`git diff --stat` should show changes only in the files listed in the \"File inventory\" section above (plus the two new Dockerfile/script files and the new release-notes file) — investigate any unexpected location. The `grep` confirms the placeholder was substituted; note that `build-builder-image.yml` job-key `<distro><version_underscore>` (e.g. `ubuntu26_04`) won't match the dotted form, so check that file separately.\n\n### 12. Squash, push, open PR\n\nThe PR title must exactly match the release-note title from step 11 (`<Distro> <Version> added as a supported platform`) — both end up as public-facing CHANGELOG content, so they must agree. The commit message matches too (it becomes the squash-merge commit subject).\n\nThe PR also needs the `changelog - added` label so release-notes aggregation picks up the entry under the right CHANGELOG section.\n\n```bash\ngit fetch origin main\ngit reset --soft origin/main\ngit add -A\ngit commit -m \"<Distro> <Version> added as a supported platform\"\ngit push --force-with-lease\n\nPR_URL=$(gh pr create \\\n  --title \"<Distro> <Version> added as a supported platform\" \\\n  --label \"changelog - added\" \\\n  --body \"$(cat <<'EOF'\nAdds <Distro> <Version> as a supported release target. Builder image, release/nightly/lib-cache workflow entries, INSTALL/RELEASE_PROCESS docs, and a release note are all included.\nEOF\n)\")\nPR_NUM=${PR_URL##*/}\n```\n\n### 13. Wait for CI, then squash-merge\n\nBlock until all PR checks complete, then merge if they pass:\n\n```bash\ngh pr checks \"$PR_NUM\" --watch --exit-status\ngh pr merge \"$PR_NUM\" --squash --delete-branch\ngit checkout main && git pull\n```\n\n`--watch --exit-status` blocks until checks finish and exits non-zero if any failed. If checks fail: **STOP**. Read the failure with `gh pr checks \"$PR_NUM\"` to see which check, drill into its logs, and surface to the human. Don't retry the merge.\n\n`--squash --delete-branch` matches the ponylang merge convention (repos under the ponylang and seantallen-org GitHub orgs only allow squash merges) and removes both the remote and local branch. The `git checkout main && git pull` afterward returns you to an up-to-date main.\n\n### 14. Post-merge: Last Week in Pony comment\n\nAfter the PR merges, find the open LWIP issue and post a comment.\n\n```bash\nLWIP=$(gh issue list -R ponylang/ponylang-website \\\n  --label last-week-in-pony --state open --limit 1 --json number --jq '.[0].number // empty')\n\nif [ -z \"$LWIP\" ]; then\n  echo \"No open LWIP issue. Wait until the next one is created (typically within a week) and post then. Don't open a new LWIP issue yourself.\"\nelse\n  gh issue comment \"$LWIP\" -R ponylang/ponylang-website --body \"$(cat <<'EOF'\n<Distro> <Version> has been added as a supported platform for ponyc. Prebuilt binaries are available via [ponyup](https://github.com/ponylang/ponyup). We build releases for the two newest <Distro> releases, so we'll keep building for <Version> until two newer <Distro> releases ship.\nEOF\n)\"\nfi\n```\n\nThe comment substance matches the release note: announcing the new platform and our policy of supporting the two newest releases. Format is short, single-paragraph prose matching existing LWIP comments.\n\nAfter this step, the add-a-release-target work is complete.\n\n## Failure modes — when to stop and ask\n\n- **Local Dockerfile build fails inside the image.** Package gone, repo URL invalid, base image not yet published. Don't tweak the package list or \"fix forward\" without confirmation.\n- **GHA builder dispatch fails.** Read run logs with `gh run view <id> --log-failed`. Most causes are the same as above.\n- **GHCR tag query returns no result.** Build may not have pushed (auth failure, network error). Check the workflow run logs.\n- **arm64 build fails but amd64 succeeds (or vice versa).** Multi-platform `docker buildx` produces a single manifest covering both arches; if either platform fails, the whole build fails and nothing gets pushed. Triage from the failed-platform logs.\n- **PR CI checks fail.** Don't retry the merge. Read the failed check's logs, surface to the human. Possible causes range from a flaky test (rare on a docs/config-only PR like this) to a real interaction with workflows you didn't expect to touch.\n\n## Anti-patterns\n\n- **Don't conflate this with CI test workflows.** PR validation, tier 2, weekly checks, and stress tests are separate concerns. They may also need updating for new distros, but not as part of \"add a release target.\"\n- **Don't migrate existing per-arch builders to multi-platform as a side effect.** That's a separate change.\n- **Don't skip the smoke test to save time.** A failed GHA multi-platform build is a 30-minute round-trip plus the human-attention cost of triaging it. A failed local build is 30 seconds.\n- **Don't run `build-and-push.bash` locally.** It pushes. The smoke test is `docker build` only.\n",".claude/skills/create-dragonfly-dev-env/SKILL.md":"---\nname: create-dragonfly-dev-env\ndescription: Load when you need to reproduce or validate a gcc-specific or DragonFly-specific ponyc issue on a local VM, or to stand up a local DragonFly BSD development VM that matches ponyc tier-3 CI. Covers the prerequisite check, the sudo-free QEMU/KVM setup, the reused console automation, the in-VM gcc13 build env, and the gotchas (csh, screendump paths, boot timing, detached builds, per-block shells) that make hand-rolling one error-prone.\ndisable-model-invocation: false\n---\n\n# Create a DragonFly BSD dev/test VM for ponyc\n\nStand up a local DragonFly BSD VM that matches the ponyc tier-3 CI job, so you can\nbuild and test ponyc on DragonFly without GitHub Actions.\n\nDragonFly only builds ponyc with gcc (the `gcc13` package), so it is the authoritative\nlocal environment for **gcc-specific** ponyc issues — the ones clang-based CI never\nsees. The setup is fiddly and timing-sensitive; the steps below are the known-good\nsequence. Follow them in order.\n\nRun the repo-relative commands (`.ci-scripts/bsd/...`, `rsync` of the source) from your\nponyc checkout root. The VM itself lives in a separate directory (`$VMDIR`).\n\n**Each fenced `sh` block below is self-contained.** It re-sets `VMDIR`/`DFLY_VER` (and,\nwhere needed, the `SSH` command) at its top, because an agent runs each block as a fresh\nshell — environment variables and the working directory do NOT carry from one block to\nthe next. Do not \"optimize\" by setting them once and dropping them from later blocks: an\nempty `$SSH` would make a `$SSH /bin/sh <<'EOF' ... EOF` heredoc run **on your host**\n(silently, exit 0) — and Step 7 contains `rm -rf /usr/local`. Keep every block whole.\nPick your `VMDIR`/`DFLY_VER` once and use the same values in every block.\n\n## Gotchas that will bite you (read first)\n\nEach of these cost real debugging. Internalize them before you start — they recur at\nseveral steps:\n\n- **Per-block fresh shells.** See above: re-establish `VMDIR`/`DFLY_VER`/`SSH` in every\n  block; never let `$SSH` be empty in front of a `/bin/sh` heredoc.\n- **Root's login shell is csh, not sh.** Any remote command with `$(...)`, `export`, or\n  a here-string MUST go through an explicit `/bin/sh`:\n  `ssh ... root@localhost /bin/sh <<'EOF' ... EOF`. A bare\n  `ssh ... root@localhost 'cmd with $(...)'` fails with `Illegal variable name.`\n- **Boot timing is handled by the script.** `dfly_configure_vm.py` takes periodic VGA\n  screendumps and waits for the screen to stabilize before attempting login. It then types\n  three short commands via sendkey (login, mount the seed ISO, run the setup script) and\n  verifies SSH becomes reachable. If something goes wrong, check the diagnostic screendump\n  the script saves, or use the manual screendump helper (Step 5) to see the VM state.\n- **A daemonized QEMU `chdir`s to `/`.** The monitor `screendump` (and any relative path\n  the daemon writes) needs an **absolute** path, or it fails `Permission denied`.\n- **No `sudo` needed (and often unavailable).** CI loop-mounts the ISO with `sudo` to get\n  `/usr/include`; locally, extract it with `bsdtar` instead — same result, no privilege.\n- **Detach long in-VM builds.** The libs build (LLVM) takes hours; run it\n  `nohup … > /build/x.log 2>&1 &` and poll the log, so an ssh drop doesn't kill it.\n- **Reuse the CI console script verbatim.** `.ci-scripts/bsd/dfly_configure_vm.py`\n  types login + mount + setup via QEMU sendkey, then verifies SSH is reachable. The setup\n  script lives on the seed ISO created in Step 4. Copy the Python script, don't reimplement\n  the bootstrap or setup.\n\n## Step 0 — verify prerequisites (do NOT assume they're installed)\n\nThis skill needs, on the host:\n\n- an existing ponyc checkout (you run the repo-relative commands from it)\n- hardware-accelerated virtualization for QEMU (KVM on Linux, HVF on macOS)\n- enough free disk for the VM's sparse qcow2 disks (provisioned at 60 GB + 50 GB nominal;\n  they grow only as used, and the libs build plus a build fill a large chunk of the data\n  disk), ~2 GB for the image/ISO files, and network access to mirror-master.dragonflybsd.org\n- `qemu-system-x86_64` and `qemu-img`\n- `bsdtar` (a libarchive tar that reads ISO9660; the default `tar` on macOS, FreeBSD, and DragonFly)\n- `genisoimage` (or `mkisofs`) for creating the seed ISO\n- `bunzip2`, `rsync`, `git`, `curl`, `python3`\n- an OpenSSH client (`ssh`, `scp`, `ssh-keygen`)\n- a PPM-to-PNG converter: any one of magick/convert, ffmpeg, or pnmtopng\n\nCheck that every required tool is present. Do not assume the environment has them, and do\nnot install anything yourself — installing them is the user's call (it needs privilege,\nand the package names vary by OS, so naming a specific package would be wrong on half the\nplatforms this runs on). If anything is missing, **stop, tell the user which\ntools/capabilities are missing, and ask them to install them with their platform's\npackage manager**, then re-run the check before proceeding.\n\n```sh\nmissing=\"\"\nfor t in qemu-system-x86_64 qemu-img bsdtar bunzip2 rsync git ssh scp ssh-keygen curl python3 genisoimage; do\n  command -v \"$t\" >/dev/null 2>&1 || missing=\"$missing $t\"\ndone\ncommand -v magick >/dev/null 2>&1 || command -v convert >/dev/null 2>&1 \\\n  || command -v ffmpeg >/dev/null 2>&1 || command -v pnmtopng >/dev/null 2>&1 \\\n  || missing=\"$missing ppm-converter(magick|ffmpeg|pnmtopng)\"\n# Hardware acceleration is host-specific — check the right accelerator for this OS.\ncase \"$(uname -s)\" in\n  Linux)  [ -w /dev/kvm ] || missing=\"$missing kvm(/dev/kvm-not-writable)\" ;;\n  Darwin) qemu-system-x86_64 -accel help 2>&1 | grep -q hvf || missing=\"$missing hvf-accelerator\" ;;\n  *)      echo \"NOTE: confirm this host has a QEMU hardware accelerator and adjust the boot accel= below\" ;;\nesac\n[ -n \"$missing\" ] && echo \"MISSING:$missing\" || echo \"all prerequisites present\"\n```\n\nIf the output is anything but `all prerequisites present`, name the missing\ntools/capabilities to the user and ask them to install them however their OS does — do\nNOT guess a distro package name. (`bsdtar` is the libarchive tar; on macOS/FreeBSD/\nDragonFly the system `tar` is already bsdtar, elsewhere it comes from libarchive.) A\nmissing hardware accelerator — no writable `/dev/kvm` on Linux, no HVF on macOS — is a\nhost/BIOS/virtualization fix only the user can make; the VM is unusably slow without it,\nso don't fall back to TCG emulation.\n\nThis flow is verified on Linux+KVM. The guest side is OS-agnostic, so macOS goes through\nthe same steps with the HVF accelerator (`accel=kvm:hvf` in Step 4 selects it).\n\n## Setup\n\nPick a persistent VM directory and a DragonFly version that **matches ponyc CI** (check\nthe image URL in `.ci-scripts/bsd/dragonfly-provision.bash`; currently 6.4.2). Use the\nsame two values in every block below.\n\n### 1. Download the image + ISO (one time; keep the `.bz2` to avoid re-downloading)\n\nThe raw `.img` boots; the `.iso` is only mined for system headers (the raw image ships\nwithout `/usr/include`).\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; DFLY_VER=6.4.2\nmkdir -p \"$VMDIR\" && cd \"$VMDIR\"\nbase=\"https://mirror-master.dragonflybsd.org/iso-images\"\ncurl -fL --retry 3 -o dfly.img.bz2 \"$base/dfly-x86_64-${DFLY_VER}_REL.img.bz2\"\ncurl -fL --retry 3 -o dfly.iso.bz2 \"$base/dfly-x86_64-${DFLY_VER}_REL.iso.bz2\"\n```\n\n### 2. Build the disks\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\nbunzip2 -k -f dfly.img.bz2 dfly.iso.bz2          # -k keeps the .bz2 originals\nqemu-img convert -f raw -O qcow2 dfly.img dfly.qcow2\nqemu-img resize dfly.qcow2 60G\nqemu-img create -f qcow2 dfly-data.qcow2 50G     # build workspace; root part is ~1.8G\nrm -f dfly.img                                    # keep dfly.qcow2 + the .bz2\n```\n\n### 3. Extract system headers from the ISO — no sudo (replaces CI's `mount -o loop`)\n\n`bsdtar` reads ISO9660 directly. The tar must have `include/` as its top entry so the\nin-VM extraction matches CI. (Both the extract and the repack use `bsdtar`, so no\nseparate `tar` is needed on the host.)\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\nrm -rf iso-stage && mkdir iso-stage\nbsdtar -xf dfly.iso -C iso-stage usr/include\nbsdtar -cf dfly-include.tar -C iso-stage/usr include\nrm -rf iso-stage\n```\n\n### 4. Key + seed ISO + boot (daemonized, persistent)\n\nCreate the SSH key, a seed ISO with the key and a setup script, then boot with the ISO\nattached as a CD-ROM. The setup script on the ISO configures networking, sshd, and SSH\nkeys — the sendkey bootstrap only needs to type three short commands (login, mount, run\nscript) instead of piping long commands through the fragile VGA console.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; DFLY_VER=6.4.2; cd \"$VMDIR\"\ntest -f vm_key || ssh-keygen -t ed25519 -f vm_key -N \"\" -q\ncat > setup.sh <<'SETUP'\n#!/bin/sh\nset -e\nif pgrep -x sshd >/dev/null 2>&1; then\n  exit 0\nfi\ndhclient vtnet0\necho \"PermitRootLogin yes\" >> /etc/ssh/sshd_config\necho \"PermitEmptyPasswords yes\" >> /etc/ssh/sshd_config\nmkdir -p /root/.ssh && chmod 700 /root/.ssh\ncp /mnt/authorized_keys /root/.ssh/authorized_keys\nchmod 600 /root/.ssh/authorized_keys\nssh-keygen -A\n/usr/sbin/sshd\nSETUP\ncp vm_key.pub authorized_keys\ngenisoimage -output seed.iso -volid CIDATA -joliet -rock setup.sh authorized_keys\nqemu-system-x86_64 \\\n  -name dragonfly-${DFLY_VER} \\\n  -machine pc,accel=kvm:hvf -cpu host -smp 8 -m 12G \\\n  -drive file=dfly.qcow2,format=qcow2,if=virtio \\\n  -drive file=dfly-data.qcow2,format=qcow2,if=virtio \\\n  -drive file=seed.iso,media=cdrom \\\n  -netdev user,id=net0,hostfwd=tcp::2222-:22 \\\n  -device virtio-net-pci,netdev=net0 \\\n  -object rng-random,id=rng0,filename=/dev/urandom \\\n  -device virtio-rng-pci,rng=rng0 \\\n  -monitor unix:dfly-monitor.sock,server,nowait \\\n  -serial unix:dfly-serial.sock,server,nowait \\\n  -display none -pidfile dfly.pid -daemonize\n```\n\n(`accel=kvm:hvf` picks KVM on Linux or HVF on macOS and errors if neither is available —\nit never silently falls back to slow TCG. `-smp`/`-m` are speed knobs; CI uses 4 CPUs /\n12G. `hostfwd 2222->22` is the ssh port.)\n\n### 5. Run the console setup script\n\nThe CI console script takes periodic VGA screendumps to detect when boot finishes, then\ntypes three short commands via sendkey (login as root, mount the seed ISO, run the setup\nscript). It verifies SSH becomes reachable after each attempt, retrying if needed.\n\nThe script lives in the repo at `.ci-scripts/bsd/dfly_configure_vm.py`. Copy it into\n`$VMDIR` (it connects to `dfly-monitor.sock` in its working directory) and run it there.\nSet `DFLY_ARTIFACTS_DIR` to the VM directory so the script can write screendumps for boot\ndetection and diagnostics:\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\ncp \"$(git rev-parse --show-toplevel)/.ci-scripts/bsd/dfly_configure_vm.py\" \"$VMDIR/\"\nexport DFLY_ARTIFACTS_DIR=\"$VMDIR\"\npython3 dfly_configure_vm.py\n```\n\nA `monitor_cmd.py` helper is still useful for ad-hoc screendumps when debugging boot\nproblems. Create one if needed:\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\ncat > monitor_cmd.py <<'PY'\nimport socket, sys, time\ns = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.connect('dfly-monitor.sock')\ntime.sleep(0.4); s.recv(65536)\ns.sendall((sys.argv[1] + '\\n').encode()); time.sleep(0.5); s.settimeout(1.0)\ntry:\n    while True:\n        d = s.recv(65536)\n        if not d: break\n        sys.stdout.write(d.decode(errors='replace'))\nexcept socket.timeout: pass\ns.close()\nPY\nto_png() {  # convert PPM->PNG with whichever converter is installed\n  if   command -v magick   >/dev/null 2>&1; then magick \"$1\" \"$2\"\n  elif command -v convert  >/dev/null 2>&1; then convert \"$1\" \"$2\"\n  elif command -v ffmpeg   >/dev/null 2>&1; then ffmpeg -y -loglevel error -i \"$1\" \"$2\"\n  else pnmtopng \"$1\" > \"$2\"; fi\n}\npython3 monitor_cmd.py \"screendump $VMDIR/console1.ppm\"   # absolute path is required\nto_png \"$VMDIR/console1.ppm\" \"$VMDIR/console1.png\"\n```\n\n### 6. Wait for ssh\n\nStep 5 already verifies SSH is reachable, so this is a belt-and-suspenders check. If it\nfails, re-screendump to check the console state, and re-run Step 5.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\nup=\"\"\nfor i in $(seq 1 150); do   # ~5 min, matching CI's timeout\n  if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=2 -i vm_key -p 2222 \\\n       root@localhost true 2>/dev/null; then up=1; echo \"SSH up\"; break; fi\n  sleep 2\ndone\n[ -n \"$up\" ] || echo \"SSH NEVER CAME UP — check the console screendump (Step 5) and rerun if needed\"\n```\n\nFrom here, **always** drive the VM through `/bin/sh` (root's shell is csh — see Gotchas),\nand define `$SSH` in the same block you use it (it does not carry between blocks):\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nuname -a\nEOF\n```\n\n### 7. Format and mount the build disk\n\nThe root partition is only ~1.8G — not enough for the build or packages. Format the\nsecond disk and mount it at `/build`.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\nnewfs /dev/vbd1\nmkdir -p /build\nmount /dev/vbd1 /build\nEOF\n```\n\n### 8. Move `/usr/local` to the build disk, install headers, install deps\n\n`gcc13` alone is ~418M, so package installs must land on `/build`.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncpdup /usr/local /build/usr_local\nrm -rf /usr/local\nln -s /build/usr_local /usr/local\nEOF\n\nscp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i \"$VMDIR/vm_key\" -P 2222 \"$VMDIR/dfly-include.tar\" root@localhost:/build/\n$SSH /bin/sh <<'EOF'\nset -e\ntar xf /build/dfly-include.tar -C /build\nln -s /build/include /usr/include\nrm /build/dfly-include.tar\nEOF\n\n$SSH /bin/sh <<'EOF'\nset -e\npkg install -y cmake gmake git python3 cxx_atomics rsync gcc13 ca_root_nss\npkg clean -ay\ngit config --global --add safe.directory /build/ponyc\nEOF\n```\n\n### 9. Rsync the ponyc checkout in (exclude the host `build/`)\n\nRun from your ponyc checkout. Exclude the top-level `build/` (host artifacts; the VM\nbuilds its own). Keep `.git` (CMake runs `git rev-parse`). The vendored LLVM submodule\nunder `lib/llvm/src` IS transferred — the VM needs it for the libs build.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nrsync -az --exclude='/build' \\\n  -e \"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222\" \\\n  \"$(git rev-parse --show-toplevel)/\" root@localhost:/build/ponyc/\n```\n\n## Using the VM\n\nAlways set the gcc13 build env first (matches the tier-3 `dragonflybsd` job). `cmake -P lib/build-libs.cmake`\nbuilds the vendored LLVM and is the multi-hour long pole — run it detached, then poll the\nlog until it ends with `libs DONE rc=0` (a nonzero rc means it failed — read the log\nabove the marker):\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncat > /build/run-libs.sh <<'S'\n#!/bin/sh\nexport CC=/usr/local/bin/gcc13 CXX=/usr/local/bin/g++13\nexport LD_LIBRARY_PATH=/usr/local/lib/gcc13\nexport SSL_CERT_FILE=/usr/local/share/certs/ca-root-nss.crt\ncd /build/ponyc && cmake -DJOBS=4 -DCMAKE_C_COMPILER=/usr/local/bin/gcc13 -DCMAKE_CXX_COMPILER=/usr/local/bin/g++13 -P lib/build-libs.cmake\necho \"libs DONE rc=$?\"\nS\nchmod +x /build/run-libs.sh\nnohup /build/run-libs.sh > /build/libs-build.log 2>&1 &\necho \"libs building pid $!\"\nEOF\n# poll until done: $SSH /bin/sh -c 'tail -3 /build/libs-build.log'  -> wait for \"libs DONE rc=0\"\n```\n\nOnce `libs` is built (it persists in the VM), build and test with the same env:\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\nexport CC=/usr/local/bin/gcc13 CXX=/usr/local/bin/g++13\nexport LD_LIBRARY_PATH=/usr/local/lib/gcc13\nexport SSL_CERT_FILE=/usr/local/share/certs/ca-root-nss.crt\ncd /build/ponyc\ncmake --preset debug -DCMAKE_C_COMPILER=/usr/local/bin/gcc13 -DCMAKE_CXX_COMPILER=/usr/local/bin/g++13\ncmake --build --preset debug\nctest --preset debug -L ci-core\nEOF\n```\n\nTo iterate on a fix: edit on the host, re-run Step 9's `rsync` (it's incremental — only\nchanged files are sent), then re-run the build block above (long rebuilds should also be\ndetached + polled). For exact CI parity, the tier-3 `dragonflybsd` job\n(`.github/workflows/ponyc-tier3.yml`) runs more than this — the reject-unsupported-builds\ncheck, a `--static` link smoke, and the coverage smoke — so consult it when validating a\nCI-matching issue.\n\n## Lifecycle\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\npgrep -af \"qemu-system-x86_64 -name dragonfly\"        # status\nssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 root@localhost   # interactive shell\n# stop: DragonFly's default install has no ACPI shutdown handler, so system_powerdown\n# usually does nothing — kill the process instead. Disks persist for next time.\nkill \"$(cat dfly.pid)\" 2>/dev/null || pkill -f \"qemu-system-x86_64 -name dragonfly\"\n```\n\nTo restart later, re-run Steps 4–7 (the disks and installed deps persist; the key and ISO\ncreation are idempotent, and Steps 5–7 re-establish sshd and the `/build` mount, which\nare not persisted across a guest reboot). For a long-lived VM, make `sshd_enable`\npermanent in `/etc/rc.conf` and add the `/build` mount to `/etc/fstab`.\n\n## How this relates to CI (don't re-add the CI-only steps)\n\nThis mirrors `.ci-scripts/bsd/dragonfly-provision.bash` (the source of truth — keep this\nskill in sync if it changes) and **reuses `.ci-scripts/bsd/dfly_configure_vm.py`\nunchanged**. The local setup deliberately differs from CI, and each difference is safe:\n\n- Skips CI host-bootstrap (free disk space, install qemu, make `/dev/kvm` writable) —\n  your host already has qemu and a usable hardware accelerator (Step 0 verified it).\n- `bsdtar` instead of `sudo mount -o loop` for the ISO headers — no privilege needed.\n- Persistent VM instead of built-fresh-and-killed per run.\n- ssh adds `-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR` to quiet the host-key\n  warning: a persistent VM reused on `localhost:2222` collides with the stale\n  `known_hosts` entry from a prior VM. CI's ephemeral runners never reuse the port, so\n  `dragonfly-provision.bash` doesn't bother.\n- `-smp 8` for build speed (CI uses 4).\n- Both use the seed ISO + sendkey approach with screendump-based boot detection; locally\n  you can also use the manual screendump helper for ad-hoc debugging.\n- No GHCR libs cache (that's token-gated CI plumbing) — you just run `cmake -P lib/build-libs.cmake` once.\n\nThe FreeBSD and OpenBSD CI VMs follow the same shape\n(`.ci-scripts/bsd/{freebsd,openbsd}-provision.bash`) and each has its own skill:\n`create-freebsd-dev-env` (takes a `FREEBSD_VERSION`) and `create-openbsd-dev-env` (its\nstatic-link checks expect static-PIE, and it rejects the unsupported `use=` options). This\nskill is DragonFly-only; load a sibling if you need one of those.\n",".claude/skills/create-freebsd-dev-env/SKILL.md":"---\nname: create-freebsd-dev-env\ndescription: Load when you need to reproduce or validate a FreeBSD-specific ponyc issue on a local VM, or to stand up a local FreeBSD development VM that matches ponyc tier-3 CI. Covers the cloud-init seed, the sudo-free QEMU/KVM boot, the one-time expect/su root bootstrap, the clang build env, and the gotchas (freebsd-user login, no bash in base, growfs auto-resize, detached builds, per-block shells) that make hand-rolling one error-prone.\ndisable-model-invocation: false\n---\n\n# Create a FreeBSD dev/test VM for ponyc\n\nStand up a local FreeBSD VM that matches the ponyc tier-3 CI job, so you can build\nand test ponyc on FreeBSD without GitHub Actions.\n\nFreeBSD's tier-3 job exercises paths the clang-on-Linux jobs never do: the embedded-LLD\n`--static` link recipe, the native sanitizer link path, and FreeBSD's base\nlibc++/libunwind. So this VM is the authoritative local\nenvironment for **FreeBSD-platform** ponyc issues. (It is clang-based — FreeBSD's base\ncompiler is clang, not gcc. For **gcc-specific** issues use the DragonFly skill instead.)\n\nRun the repo-relative commands (`rsync` of the source) from your ponyc checkout root.\nThe VM itself lives in a separate directory (`$VMDIR`).\n\n**Each fenced `sh` block below is self-contained.** It re-sets `VMDIR`/`FREEBSD_VERSION`\n(and, where needed, the `SSH` command) at its top, because an agent runs each block as a\nfresh shell — environment variables and the working directory do NOT carry from one\nblock to the next. Do not \"optimize\" by setting them once and dropping them from later\nblocks: an empty `$SSH` would make a `$SSH /bin/sh <<'EOF' ... EOF` heredoc run **on your\nhost** (silently) instead of in the VM. Keep every block whole. Pick your `VMDIR`/\n`FREEBSD_VERSION` once and use the same values in every block.\n\n## Gotchas that will bite you (read first)\n\nFreeBSD is much simpler to bring up than DragonFly (cloud-init injects the ssh key, so\nthere is no VGA-console automation), but these still matter:\n\n- **Per-block fresh shells.** See above: re-establish `VMDIR`/`FREEBSD_VERSION`/`SSH` in\n  every block; never let `$SSH` be empty in front of a `/bin/sh` heredoc.\n- **You log in as `freebsd`, not `root`.** The cloud-init seed installs your key for the\n  `freebsd` user and sets root's password to `ciroot`. Build and test run **as\n  `freebsd`** (no root needed). Root is only needed twice during setup — the one-time\n  `expect`/`su` bootstrap below installs the packages and configures passwordless `doas`,\n  after which the rare root operation goes through `doas`.\n- **Cloud-init means no console driving.** Unlike the DragonFly skill there is no QEMU\n  `sendkey`/screendump/monitor dance — just boot and wait for ssh. But the **first** boot\n  runs `nuageinit` (key install) and `growfs` (root-fs auto-resize), so ssh is not\n  instant; poll for it (Step 4).\n- **The root fs auto-grows; there is no growfs command to run.** `qemu-img resize ... 60G`\n  enlarges the disk and the image's first boot grows the root fs to fill it. You do not\n  run `growfs` yourself — the wait-for-ssh loop is what accounts for it.\n- **FreeBSD's base system has no `bash`.** In-VM scripts must be POSIX `sh` (the two CI\n  smoke scripts already are). Don't reach for bashisms in a `$SSH /bin/sh` heredoc.\n- **Detach long in-VM builds.** The libs build (LLVM) takes hours; run it\n  `nohup … > /…/x.log 2>&1 &` and poll the log, so an ssh drop doesn't kill it.\n- **The one-time root bootstrap needs `expect`.** FreeBSD's `nuageinit` supports only\n  `ssh_authorized_keys` + `chpasswd` (not cloud-init's `write_files`/`runcmd`), so the\n  package install and `doas.conf` setup are driven through `su -m root` with `expect`,\n  exactly as CI does it. After that, `doas` is on disk and persists across reboots.\n\n## Step 0 — verify prerequisites (do NOT assume they're installed)\n\nThis skill needs, on the host:\n\n- an existing ponyc checkout (you run the `rsync` from it)\n- hardware-accelerated virtualization for QEMU (KVM on Linux, HVF on macOS)\n- enough free disk for the VM's sparse qcow2 disk (provisioned at 60 GB nominal; it grows\n  only as used — the libs build plus a debug build take the qcow2 to ~12 GB, and a release\n  build on top adds more), ~1 GB for the compressed image, and network access to\n  download.freebsd.org\n- `qemu-system-x86_64` and `qemu-img`\n- `cloud-localds` (from cloud-image-utils) or `genisoimage` to build the cloud-init\n  seed image\n- `expect` (drives the one-time `su` root bootstrap)\n- `xz`, `rsync`, `git`, `curl`, `python3`\n- an OpenSSH client (`ssh`, `scp`, `ssh-keygen`)\n\nNote this list is shorter than the DragonFly skill's: FreeBSD needs no `bsdtar`/`bunzip2`\n(no ISO header mining) and no PPM-to-PNG converter (no console screendumps).\n\nCheck that every required tool is present. Do not assume the environment has them, and do\nnot install anything yourself — installing them is the user's call (it needs privilege,\nand the package names vary by OS). If anything is missing, **stop, tell the user which\ntools/capabilities are missing, and ask them to install them with their platform's\npackage manager**, then re-run the check before proceeding.\n\n```sh\nmissing=\"\"\nfor t in qemu-system-x86_64 qemu-img expect xz rsync git ssh scp ssh-keygen curl python3; do\n  command -v \"$t\" >/dev/null 2>&1 || missing=\"$missing $t\"\ndone\ncommand -v cloud-localds >/dev/null 2>&1 || command -v genisoimage >/dev/null 2>&1 \\\n  || missing=\"$missing seed-builder(cloud-localds|genisoimage)\"\n# Hardware acceleration is host-specific — check the right accelerator for this OS.\ncase \"$(uname -s)\" in\n  Linux)  [ -w /dev/kvm ] || missing=\"$missing kvm(/dev/kvm-not-writable)\" ;;\n  Darwin) qemu-system-x86_64 -accel help 2>&1 | grep -q hvf || missing=\"$missing hvf-accelerator\" ;;\n  *)      echo \"NOTE: confirm this host has a QEMU hardware accelerator and adjust the boot accel= below\" ;;\nesac\n[ -n \"$missing\" ] && echo \"MISSING:$missing\" || echo \"all prerequisites present\"\n```\n\nIf the output is anything but `all prerequisites present`, name the missing\ntools/capabilities to the user and ask them to install them however their OS does — do\nNOT guess a distro package name. (`cloud-localds` ships in `cloud-image-utils` on\nDebian/Ubuntu; if it is unavailable, the cidata seed can also be built with\n`genisoimage -output seed.img -volid cidata -joliet -rock user-data meta-data`, which is\nwhat the OpenBSD CI path uses.) A missing hardware accelerator — no writable `/dev/kvm`\non Linux, no HVF on macOS — is a host/BIOS/virtualization fix only the user can make; the\nVM is unusably slow without it, so don't fall back to TCG emulation.\n\nThis flow is verified on Linux+KVM through the libs build and a debug build. The guest side\nis OS-agnostic, so macOS goes through the same steps with the HVF accelerator\n(`accel=kvm:hvf` in Step 3 selects it).\n\n## Setup\n\nPick a persistent VM directory and a FreeBSD version that **matches ponyc CI** (the tier-3\n`freebsd` job in `.github/workflows/ponyc-tier3.yml` tests a single version — currently\n15.1; the image URL is in `.ci-scripts/bsd/freebsd-provision.bash`). Use the same value\nin every block below.\n\n### 1. Download the image (one time; keep the `.xz` to avoid re-downloading)\n\nThe BASIC-CLOUDINIT image boots straight to a cloud-init-configured system — no installer,\nand it ships the full base system including `/usr/include`.\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1\nmkdir -p \"$VMDIR\" && cd \"$VMDIR\"\nbase=\"https://download.freebsd.org/releases/VM-IMAGES/${FREEBSD_VERSION}-RELEASE/amd64/Latest\"\ncurl -fL --retry 3 -o freebsd.qcow2.xz \\\n  \"$base/FreeBSD-${FREEBSD_VERSION}-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz\"\nxz -dk freebsd.qcow2.xz                 # -k keeps the .xz original\nqemu-img resize freebsd.qcow2 60G       # first boot grows the root fs to fill this\n```\n\n### 2. Key + cloud-init seed\n\n`nuageinit` reads a cidata seed image at boot: it installs your ssh public key for the\n`freebsd` user and sets root's password (so the Step 5 `su` bootstrap can reach root).\n\n```sh\nVMDIR=~/vms/freebsd-15.1; cd \"$VMDIR\"\ntest -f vm_key || ssh-keygen -t ed25519 -f vm_key -N \"\" -q\ncat > user-data <<USERDATA\n#cloud-config\nssh_authorized_keys:\n  - $(cat vm_key.pub)\nchpasswd:\n  expire: false\n  list:\n    - root:ciroot\nUSERDATA\ncat > meta-data <<'METADATA'\ninstance-id: freebsd-dev\nlocal-hostname: freebsd-dev\nMETADATA\ncloud-localds seed.img user-data meta-data\n```\n\n### 3. Boot (daemonized, persistent)\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1; cd \"$VMDIR\"\nqemu-system-x86_64 \\\n  -name freebsd-${FREEBSD_VERSION} \\\n  -machine pc,accel=kvm:hvf -cpu host -smp 8 -m 12G \\\n  -drive file=freebsd.qcow2,format=qcow2,if=virtio \\\n  -drive file=seed.img,format=raw,if=virtio \\\n  -netdev user,id=net0,hostfwd=tcp::2222-:22 \\\n  -device virtio-net-pci,netdev=net0 \\\n  -display none -pidfile freebsd.pid -daemonize\n```\n\n(`accel=kvm:hvf` picks KVM on Linux or HVF on macOS and errors if neither is available —\nit never silently falls back to slow TCG. `-smp`/`-m` are speed knobs; CI uses 4 CPUs /\n12G. `hostfwd 2222->22` is the ssh port — to run two FreeBSD VMs at once (e.g. CI's 15.1\nplus another version you want to compare against), give each its own `$VMDIR` and a\ndistinct hostfwd port, and pass that port to every ssh/scp/rsync `-p`. The seed drive stays attached — `nuageinit` skips it on\nlater boots once the instance-id is seen. This boot command is adapted from\n`.ci-scripts/bsd/freebsd-provision.bash`: it adds `-pidfile`/`-smp 8` and drops the CI\nhost-bootstrap; CI has no rng or monitor device for FreeBSD and neither does this.)\n\n### 4. Wait for ssh (first boot runs nuageinit + growfs)\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1; cd \"$VMDIR\"\nup=\"\"\nfor i in $(seq 1 150); do   # ~5 min, matching CI's timeout\n  if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=2 -i vm_key -p 2222 \\\n       freebsd@localhost true 2>/dev/null; then up=1; echo \"SSH up\"; break; fi\n  sleep 2\ndone\n[ -n \"$up\" ] || echo \"SSH NEVER CAME UP — see recovery below.\"\n```\n\nIf ssh never comes up, this VM has no VGA console to look at (it booted headless), so\ndiagnose in this order:\n\n- **Is the process alive?** `pgrep -af \"qemu-system-x86_64 -name freebsd-${FREEBSD_VERSION}\"`.\n  If it's gone, the boot died early (usually a bad/incomplete image from Step 1) — re-check\n  the Step 1 download/resize and re-boot.\n- **Watch the boot.** The image logs to the serial console, so re-boot with a serial log\n  added — `kill \"$(cat freebsd.pid)\"`, then re-run the Step 3 boot command with\n  `-serial file:$VMDIR/console.log` appended — and read `console.log` to see how far it got.\n- **ssh actively *refuses* the key (vs. just timing out)?** That means `nuageinit` never\n  installed it — the seed didn't apply. Confirm `seed.img` is attached as a drive in the\n  boot command (Step 3) and was built in Step 2, then re-boot.\n\n### 5. One-time root bootstrap (install deps + configure doas) via expect\n\nFreeBSD's `nuageinit` can't run `write_files`/`runcmd`, so — exactly as CI does — drive\n`su -m root` with `expect` to install the build dependencies and grant the `freebsd` user\npasswordless `doas`. This runs once; `doas.conf` persists on disk for the VM's life. (The\nexpect block is adapted from the one embedded in `freebsd-provision.bash`.)\n\n```sh\nVMDIR=~/vms/freebsd-15.1; cd \"$VMDIR\"\nexpect <<'EXPECT'\nset timeout 600\nspawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 -t freebsd@localhost su -m root\nexpect {\n  \"Password:\" { send \"ciroot\\r\" }\n  timeout { puts \"Timeout waiting for password prompt\"; exit 1 }\n}\nexpect {\n  \"su: Sorry\" { puts \"su failed - wrong password or nuageinit didn't set it\"; exit 1 }\n  \"#\" {}\n  timeout { puts \"Timeout waiting for root shell\"; exit 1 }\n}\nsend \"pkg install -y cmake gmake libunwind git python3 rsync doas\\r\"\nexpect {\n  \"#\" {}\n  timeout { puts \"Timeout during pkg install\"; exit 1 }\n}\nsend \"echo 'permit nopass freebsd' > /usr/local/etc/doas.conf\\r\"\nexpect {\n  \"#\" {}\n  timeout { puts \"Timeout writing doas.conf\"; exit 1 }\n}\nsend \"exit\\r\"\nexpect eof\nEXPECT\n```\n\nIf `expect` prints one of its `Timeout …` messages, it's safe to re-run this block — the\n`pkg install -y` and the clobbering `>` write to `doas.conf` are both idempotent. The one\nexception is `su failed`: that means the seed never set root's password, so root was never\nreachable. Re-running won't fix it — that's a boot/seed problem, so work through the Step 4\nrecovery (is the VM up? did `seed.img` apply?) first.\n\n### 6. Rsync the ponyc checkout in (exclude the host `build/`)\n\nRun from your ponyc checkout. Exclude the top-level `build/` (host artifacts; the VM\nbuilds its own). Keep `.git` (CMake runs `git rev-parse`). The vendored LLVM submodule\nunder `lib/llvm/src` IS transferred — the VM needs it for the libs build.\n\n```sh\nVMDIR=~/vms/freebsd-15.1\nrsync -az --exclude='/build' \\\n  -e \"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222\" \\\n  \"$(git rev-parse --show-toplevel)/\" freebsd@localhost:/home/freebsd/ponyc/\n```\n\n## Using the VM\n\nThe build uses FreeBSD's default clang — no `CC`/`CXX`/`LD_LIBRARY_PATH` exports (that is\nthe DragonFly-only gcc13 dance). `cmake -P lib/build-libs.cmake` builds the vendored LLVM and is the multi-hour\nlong pole — run it detached, then poll the log until it ends with `libs DONE rc=0` (a\nnonzero rc means it failed — read the log above the marker):\n\n```sh\nVMDIR=~/vms/freebsd-15.1\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 freebsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncat > /home/freebsd/run-libs.sh <<'S'\n#!/bin/sh\ncd /home/freebsd/ponyc && cmake -DJOBS=4 -P lib/build-libs.cmake\necho \"libs DONE rc=$?\"\nS\nchmod +x /home/freebsd/run-libs.sh\nnohup /home/freebsd/run-libs.sh > /home/freebsd/libs-build.log 2>&1 &\necho \"libs building pid $!\"\nEOF\n# poll until done: $SSH /bin/sh -c 'tail -3 /home/freebsd/libs-build.log'  -> wait for \"libs DONE rc=0\"\n```\n\nOnce `libs` is built (it persists in the VM), build and test with the same shell. Do not\nrun this block until the poll shows `libs DONE rc=0` — `cmake --build` against a half-built\n`build/libs` fails:\n\n```sh\nVMDIR=~/vms/freebsd-15.1\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 freebsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncd /home/freebsd/ponyc\ncmake --preset debug\ncmake --build --preset debug\nctest --preset debug -L ci-core\nEOF\n```\n\nTo iterate on a fix: edit on the host, re-run Step 6's `rsync` (it's incremental — only\nchanged files are sent), then re-run the build block above (long rebuilds should also be\ndetached + polled). For exact CI parity, the tier-3 `freebsd` job\n(`.github/workflows/ponyc-tier3.yml`) runs more than this — a `--static` embedded-LLD link\nsmoke, the self-hosted tool tests (`pony-doc-tests`/`pony-lint-tests`/`pony-lsp-tests`), a\nrelease build + the `ci-core` suite, and two extra smokes:\n`.ci-scripts/freebsd-sanitizer-smoke.sh`. Consult that job when validating a\nCI-matching issue.\n\n## Lifecycle\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1; cd \"$VMDIR\"\npgrep -af \"qemu-system-x86_64 -name freebsd-${FREEBSD_VERSION}\"        # status\nssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 freebsd@localhost   # interactive shell\n# stop: CI stops this VM by killing the process; do the same. Disks persist for next time.\n# The pgrep/pkill are scoped to this version so a second FreeBSD VM (e.g. another\n# version you're comparing against) isn't matched; the pidfile path is per-$VMDIR anyway.\nkill \"$(cat freebsd.pid)\" 2>/dev/null || pkill -f \"qemu-system-x86_64 -name freebsd-${FREEBSD_VERSION}\"\n```\n\nTo restart later, re-run the step-3 boot command (the disk and installed deps persist; you\ndo not redo setup). On a reboot, ssh-as-`freebsd` works immediately (the key was written\nto disk on first boot) and `doas` still works (`doas.conf` persisted) — there is no\nre-bootstrap, unlike the DragonFly VM.\n\n## How this relates to CI (don't re-add the CI-only steps)\n\nThis mirrors `.ci-scripts/bsd/freebsd-provision.bash` (the source of truth — keep this\nskill in sync if it changes). The local setup deliberately differs from CI, and each\ndifference is safe:\n\n- Skips CI host-bootstrap (free disk space, `apt-get` qemu, make `/dev/kvm` writable) —\n  your host already has qemu and a usable hardware accelerator (Step 0 verified it).\n- Persistent VM instead of built-fresh-and-killed per run.\n- ssh adds `-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR` to quiet the host-key\n  warning: a persistent VM reused on `localhost:2222` collides with the stale\n  `known_hosts` entry from a prior VM. CI's ephemeral runners never reuse the port, so\n  `freebsd-provision.bash` doesn't bother.\n- `-smp 8` for build speed (CI uses 4), and a `-pidfile` for the lifecycle commands.\n- No GHCR libs cache (that's token-gated CI plumbing) — you just run `cmake -P lib/build-libs.cmake` once.\n\nThe DragonFly and OpenBSD CI VMs follow the same shape\n(`.ci-scripts/bsd/{dragonfly,openbsd}-provision.bash`) and each has its own skill:\n`create-dragonfly-dev-env` (gcc-only, and it drives a VGA console) and\n`create-openbsd-dev-env` (also cloud-init, but with static-PIE static-link checks, a raised\n`datasize` limit, and the source under a `/build` disk). This skill is FreeBSD-only; load a\nsibling if you need one of those.\n",".claude/skills/create-openbsd-dev-env/SKILL.md":"---\nname: create-openbsd-dev-env\ndescription: Load when you need to reproduce or validate an OpenBSD-specific ponyc issue on a local VM, or to stand up a local OpenBSD development VM that matches ponyc tier-3 CI. Covers the cloud-init seed (cdrom), the sudo-free QEMU/KVM boot, the doas-via-cloud-init setup (no expect bootstrap), the dedicated /build data disk, the datasize limit, the clang build env, and the gotchas (openbsd-user login, disklabel build disk, raised datasize, static-PIE static linking, pkg_add naming, detached builds, per-block shells) that make hand-rolling one error-prone.\ndisable-model-invocation: false\n---\n\n# Create an OpenBSD dev/test VM for ponyc\n\nStand up a local OpenBSD VM that matches the ponyc tier-3 CI job, so you can build\nand test ponyc on OpenBSD without GitHub Actions.\n\nOpenBSD's tier-3 job exercises paths the Linux jobs never do: the static-PIE `--static`\nembedded-LLD link recipe, OpenBSD's base clang/libc++, and the per-process `datasize`\nlimit that the LLVM build pushes against. It also asserts the `use=` build options that\ncan't work there are rejected. So this VM is the authoritative local environment for\n**OpenBSD-platform** ponyc issues. (It is clang-based — OpenBSD's base compiler is clang,\nnot gcc. For **gcc-specific** issues use the DragonFly skill instead.)\n\nRun the repo-relative commands (`rsync` of the source) from your ponyc checkout root. The\nVM itself lives in a separate directory (`$VMDIR`).\n\n**Each fenced `sh` block below is self-contained.** It re-sets `VMDIR`/`OPENBSD_VERSION`\n(and, where needed, the `SSH` command) at its top, because an agent runs each block as a\nfresh shell — environment variables and the working directory do NOT carry from one block\nto the next. Do not \"optimize\" by setting them once and dropping them from later blocks:\nan empty `$SSH` would make a `$SSH /bin/sh <<'EOF' ... EOF` heredoc run **on your host**\n(silently) instead of in the VM — and Step 5 contains `newfs` and Step 6 edits\n`/etc/login.conf`. Keep every block whole. Pick your `VMDIR`/`OPENBSD_VERSION` once and use\nthe same values in every block.\n\n## Gotchas that will bite you (read first)\n\nOpenBSD is a hybrid of the other two BSD skills: like FreeBSD it boots from a cloud-init\nseed and is driven entirely over ssh (no VGA-console automation), but like DragonFly the\nbuild lives on a dedicated data disk. These matter:\n\n- **Per-block fresh shells.** See above: re-establish `VMDIR`/`OPENBSD_VERSION`/`SSH` in\n  every block; never let `$SSH` be empty in front of a `/bin/sh` heredoc.\n- **You log in as `openbsd`, not `root`.** The cloud-init seed creates the `openbsd` user\n  in the `wheel` group and writes `/etc/doas.conf` (`permit nopass keepenv :wheel`). Build\n  and test run **as `openbsd`**; the few root operations (disk setup, package install,\n  editing `login.conf`) go through `doas`, which is configured before first login and\n  persists on disk. There is **no** `expect`/`su` bootstrap — OpenBSD's cloud-init runs\n  `write_files`, so `doas` is in place from the first boot (simpler than FreeBSD).\n- **`doas` is in the base system.** Nothing to install; cloud-init just drops\n  `/etc/doas.conf`. Keep its `0600 root:wheel` permissions exactly — `doas` refuses to run\n  with a group- or world-writable config.\n- **Cloud-init means no console driving.** Unlike the DragonFly skill there is no QEMU\n  `sendkey`/screendump/monitor dance — just boot and wait for ssh. But the **first** boot\n  runs cloud-init (user + key + `doas.conf`), so ssh is not instant; poll for it (Step 4).\n- **Build on the `/build` data disk, not `/home`.** OpenBSD's default disklabel confines\n  writable space to `/home` (~10.8G), which the LLVM 22 build overflows. A second qcow2 is\n  partitioned, `newfs`'d, and mounted at `/build`, and the source plus the build live\n  there — mirroring the DragonFly job. The disklabel setup is **one-time and not\n  idempotent** (`newfs` reformats), so run Step 5 exactly once.\n- **The `/build` mount is not persisted across reboot.** `disklabel`/`newfs`/`mount` are\n  manual one-time steps with no `/etc/fstab` entry; after a guest reboot, remount\n  `/dev/sd1a` at `/build`. `/etc/login.conf` and `/etc/doas.conf` are on the root disk and\n  **do** persist.\n- **Raise the `datasize` limit.** OpenBSD caps a process's data segment in\n  `/etc/login.conf` (1536M on the stock 7.9 image); the LLVM build needs more, so Step 6\n  raises `datasize-max`/`-cur` to at least 8192M. Without it the libs build dies with\n  allocation failures partway through. The edit is on the root disk and persists.\n- **OpenBSD's base system has no `bash`.** `/bin/sh` is a POSIX `ksh`; in-VM scripts must\n  be POSIX `sh`. Don't reach for bashisms in a `$SSH /bin/sh` heredoc.\n- **Two `pkg_add` names look like typos but aren't:** `python%3` (the `%3` selects the 3.x\n  branch) and `rsync--` (the trailing `--` picks the no-flavor build). Copy them verbatim.\n- **Detach long in-VM builds.** The libs build (LLVM) takes hours; run it\n  `nohup … > /build/x.log 2>&1 &` and poll the log, so an ssh drop doesn't kill it.\n\n## Step 0 — verify prerequisites (do NOT assume they're installed)\n\nThis skill needs, on the host:\n\n- an existing ponyc checkout (you run the `rsync` from it)\n- hardware-accelerated virtualization for QEMU (KVM on Linux, HVF on macOS)\n- enough free disk for the VM's two sparse qcow2 disks (the shipped root image plus a 50 GB\n  nominal data disk; they grow only as used — the libs build plus a debug build take the data\n  disk to several GB), network access to github.com (the image is a GitHub release asset)\n- `qemu-system-x86_64` and `qemu-img`\n- `genisoimage` (builds the cidata seed CD-ROM; `cloud-localds` can also produce an\n  equivalent cidata image)\n- `rsync`, `git`, `curl`\n- an OpenSSH client (`ssh`, `scp`, `ssh-keygen`)\n\nNote this is the shortest prerequisite list of the three BSD skills: OpenBSD needs no\n`expect` (cloud-init configures `doas` directly), no `xz` (the image ships as a plain\nqcow2 — no decompress, no root-fs resize), and no `bsdtar`/PPM-converter (it is headless\nwith no ISO header mining and no console screendumps).\n\nCheck that every required tool is present. Do not assume the environment has them, and do\nnot install anything yourself — installing them is the user's call (it needs privilege, and\nthe package names vary by OS). If anything is missing, **stop, tell the user which\ntools/capabilities are missing, and ask them to install them with their platform's package\nmanager**, then re-run the check before proceeding.\n\n```sh\nmissing=\"\"\nfor t in qemu-system-x86_64 qemu-img rsync git ssh scp ssh-keygen curl; do\n  command -v \"$t\" >/dev/null 2>&1 || missing=\"$missing $t\"\ndone\ncommand -v genisoimage >/dev/null 2>&1 || command -v cloud-localds >/dev/null 2>&1 \\\n  || missing=\"$missing seed-builder(genisoimage|cloud-localds)\"\n# Hardware acceleration is host-specific — check the right accelerator for this OS.\ncase \"$(uname -s)\" in\n  Linux)  [ -w /dev/kvm ] || missing=\"$missing kvm(/dev/kvm-not-writable)\" ;;\n  Darwin) qemu-system-x86_64 -accel help 2>&1 | grep -q hvf || missing=\"$missing hvf-accelerator\" ;;\n  *)      echo \"NOTE: confirm this host has a QEMU hardware accelerator and adjust the boot accel= below\" ;;\nesac\n[ -n \"$missing\" ] && echo \"MISSING:$missing\" || echo \"all prerequisites present\"\n```\n\nIf the output is anything but `all prerequisites present`, name the missing\ntools/capabilities to the user and ask them to install them however their OS does — do NOT\nguess a distro package name. (`genisoimage` ships in `genisoimage` or `cdrkit` on\nDebian/Ubuntu; CI uses `genisoimage`. If only `cloud-localds` is available it can build the\nsame cidata image — attach it as the cdrom in Step 3.) A missing hardware accelerator — no\nwritable `/dev/kvm` on Linux, no HVF on macOS — is a host/BIOS/virtualization fix only the\nuser can make; the VM is unusably slow without it, so don't fall back to TCG emulation.\n\nThis flow is verified on Linux+KVM through VM bring-up, the `/build` disk plus\n`doas`/`datasize` setup, dependency install, the source rsync, and the unsupported-build\nrejection smoke (`.ci-scripts/openbsd-reject-unsupported-builds.sh`, which runs `cmake …\n-DPONY_USES=…` and so needs no LLVM). The LLVM build (`cmake -P lib/build-libs.cmake`), the subsequent\n`cmake --preset debug`/build — which require the built LLVM (CMake's `find_package(LLVM)`\nfails without it) — and the test suites were **not** run locally; those steps mirror\n`.ci-scripts/bsd/openbsd-provision.bash` and the tier-3 `openbsd` job, which CI exercises\nend-to-end and keeps green. The guest side is OS-agnostic, so macOS goes through the same\nsteps with the HVF accelerator (`accel=kvm:hvf` in Step 3 selects it).\n\n## Setup\n\nPick a persistent VM directory and an OpenBSD version that **matches ponyc CI** (the tier-3\n`openbsd` job in `.github/workflows/ponyc-tier3.yml` runs OpenBSD 7.9; the exact image URL,\nincluding the dated build tag, is in `.ci-scripts/bsd/openbsd-provision.bash`). Use the same\ntwo values in every block below.\n\n### 1. Download the image + create the build disk (one time)\n\nOpenBSD ships no official cloud image, so CI uses the community\n`hcartiaux/openbsd-cloud-image` GitHub release — a plain qcow2 that boots straight to a\ncloud-init-configured system. Pin the **full dated release tag** that\n`openbsd-provision.bash` references (a bare `v7.9` is not a valid asset path). The root\nimage is left at its shipped size; the build does not live on it.\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9\nmkdir -p \"$VMDIR\" && cd \"$VMDIR\"\ncurl -fL --retry 3 -o openbsd.qcow2 \\\n  \"https://github.com/hcartiaux/openbsd-cloud-image/releases/download/v7.9_2026-06-03-20-46/openbsd-generic.qcow2\"\n# Build-workspace disk. OpenBSD's default disklabel confines builds to /home\n# (~10.8G), which the LLVM 22 build overflows, so build on a dedicated disk.\nqemu-img create -f qcow2 openbsd-data.qcow2 50G\n```\n\n### 2. Key + cloud-init seed\n\nOpenBSD's cloud-init reads a cidata seed at boot: it creates the `openbsd` user (in\n`wheel`) with your ssh public key, and writes `/etc/doas.conf` so the `openbsd` user gets\npasswordless `doas`. Build the seed as an ISO and attach it as a CD-ROM (Step 3).\n\n```sh\nVMDIR=~/vms/openbsd-7.9; cd \"$VMDIR\"\ntest -f vm_key || ssh-keygen -t ed25519 -f vm_key -N \"\" -q\ncat > user-data <<USERDATA\n#cloud-config\nusers:\n- name: openbsd\n  groups: wheel\n  ssh_authorized_keys:\n  - $(cat vm_key.pub)\nwrite_files:\n- path: /etc/doas.conf\n  content: |\n    permit nopass keepenv :wheel\n  owner: root:wheel\n  permissions: '0600'\nUSERDATA\ncat > meta-data <<'METADATA'\ninstance-id: openbsd-dev\nlocal-hostname: openbsd-dev\nMETADATA\ngenisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data\n```\n\n### 3. Boot (daemonized, persistent)\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9; cd \"$VMDIR\"\nqemu-system-x86_64 \\\n  -name openbsd-${OPENBSD_VERSION} \\\n  -machine pc,accel=kvm:hvf -cpu host -smp 8 -m 12G \\\n  -drive file=openbsd.qcow2,format=qcow2,if=virtio \\\n  -drive file=openbsd-data.qcow2,format=qcow2,if=virtio \\\n  -drive file=seed.iso,media=cdrom \\\n  -netdev user,id=net0,hostfwd=tcp::2222-:22 \\\n  -device virtio-net-pci,netdev=net0 \\\n  -display none -pidfile openbsd.pid -daemonize\n```\n\n(`accel=kvm:hvf` picks KVM on Linux or HVF on macOS and errors if neither is available — it\nnever silently falls back to slow TCG; CI uses bare `accel=kvm` because it always runs on\nLinux. `-smp`/`-m` are speed knobs; CI uses 4 CPUs / 12G. The build is capped by the 8192M\n`datasize` limit per process, not by total RAM. `hostfwd 2222->22` is the ssh port — to run\ntwo OpenBSD VMs at once, give each its own `$VMDIR` and a distinct hostfwd port, and pass\nthat port to every ssh/scp/rsync `-p`. The seed is attached as a **cdrom** — that is how\nOpenBSD's cloud-init finds the cidata volume; do not copy FreeBSD's raw-virtio-disk seed\nform here. This boot command is adapted from `.ci-scripts/bsd/openbsd-provision.bash`: it\nadds `-pidfile`/`-smp 8`, drops the CI host-bootstrap, and like CI uses no rng or monitor\ndevice.)\n\n### 4. Wait for ssh (first boot runs cloud-init)\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9; cd \"$VMDIR\"\nup=\"\"\nfor i in $(seq 1 150); do   # ~5 min, matching CI's timeout\n  if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=2 -i vm_key -p 2222 \\\n       openbsd@localhost true 2>/dev/null; then up=1; echo \"SSH up\"; break; fi\n  sleep 2\ndone\n[ -n \"$up\" ] || echo \"SSH NEVER CAME UP — see recovery below.\"\n```\n\nIf ssh never comes up, this VM has no VGA console to look at (it booted headless), so\ndiagnose in this order:\n\n- **Is the process alive?** `pgrep -af \"qemu-system-x86_64 -name openbsd-${OPENBSD_VERSION}\"`.\n  If it's gone, the boot died early (usually a bad/incomplete image from Step 1) — re-check\n  the Step 1 download and re-boot.\n- **Watch the boot.** The image logs to the serial console, so re-boot with a serial log\n  added — `kill \"$(cat openbsd.pid)\"`, then re-run the Step 3 boot command with\n  `-serial file:$VMDIR/console.log` appended — and read `console.log` to see how far it got.\n- **ssh actively *refuses* the key (vs. just timing out)?** That means cloud-init never\n  applied the seed. Confirm `seed.iso` is attached as a **cdrom** drive in the boot command\n  (Step 3) and was built in Step 2, then re-boot.\n\n### 5. One-time build-disk setup (disklabel, newfs, mount)\n\nOpenBSD sees the second virtio disk as `sd1`. Give it a single 4.2BSD partition spanning\nthe disk, `newfs` it, mount it at `/build`, and `chown` it to `openbsd` so the later rsync\nand build run without root. The `printf` drives the interactive `disklabel -E` editor (`a a`\nadd partition, then default offset/size/type, `w` write, `q` quit) — it is fiddly and copied\nverbatim from CI. **Run this once:** `newfs` reformats, so re-running destroys whatever is\non `/build`.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\nprintf 'a a\\n\\n\\n\\nw\\nq\\n' | doas disklabel -E sd1\ndoas newfs /dev/rsd1a\ndoas mkdir -p /build\ndoas mount /dev/sd1a /build\ndoas chown openbsd:wheel /build\ndf -h /build\nEOF\n```\n\nIf the `doas` commands here fail (permission denied, or `doas` prompts for a password),\ncloud-init's `write_files` never created `/etc/doas.conf` — the seed didn't apply. Don't\nretype the disk commands; nothing here works until `doas` does, so work through the Step 4\nrecovery (is the VM up? is `seed.iso` attached as a **cdrom**?) first.\n\n### 6. Install deps + raise the datasize limit\n\n`doas pkg_add` installs the build tools (the `python%3`/`rsync--` spellings are correct —\nsee Gotchas). Then raise the per-process `datasize` ceiling for the `openbsd` user — and\ntreat this as intent, not a fixed substitution. OpenBSD's `/etc/login.conf` caps each\nprocess's data segment in the `default` login class (the class the `openbsd` user falls\nunder), and the LLVM build needs roughly 4 GB, so both `datasize-cur` and `datasize-max`\nmust end up at least `8192M` (what CI sets). On the stock OpenBSD 7.9 image both default to\n`1536M`, so the two `sed`s below do it. But the goal is the `8192M` ceiling, not the literal\n`1536M`: if a future image ships a different default the `sed`s match nothing, so read the\ncurrent values (the trailing `grep` prints them) and, if either is below `8192M`, raise it\nto `8192M` directly — adjust the `sed` pattern or edit `/etc/login.conf`. Confirm both read\n`8192M` before continuing; too low and the libs build dies with allocation failures partway\nthrough, far from this step. The edit persists on the root disk and re-running is a no-op.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ndoas pkg_add -u && doas pkg_add cmake gmake git python%3 rsync--\n# Raise the openbsd user's datasize cap to 8192M. The stock 7.9 image\n# defaults both to 1536M; if a future image differs, the sed matches\n# nothing — read the grep below and set both to 8192M however fits.\ndoas sed -i 's/datasize-max=1536M/datasize-max=8192M/' /etc/login.conf\ndoas sed -i 's/datasize-cur=1536M/datasize-cur=8192M/' /etc/login.conf\ngrep -E 'datasize-(cur|max)=' /etc/login.conf   # confirm both now read 8192M\nEOF\n```\n\n### 7. Rsync the ponyc checkout in (exclude the host `build/`)\n\nRun from your ponyc checkout. Exclude the top-level `build/` (host artifacts; the VM builds\nits own — this `--exclude` is a deliberate divergence from CI, whose fresh checkout has none\nto skip). Keep `.git` (CMake runs `git rev-parse`). The vendored LLVM submodule under\n`lib/llvm/src` IS transferred — the VM needs it for the libs build.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nrsync -az --exclude='/build' \\\n  -e \"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222\" \\\n  \"$(git rev-parse --show-toplevel)/\" openbsd@localhost:/build/ponyc/\n```\n\n## Using the VM\n\nThe build uses OpenBSD's base clang — no `CC`/`CXX`/`LD_LIBRARY_PATH` exports (that is the\nDragonFly-only gcc13 dance). `cmake -P lib/build-libs.cmake` builds the vendored LLVM and is the multi-hour long\npole — run it detached, then poll the log until it ends with `libs DONE rc=0` (a nonzero rc\nmeans it failed — read the log above the marker). Keep `-DJOBS=4` (what CI uses):\neach parallel compile is bounded by the 8192M `datasize` cap, so higher parallelism is the\nfirst thing to suspect on a libs out-of-memory.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncat > /build/run-libs.sh <<'S'\n#!/bin/sh\ncd /build/ponyc && cmake -DJOBS=4 -P lib/build-libs.cmake\necho \"libs DONE rc=$?\"\nS\nchmod +x /build/run-libs.sh\nnohup /build/run-libs.sh > /build/libs-build.log 2>&1 &\necho \"libs building pid $!\"\nEOF\n# poll until done: $SSH /bin/sh -c 'tail -3 /build/libs-build.log'  -> wait for \"libs DONE rc=0\"\n```\n\nOnce `libs` is built (it persists in the VM), build and test with the same shell. Do not run\nthis block until the poll shows `libs DONE rc=0` — even `cmake --preset debug` needs\nthe built LLVM (CMake's `find_package(LLVM)` fails without it), so it cannot run before\nthe libs build:\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncd /build/ponyc\ncmake --preset debug\ncmake --build --preset debug\nctest --preset debug -L ci-core\nEOF\n```\n\nTo iterate on a fix: edit on the host, re-run Step 7's `rsync` (it's incremental — only\nchanged files are sent), then re-run the build block above (long rebuilds should also be\ndetached + polled). For exact CI parity, the tier-3 `openbsd` job\n(`.github/workflows/ponyc-tier3.yml`) runs more than this. In order: an\nunsupported-build-rejection smoke that runs **before** the libs build (it needs no LLVM —\n`.ci-scripts/openbsd-reject-unsupported-builds.sh` asserts `use=address_sanitizer`,\n`thread_sanitizer`, `undefined_behavior_sanitizer`, `coverage`, and `valgrind` are\neach rejected with \"not supported on OpenBSD\"); then debug configure/build; a `--static`\nembedded-LLD link smoke (OpenBSD static is static-PIE, so the binary's `file` output reads\n\"shared object\" — the smoke instead asserts `readelf -d` shows **no** `(NEEDED)` entries);\nthe debug `ci-core` suite; the self-hosted tool tests (`pony-doc-tests`/`pony-lint-tests`/\n`pony-lsp-tests`); and a release build + the `ci-core` suite. Consult that job when validating a\nCI-matching issue.\n\n## Lifecycle\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9; cd \"$VMDIR\"\npgrep -af \"qemu-system-x86_64 -name openbsd-${OPENBSD_VERSION}\"        # status\nssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 openbsd@localhost   # interactive shell\n# stop: kill the process. Disks persist for next time. The pgrep/pkill are scoped to this\n# version so a second OpenBSD VM isn't matched; the pidfile path is per-$VMDIR anyway.\nkill \"$(cat openbsd.pid)\" 2>/dev/null || pkill -f \"qemu-system-x86_64 -name openbsd-${OPENBSD_VERSION}\"\n```\n\nTo restart later, re-run the step-3 boot command (the disks and installed deps persist; you\ndo not redo setup). On a reboot, ssh-as-`openbsd` works immediately and `doas` still works\n(`/etc/doas.conf` persisted), and the raised `datasize` limit is still in `/etc/login.conf`\n— but the `/build` data disk is **not** auto-mounted (there is no `/etc/fstab` entry), so\nremount it before building: `doas mount /dev/sd1a /build`. The source under `/build/ponyc`\nis preserved on the data disk across the remount. For a long-lived VM, add the mount to\n`/etc/fstab` so it survives reboots.\n\n## How this relates to CI (don't re-add the CI-only steps)\n\nThis mirrors `.ci-scripts/bsd/openbsd-provision.bash` (the source of truth — keep this skill\nin sync if it changes). The local setup deliberately differs from CI, and each difference is\nsafe:\n\n- Skips CI host-bootstrap (free disk space, `apt-get` qemu, make `/dev/kvm` writable) — your\n  host already has qemu and a usable hardware accelerator (Step 0 verified it).\n- Persistent VM instead of built-fresh-and-killed per run.\n- ssh adds `-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR` to quiet the host-key\n  warning: a persistent VM reused on `localhost:2222` collides with the stale `known_hosts`\n  entry from a prior VM. CI's ephemeral runners never reuse the port, so\n  `openbsd-provision.bash` doesn't bother.\n- `-smp 8` / `-m 12G` for build speed (CI uses 4 / 12G), and a `-pidfile` for the lifecycle\n  commands.\n- `rsync --exclude='/build'` to skip the host's build artifacts (CI's fresh checkout has\n  none).\n- No GHCR libs cache (that's token-gated CI plumbing) — you just run `cmake -P lib/build-libs.cmake` once.\n\nThe FreeBSD and DragonFly CI VMs follow the same shape\n(`.ci-scripts/bsd/{freebsd,dragonfly}-provision.bash`) and each has its own skill:\n`create-freebsd-dev-env` (also cloud-init, but base libc++, an `expect`/`su` root\nbootstrap, and the source under `/home/freebsd` rather than a `/build` disk) and\n`create-dragonfly-dev-env` (gcc-only, and it drives a VGA console). This skill is\nOpenBSD-only; load a sibling if you need one of those.\n",".claude/skills/update-ssl-builders/SKILL.md":"---\nname: update-ssl-builders\ndescription: Twice-yearly workflow for bumping ponyc CI SSL builder images (OpenSSL, LibreSSL) to their latest series releases. Invoked via /update-ssl-builders.\ndisable-model-invocation: false\n---\n\n# Update SSL Builders\n\nponyc CI tests `packages/net/` and `packages/crypto/` against multiple SSL implementations. Each has its own Docker builder image under `.ci-dockerfiles/ubuntu26.04-builder-with-<library>-<version>/`. This skill walks through bumping those builders to the latest upstream releases.\n\n## Background\n\n**One version per series**: These are internal CI builders. Each series keeps exactly one version — an update replaces the old version with the new one in a single PR.\n\n**OpenSSL 1.1.1 is EOL**: The `openssl-1.1.1w` builder exists for backward-compatibility testing. This skill does not update or drop it.\n\n**Builders are amd64-only**: Each builder directory contains two files: `Dockerfile` and `build-and-push.bash`. No multiplatform build, no `combine-images.bash`, no `README.md`.\n\n**SSL flag mapping**: The `ssl-backends-test` matrix in `pr.yml` maps each SSL library to a Pony compiler flag:\n\n| Library series | Pony flag |\n|---|---|\n| `libressl-*` | `-Dlibressl` |\n| `openssl-3.*` | `-Dopenssl_3.0.x` |\n| `openssl-4.*` | `-Dopenssl_4.0.x` |\n\nIf a new major version line appears upstream (e.g., OpenSSL 5.x) that has no existing Pony compiler flag, do not add a builder. Open a task issue on this repo to add support for testing with the new version, noting the upstream release.\n\n**Three workflow files reference SSL builders**:\n\n- `rebuild-ssl-builder-images.yml` — `matrix.ssl` list; rebuilds all builders when the base image changes.\n- `build-builder-image.yml` — `inputs.builder-name` dropdown and per-builder job blocks; builds a single chosen builder on demand.\n- `pr.yml` — `ssl-backends-test.strategy.matrix.include`; runs `net` and `crypto` tests against each builder image.\n\n## Phase 1: Inventory\n\nList all `ubuntu26.04-builder-with-*` directories under `.ci-dockerfiles/` and group them by library + series. Ignore `openssl-1.1.1w`.\n\n## Phase 2: Fetch upstream releases\n\n- LibreSSL: https://www.libressl.org/releases.html\n- OpenSSL: https://openssl-library.org/source/\n\nFor each library, find the highest version number available across all currently-supported upstream branches.\n\n## Phase 3: Compute updates and confirm\n\n**Series definition (deliberate project policy)**: One major version line = one series. LibreSSL 3.x, LibreSSL 4.x, OpenSSL 3.x, OpenSSL 4.x are each a single series. This collapses OpenSSL's concurrently-supported LTS branches (e.g., 3.0/3.4/3.5/3.6) into one series — a new release in any 3.x branch counts as \"the latest of OpenSSL 3.x.\"\n\nFor each series (excluding OpenSSL 1.x):\n- If the upstream latest is newer than what we have, plan to **replace** the current version with the new one.\n- If upstream is not newer, no changes for that series.\n\nPresent the plan to the user and wait for explicit confirmation before making any changes.\n\n## Phase 4: Per-update workflow (one PR per series)\n\nEach series update gets its own PR. The PR replaces the old builder with the new one.\n\n1. Create a new branch.\n2. Create a new directory `.ci-dockerfiles/ubuntu26.04-builder-with-<library>-<new-version>/`.\n3. Copy the two files from the current version in the same series: `Dockerfile` and `build-and-push.bash`.\n4. Bump version references in each file:\n   - **Dockerfile**: The version ARG (`OPENSSL_VERSION` or `LIBRESSL_VERSION`). The download URL pattern is stable within a major version line and should not need changing.\n   - **build-and-push.bash**: The `NAME` variable (full GHCR image path including the version) and the `BUILDER` variable (strip dots and dashes from the version, e.g., `ssl-openssl372`, `ssl-libressl430`).\n5. Delete the old directory `.ci-dockerfiles/ubuntu26.04-builder-with-<library>-<old-version>/`.\n6. Edit `.github/workflows/rebuild-ssl-builder-images.yml`: replace the old `<library>-<old-version>` entry in `matrix.ssl` with `<library>-<new-version>`.\n7. Edit `.github/workflows/build-builder-image.yml`:\n   - Replace the old builder name with the new one in the `options` list.\n   - Replace the old job block with a new one. The job key uses underscores for dots (e.g., `ubuntu26_04-builder-with-openssl-3_7_0`). Copy the structure from the old job, updating the `if:` condition, `name:` field, and `run:` step to reference the new directory.\n8. Commit, push, open PR. Do **not** update `pr.yml` yet — there is no image to reference.\n9. Dispatch the build workflow on the branch to build and push the new image to GHCR:\n   ```\n   gh workflow run build-builder-image.yml --ref <branch> -f ref=<branch> -f builder-name=ubuntu26.04-builder-with-<library>-<new-version>\n   ```\n   `--ref <branch>` is required — the new builder name only exists in the branch's `choice` input list. The image is tagged with today's date (`YYYYMMDD` from `build-and-push.bash`).\n10. Wait for the workflow run to complete successfully. Verify the image exists on GHCR:\n    ```\n    gh api orgs/ponylang/packages/container/ponyc-ci-ubuntu26.04-builder-with-<library>-<new-version>/versions --jq '.[0].metadata.container.tags'\n    ```\n11. Edit `.github/workflows/pr.yml`: in `ssl-backends-test.strategy.matrix.include`, replace the old entry with the new one:\n    ```yaml\n    - ssl-name: <library>-<new-version>\n      ssl-flag: '<flag from mapping table>'\n      image: ghcr.io/ponylang/ponyc-ci-ubuntu26.04-builder-with-<library>-<new-version>:<YYYYMMDD>\n    ```\n12. Commit and push.\n13. Wait for PR CI to pass. The `ssl-backends-test` suite should now include the new builder and its `net`/`crypto` tests should pass.\n14. Squash-merge the PR.\n\nExisting tagged images for the old version on GHCR are left intact — they simply stop receiving updates because they are no longer in the rebuild workflow's matrix.\n",".claude/skills/upgrade-llvm/SKILL.md":"---\nname: upgrade-llvm\ndescription: Load when upgrading the vendored LLVM submodule in ponyc. Covers the per-version commit strategy, submodule mechanics, patch handling, hash updates, and common API migration patterns.\ndisable-model-invocation: false\n---\n\n# Upgrading the vendored LLVM in ponyc\n\nponyc vendors LLVM as a submodule at `lib/llvm/src`, pinned by hash in\n`lib/CMakeLists.txt` and patched from `lib/llvm/patches/`. Upgrading it means\nmoving the pin, re-checking the patches, fixing whatever LLVM removed or\nchanged under `src/libponyc/codegen/`, and rebuilding LLVM from source.\n\nRun every command from the repo root.\n\n## Gotchas that will bite you (read first)\n\n- **The submodule is a shallow clone.** `lib/CMakeLists.txt` clones it with\n  `--depth 1` and fetches no tags. A plain `git fetch origin tag llvmorg-XX.Y.Z`\n  deepens it to the whole of llvm-project's history up to that tag. Pass\n  `--depth 1`.\n- **A patch that fails the forward check has not necessarily been upstreamed.**\n  The forward check also fails when the patch is already applied to the tree,\n  and when `git apply` cannot read the tree at all. Work through step 4 before\n  you delete anything.\n- **Don't `rm -rf build`.** That directory holds every configured build tree,\n  not just LLVM's. The vendored LLVM builds in `build/build_libs` and installs\n  to `build/libs`; remove only those two.\n- **Stage the submodule pointer before you build.** `cmake -P\n  lib/build-libs.cmake` runs `git submodule update --init`, which resets\n  `lib/llvm/src` to the recorded pointer. A pointer change that exists only in\n  the working tree is lost.\n- **ponyc still builds when an intrinsic's signature changes.** The failure\n  comes later, when ponyc compiles a Pony program, so run the tests and not\n  just the build.\n\n## Strategy\n\nUpgrade one major version at a time, and give each version its own commit. One\nPR can carry more than one version. Separate commits show which LLVM version\neach source change came from, and let you bisect the branch when something\nbreaks. ponylang repos squash-merge, so those commits become one commit on\n`main` — you can only bisect them while the branch is open.\n\n## Steps\n\nSteps 1, 8, and 9 run once for the PR. Steps 2 through 7 repeat for each major\nversion you move through.\n\n### 1. Branch\n\nBranch off `main`.\n\n### 2. Update the submodule\n\n```bash\n# Discard any patches applied to the tree\ngit -C lib/llvm/src checkout -- .\n# Fetch and check out the new tag, keeping the clone shallow\ngit -C lib/llvm/src fetch --depth 1 origin tag llvmorg-XX.Y.Z\ngit -C lib/llvm/src checkout llvmorg-XX.Y.Z\n```\n\n`git checkout -- .` throws away every modification to a tracked file in the\nsubmodule, including a patch you rebased by hand. Save that work outside the\ntree first.\n\n### 3. Update `LLVM_DESIRED_HASH`\n\nGet the new commit hash:\n\n```bash\ngit -C lib/llvm/src rev-parse HEAD\n```\n\nSet `LLVM_DESIRED_HASH` in `lib/CMakeLists.txt` to this value.\n\n### 4. Check the patches\n\nThe build applies every `.diff` in `lib/llvm/patches/`, so check each one.\nRun the same check the build runs, with the same flags:\n\n```bash\ngit -C lib/llvm/src apply --check -p 1 --ignore-whitespace --whitespace=nowarn \\\n  ../patches/<patch>.diff\n```\n\nIf that fails, run the reverse check before concluding anything:\n\n```bash\ngit -C lib/llvm/src apply --reverse --check -p 1 --ignore-whitespace \\\n  --whitespace=nowarn ../patches/<patch>.diff\n```\n\n- Forward check passes: the patch still applies. Keep it.\n- Forward fails, reverse passes: the change is already in the tree. On a tree\n  you reset in step 2, LLVM upstreamed it, so delete the patch file. On a tree\n  you did not reset, an earlier build applied it — reset and check again.\n- Both fail: either LLVM moved the code the patch touches, or `git apply` could\n  not read the tree. In the second case `git apply` reports `fatal: not a git\n  repository`, which comes from a dangling submodule gitlink — a `.git` file\n  pointing at a `.git/modules/...` target that isn't there, left behind when\n  the tree was copied or rsynced without the parent's `.git/modules`.\n  Otherwise, rebase the patch onto the new source rather than deleting it.\n\n### 5. Commit the submodule pointer\n\n```bash\ngit add lib/llvm/src lib/CMakeLists.txt\ngit commit -m \"Upgrade LLVM AA.B.C -> XX.Y.Z\"\n```\n\n`cmake -P lib/build-libs.cmake` resets the submodule to the pointer recorded in\nthe index, so stage this before step 6 or the new pointer is lost. Amend this\ncommit as the next two steps produce changes.\n\n### 6. Build, and fix what breaks\n\n```bash\nrm -rf build/libs build/build_libs         # Drop the old LLVM; the next command rebuilds it\ncmake -DJOBS=<n> -P lib/build-libs.cmake   # Build LLVM; see BUILD.md for picking <n>\ncmake --preset release                     # Configure the ponyc build\ncmake --build --preset release             # Build ponyc\n```\n\n`JOBS` defaults to 4. BUILD.md says to pass your own core count and keep it\nmodest, because LLVM is memory-hungry. On Windows use the Windows presets\n(BUILD.md).\n\nTwo kinds of failure come out of this step.\n\n**A patch-hash mismatch**, if you added, removed, or edited a patch file in\nstep 4. The lib configure step prints both hashes and stops:\n\n```\nPatch hash actual '<actual>' does not match desired '<desired>'\n```\n\nCopy `<actual>` into `PATCHES_DESIRED_HASH` in `lib/CMakeLists.txt`. The hash\nis a SHA256 over the seed string `needed_if_no_patches` and the SHA256 of each\npatch file's text with newlines squashed to spaces, which is awkward to\nreproduce in a shell — let CMake compute it. If no patches remain at all, the\nvalue is `SHA256(\"needed_if_no_patches\")` =\n`3e16c097794cb669a8f6a0bd7600b440205ac5c29a6135750c2e83263eb16a95`.\n\n**Compile errors**, from LLVM APIs that were removed or changed. See \"Common\nmigration patterns\" below. Fix them, amend the commit, and build again.\n\n### 7. Run the tests\n\n```bash\nctest --preset release -L ci-core\n```\n\nThe `ci-core` label covers `libponyc.tests`, where the codegen assertions live,\nalong with the full-program, stdlib, and examples tests.\n\nThis is narrower than what the PR will run: CI runs `ci-core` in both debug and\nrelease on Linux, macOS, and Windows, and separately builds and runs\n`pony-compiler-tests`. A failure that only shows up in one config, or on one\nplatform, will not appear here.\n\nTo reproduce a tier-3 BSD failure, the `create-dragonfly-dev-env`,\n`create-freebsd-dev-env`, and `create-openbsd-dev-env` skills stand up a local\nVM matching that CI job.\n\n### 8. Add a release note\n\nAn LLVM upgrade is user-facing. Add `.release-notes/update-llvm-to-XX.md`:\n\n```markdown\n## Update to LLVM XX.Y.Z\n\nWe've updated the LLVM version used to build Pony from AA.B.C to XX.Y.Z.\n```\n\nDo not edit `next-release.md`; CI aggregates the individual files.\n\n### 9. Push and open the PR\n\nTitle the PR `Update to LLVM XX.Y.Z`, matching the release note. Before merging,\na Pony team member adds a `changelog - changed` label, which puts a line built\nfrom the PR title into that CHANGELOG section. That is a separate mechanism from\nthe release note file, which reaches `next-release.md` on its own.\n\n## Common migration patterns\n\n### `LLVMConst*` to `LLVMBuild*` (constant expression removal)\n\nLLVM removed a number of constant expression functions. The `LLVMBuild*`\nequivalents constant-fold when given constant operands, so they compute the\nsame thing; they take a builder and a name as well:\n\n```c\n// Before:\nreturn LLVMConstShl(l_value, r_value);\n// After:\nreturn LLVMBuildShl(c->builder, l_value, r_value, \"\");\n```\n\n`LLVMConstICmp`, `LLVMConstFCmp`, `LLVMConstShl`, and `LLVMConstMul` are gone\nfrom LLVM 22.1.6's `Core.h`. `LLVMConstAdd` and `LLVMConstSub` are still there\nand carry no deprecation, but ponyc calls none of the six.\n\n### Deprecated to newer API variants\n\nOn the clang and gcc builds ponyc's C files compile with `-Werror`, so a\ndeprecated LLVM C function fails the build if its declaration carries\n`LLVM_ATTRIBUTE_C_DEPRECATED`, which produces a compiler warning. The MSVC build\npasses `/wd4996` alongside `/WX`, so the same call compiles clean on Windows.\n\nA function marked deprecated only in a doc comment produces no warning\nanywhere. The headers use two spellings for that, `@deprecated` and a plain\n`Deprecated: use X instead`, so grep for both when you want to know what ponyc\ncalls that upstream has marked. The list below is a starting point, not a\ncomplete one.\n\n`LLVMGetMDKindID` carried the attribute rather than a doc comment, so it did\nfail the build. That migration is done; `src/` now calls\n`LLVMGetMDKindIDInContext`. `LLVMBuildNUWNeg` also carries the attribute — its\nreplacement is `LLVMBuildNeg` followed by `LLVMSetNUW`, which ponyc wraps as\n`LLVMSetNoUnsignedWrap` in `host.cc`. `LLVMBuildNSWNeg` is not deprecated.\n\n### A new link-time dependency\n\nAn LLVM upgrade can make ponyc need a system library it did not need before,\nand the need can be Windows-only, so a local Linux build will not show it. The\nLLVM 21 upgrade (`22e84c4a1`) added `ntdll.lib` to the Windows link line in\n`src/libponyc/platform/vcvars.c` and to three `CMakeLists.txt` files.\n\n### Intrinsic signature changes\n\nLLVM can change an intrinsic's signature in a major version. These do not show\nup as compile errors; ponyc builds clean. They surface as a module verification\nfailure when ponyc compiles a Pony program (`Incorrect number of arguments\npassed to called function!`), which is why step 7 runs the tests.\n\n- **LLVM 22**: `llvm.lifetime.start` and `llvm.lifetime.end` dropped their\n  leading `i64 size` argument and now take a single pointer. ponyc emits these\n  in `gencall_lifetime_start` and `gencall_lifetime_end` (`gencall.c`). Drop\n  the size operand, drop the `type` parameter the size was computed from, and\n  call with one argument:\n\n```c\n// Before (2 args: size, ptr):\nLLVMValueRef args[2];\nargs[0] = LLVMConstInt(c->i64, size, false);\nargs[1] = ptr;\nLLVMBuildCall2(c->builder, func_type, func, args, 2, \"\");\n// After (1 arg: ptr):\nLLVMBuildCall2(c->builder, func_type, func, &ptr, 1, \"\");\n```\n\n`CodegenTest.LifetimeIntrinsicsHaveSinglePointerArgument` in\n`test/libponyc/codegen.cc` asserts the emitted argument count. When a later\nLLVM changes another intrinsic's signature, assert its argument count the same\nway.\n\n### C++ API changes\n\nponyc also embeds LLD (`genexe.cc`) and clang's frontend (`gencshim.cc`). Those\nC++ APIs carry no stability guarantee and break more often than LLVM's C API.\n\n- **LLVM 19**: `DIBuilder::insertDeclare` returns `DbgInstPtr` (PointerUnion)\n  instead of `Instruction*`. If callers don't use the return value, change the\n  wrapper's return type to `void`.\n- **LLVM 20**: `Intrinsic::getDeclaration` to\n  `Intrinsic::getOrInsertDeclaration`.\n- **LLVM 20**: Optimizer extension point callbacks\n  (`registerOptimizerEarlyEPCallback`, `registerOptimizerLastEPCallback`) now\n  take a `ThinOrFullLTOPhase` parameter in the lambda.\n- **LLVM 21**: `Attribute::NoCapture` to\n  `Attribute::getWithCaptureInfo(ctx, CaptureInfo::none())`.\n- **LLVM 21**: `LintPass()` requires a `bool AbortOnError` parameter:\n  `LintPass(false)`.\n- **LLVM 22**: `createTargetMachine` takes a `const Triple&` instead of a\n  `StringRef`; wrap with `Triple(opt->triple)`. ponyc made this change under\n  LLVM 21, in `3eae0a119`, while the `StringRef` overload was still there and\n  deprecated.\n\n## APIs to check on every upgrade\n\nAPIs ponyc calls that a future LLVM may break:\n\n| API | Location | Likely replacement |\n|-----|----------|-------------------|\n| `LLVMArrayType` | codegen.c, gendesc.c, genident.c | `LLVMArrayType2` (`uint64_t` count) |\n| `LLVMConstArray` | gendesc.c, genident.c | `LLVMConstArray2` (`uint64_t` count) |\n| `LLVMConstStringInContext` | codegen.c | `LLVMConstStringInContext2` (`size_t` length) |\n| `LLVMConstNeg` | genoperator.c | `LLVMBuildNeg` |\n| `LLVMConstNot` | genoperator.c | `LLVMBuildNot` |\n| `LLVMConstXor` | genoperator.c | `LLVMBuildXor` |\n| `LLVMConstTrunc` | genreference.c | `LLVMBuildTrunc` |\n\nThe first three carry an `@deprecated` doc comment; the other four are not\ndeprecated. The build fails once one of them gains the deprecation attribute or\nis removed. Re-check this table against the new headers as part of the upgrade.\n\n## Files typically modified\n\n- `lib/CMakeLists.txt` — `LLVM_DESIRED_HASH`, `PATCHES_DESIRED_HASH`\n- `lib/llvm/patches/` — patch files, added or removed\n- `.release-notes/` — the release note\n- `src/libponyc/codegen/genoperator.c` — constant expression replacements\n- `src/libponyc/codegen/genreference.c` — constant expression replacements\n- `src/libponyc/codegen/gendebug.cc`, `gendebug.h` — debug info API changes\n- `src/libponyc/codegen/host.cc` — target machine, intrinsics\n- `src/libponyc/codegen/genopt.cc` — optimizer pass pipeline\n- `src/libponyc/codegen/gendesc.c` — array and descriptor construction\n- `src/libponyc/codegen/genident.c` — array construction\n- `src/libponyc/codegen/codegen.c` — core codegen types and helpers\n- `src/libponyc/codegen/gencontrol.c` — metadata APIs\n- `src/libponyc/codegen/gencall.c`, `gencall.h` — intrinsic emission\n  (lifetime markers, memcpy and memmove)\n- `src/libponyc/codegen/genexe.cc` — embedded LLD\n- `src/libponyc/codegen/gencshim.cc` — embedded clang frontend\n- `src/libponyc/platform/vcvars.c`, `src/ponyc/CMakeLists.txt`,\n  `test/libponyc/CMakeLists.txt`, `benchmark/libponyc/CMakeLists.txt` — link\n  dependencies\n- `test/libponyc/codegen.cc` — codegen assertions\n\nA bigger LLVM can also push a CI job past its disk limit. During the LLVM 22\nupgrade we moved the OpenBSD tier-3 job onto a dedicated 50 GB data disk; the\nscript that creates it is in `.ci-scripts/bsd/`.\n","AGENTS.md":"# ponyc\n\n<!-- contributor-only -->\n## Contributing with an AI assistant\n\nThis is a Pony project. The ponylang org maintains a set of LLM coding skills. Get set up with them before contributing:\n\n- **Not set up yet?** Install them once:\n\n  ```bash\n  git clone https://github.com/ponylang/llm-skills.git\n  cd llm-skills\n  python install.py\n  ```\n\n- **Already set up?** Make sure you're on the latest. If you installed with the script above, `git pull` in the directory where you cloned `llm-skills` and the symlinked skills update automatically — if you set them up another way, refresh them however that setup expects.\n\nSee the [llm-skills README](https://github.com/ponylang/llm-skills) for details and other harnesses.\n\nWhen you start working on this project, load the `pony-skills` skill — it tells your assistant which Pony skill to use for each task.\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md).\n<!-- /contributor-only -->\n\n## Building\n\nThe build uses CMake presets. See [BUILD.md](BUILD.md) for platform-specific instructions and build options.\n\nBuild ponyc in debug mode:\n\n```bash\ncmake --build --preset debug\n```\n\nThe output goes in `build/debug`. Use `--preset release` for a release build. The vendored LLVM libraries must be built first with `cmake -P lib/build-libs.cmake` — this only needs to run once (or when the LLVM submodule changes).\n\n## Testing\n\nTests are registered with ctest and grouped by label. Two labels matter:\n\n- **`ci-core`** — built by a normal `cmake --build --preset debug`. The C/C++ compiler tests (`libponyc.tests`), the runtime tests (`libponyrt.tests`), the stdlib suite, full-program integration tests, and grammar validation.\n- **`tools`** — **not** built by a normal `cmake --build --preset debug`. The self-hosted tool test suites: pony-compiler, pony-lsp, pony-lint, pony-doc, pony-dep, and pony-agent-server.\n\n### Core tests (ci-core)\n\nRun the full core suite:\n\n```bash\nctest --preset debug -L ci-core\n```\n\nTwo environment variables control Pony compilation features at test time:\n\n- `PONY_DEBUG=1` — compile Pony sources with `-d` (debug codegen)\n- `PONY_THIN_LTO=1` — compile Pony sources with `--thin-lto`\n\nThese apply to stdlib tests, full-program tests, examples, and tool builds. Set them as command-line prefixes (`PONY_DEBUG=1 ctest ...`) or `export` them. CI runs tests in both codegen modes — the first `ctest` pass sets `PONY_DEBUG=1`, the second runs only the Pony-compilation tests (`stdlib`, `full-programs`) without it. PR builds run only debug codegen; release-mode-checks, weekly-checks, tier-3, and rolling-releases run both.\n\nRun individual tests by name:\n\n- `ctest --preset debug -R libponyc.tests` — compiler C/C++ unit tests (GTest)\n- `ctest --preset debug -R libponyrt.tests` — runtime C/C++ unit tests (GTest)\n- `ctest --preset debug -R stdlib` — stdlib test suite\n- `ctest --preset debug -R full-programs` — compile-and-run integration tests\n- `ctest --preset debug -R validate-grammar` — checks `pony.g` against the compiler\n- `ctest --preset debug -R examples` — compiles all examples (not part of `ci-core`; runs locally and in the weekly `build-examples.yml` workflow)\n\n#### Per-module stdlib tests\n\nA single module's tests can be compiled and run without rebuilding the full stdlib suite:\n\n```bash\ncd build/debug && ./ponyc -b stdlib --checktree -Dopenssl_3.0.x --pic ../../packages/collections\n./stdlib --sequential\n```\n\nTo compile with debug codegen, add `-d`: `./ponyc -d -b stdlib ...`. To compile with thin LTO, add `--thin-lto`.\n\nThe SSL flag must match the installed SSL library: `-Dopenssl_3.0.x` for OpenSSL 3.x, `-Dopenssl_1.1.x` for OpenSSL 1.1.x, `-Dlibressl` for LibreSSL. CMake detects this automatically for `ctest` runs; the manual command needs it explicitly.\n\n### Tool tests\n\nTool test binaries must be built explicitly before running. Build one target, then run through ctest:\n\n- `cmake --build --preset debug --target pony-compiler-tests && ctest --preset debug -R pony-compiler-tests`\n- `cmake --build --preset debug --target pony-lsp-tests && ctest --preset debug -R pony-lsp-tests`\n- `cmake --build --preset debug --target pony-lint-tests && ctest --preset debug -R pony-lint-tests`\n- `cmake --build --preset debug --target pony-doc-tests && ctest --preset debug -R pony-doc-tests`\n- `cmake --build --preset debug --target pony-dep-tests && ctest --preset debug -R pony-dep-tests`\n- `cmake --build --preset debug --target pony-agent-server-tests && ctest --preset debug -R pony-agent-server-tests`\n\nBuild all tool test binaries at once with `cmake --build --preset debug --target tool-tests`, then run them with `ctest --preset debug -L tools`.\n\nThe first build of a tool test binary compiles from Pony source (~60s); the binary is not recompiled when nothing under its source tree changed. On Windows, use the `windows-x86-64-debug` preset the same way.\n\n### Linting tool source\n\nRun the `pony-lint` binary (built by a normal `cmake --build --preset debug`) against a tool's directory:\n\n```bash\ncd build/debug && PONYPATH=../../tools/lib/ponylang/pony_compiler ./pony-lint ../../tools/pony-lint/\n```\n\nThe same works for `../../tools/pony-lsp/`, `../../tools/pony-doc/`, `../../tools/pony-dep/`, and `../../tools/pony-agent-server/`.\n\n## Opening PRs\n\nBefore opening a PR, verify:\n\n- **Debug build succeeds** (if compiled code was changed): `cmake --build --preset debug` completes without errors.\n- **Relevant tests pass locally**: Run the tests that cover your change — see the Testing section above for the right commands. Don't skip a test suite because \"CI will catch it.\"\n- **pony-lint passes** (if Pony source was changed): Lint any Pony code you touched. See \"Linting tool source\" above for the command.\n- **Grammar validation passes** (if the parser or `pony.g` was changed): `ctest --preset debug -R validate-grammar`.\n- **Release notes updated** (if the change is user-facing): Load the `pony-release-notes` skill for what needs updating and how.\n\n## Adding threads or locks\n\nNever add a mutex or a new thread unless the idea came from the human operator or a committer expressly approved it. Pony's runtime is built on a deliberate concurrency model, and adding either is an architectural decision, not an implementation detail. If a change looks like it needs one, stop and raise it rather than writing it.\n\n## Declaring runtime FFI functions on Windows\n\nOn the Windows MSVC build, libponyrt's `.c` files compile as C++ (`src/libponyrt/CMakeLists.txt` sets `LANGUAGE CXX` on them). A `PONY_API` function whose definition is not inside a `PONY_EXTERN_C_BEGIN`/`PONY_EXTERN_C_END` region gets a C++-mangled symbol, which the stdlib FFI — referencing the plain C name — can't resolve at link (`lld-link: undefined symbol`). Linux, macOS, and BSD compile `.c` as C, so the failure is Windows-only and won't show up in a local build there. Every runtime `.c` already wraps its definitions in that region (see `lang/stat.c`, `lang/socket.c`); put any new `PONY_API` function inside it. To check a symbol's linkage, run `dumpbin /SYMBOLS <lib> | findstr <name>` from a vcvars64 shell: a C-linkage function shows the plain name, a mangled one shows the C++ form.\n\n## Dispatching workflows on a branch\n\n`gh workflow run <workflow> --ref <branch> -f ref=<branch>` — both flags are needed. `--ref` selects which branch the workflow YAML is read from; `-f ref=` sets the `inputs.ref` that the checkout step uses. Without `-f ref=`, the job checks out main regardless of `--ref`.\n","CLAUDE.md":"@AGENTS.md\n","benchmark/AGENTS.md":"# benchmark/\n\n## Conventions\n\n- Do not add coupling comments in `src/libponyrt/` pointing back to\n  `benchmark/`. Benchmarks depend on runtime constants — the coupling\n  risk is theirs, and the note goes on the benchmark side alone.\n","packages/net/AGENTS.md":"# Net package\n\nThe `net` package provides Pony's networking. It reworks the original stdlib `net` package around a different split: the connection logic lives in a plain `class` (`TCPConnection`, `TCPListener`, `UDPSocket`) that the user's `actor` holds and delegates to, rather than being baked into a single actor.\n\n<!-- contributor-only -->\n## Contributing with an AI assistant\n\nThis is a Pony project. The ponylang org maintains a set of LLM coding skills. Get set up with them before contributing:\n\n- **Not set up yet?** Install them once:\n\n  ```bash\n  git clone https://github.com/ponylang/llm-skills.git\n  cd llm-skills\n  python install.py\n  ```\n\n- **Already set up?** Make sure you're on the latest. If you installed with the script above, `git pull` in the directory where you cloned `llm-skills` and the symlinked skills update automatically — if you set them up another way, refresh them however that setup expects.\n\nSee the [llm-skills README](https://github.com/ponylang/llm-skills) for details and other harnesses.\n\nWhen you start working on this project, load the `pony-skills` skill — it tells your assistant which Pony skill to use for each task.\n\nRead [CONTRIBUTING.md](../../CONTRIBUTING.md).\n<!-- /contributor-only -->\n\n## Architecture\n\nA client connect subscribes one socket per resolved address at once (Happy Eyeballs). Whichever connects first becomes the connection's own event; the rest are stragglers, and each has to be unsubscribed and its fd closed when its event arrives. That is why every state handles `own_event` and `foreign_event` separately, and what `_UnconnectedClosing` drains.\n\n### Connection lifecycle\n\n`TCPConnection` tracks its lifecycle with explicit state objects — the `_ConnectionState` trait and its implementers in `_connection_state.pony` — rather than boolean flags.\n\n```\n_ConnectionNone     → _ClientConnecting\n                    → _Open              (server, plaintext)\n                    → _SSLHandshaking    (server, SSL)\n                    → _Closed            (SSL session creation failed)\n_ClientConnecting   → _Open              (connected, plaintext)\n                    → _SSLHandshaking    (connected, SSL)\n                    → _UnconnectedClosing (close, drain stragglers)\n                    → _Closed            (hard_close / all attempts failed)\n_SSLHandshaking     → _Open              (ssl_handshake_complete)\n                    → _Closed            (hard_close / SSL error)\n_Open               → _Closing           (close)\n                    → _TLSUpgrading      (start_tls)\n                    → _Closed            (hard_close)\n_TLSUpgrading       → _Open              (ssl_handshake_complete)\n                    → _Closed            (hard_close / TLS error)\n_Closing            → _Closed            (drained, or hard_close)\n_UnconnectedClosing → _Closed            (drained, or hard_close)\n```\n\n### UDP lifecycle\n\n`UDPSocket` uses the same state-object pattern with three states.\n\n```\n_UDPNone    → _UDPOpen   (bind succeeded)\n            → _UDPClosed (bind failed, or dispose/init race)\n_UDPOpen    → _UDPClosed (close or error)\n```\n\nThe dispose/init race: `_finish_initialization` is a self→self behavior. If `dispose()` arrives first, `_UDPNone.close()` transitions to `_UDPClosed`, and `_finish_initialization` checks for that and skips the bind.\n\n### Connected UDP lifecycle\n\n`ConnectedUDPSocket` uses the same state-object pattern. Initialization has three outcomes: bind failure, connect failure, or success.\n\n```\n_ConnectedUDPNone    → _ConnectedUDPOpen   (bind and connect succeeded)\n                     → _ConnectedUDPClosed (bind failed, connect failed, or dispose/init race)\n_ConnectedUDPOpen    → _ConnectedUDPClosed (close or error)\n```\n\nThe dispose/init race works the same way as `UDPSocket`. The peer is fixed at creation — there is no disconnect or reconnect.\n\n## Traps\n\n- **One read loop; keep `_on_received` out of `_ssl_poll()`.** An earlier design had `_ssl_poll()` deliver application data from a second loop of its own, so mute and the liveness check had to be written twice. Both shipped as bugs before the second copy existed.\n\n- **`_ssl` is a `_TLSState`, not `(SSL | None)`.** `(SSL | None)` was the earlier design. It records whether a session exists but not whether one may be used, and that ambiguity was a segfault.\n\n- **Mint the send token before the flush.** The flush can close the connection, so a token minted after it loses the terminal callback for a send whose bytes already went out. It is also why `send()` returns `SendAccepted` rather than the token: `_on_sent` is a direct call, so the application has to be holding the token before the flush runs. Keep every error return upstream of the mint, so an errored send burns no token id.\n\n- **`_on_send_failed` is deferred; `_on_sent` is not.** `_on_sent` was deferred too, for the same-looking reason, and it was wrong: a send reaching the OS is its own event, and queueing it let callbacks for later events overtake it.\n\n- **The hard-close reason is the `_HardCloseCause` argument, not a field.** An earlier design set one of three fields (`_connect_timed_out` and the like) right before an argumentless `hard_close()` and dug it back out after. Do not put it back in a field.\n\n- **`close_notify` is deferred to `_Closing.drained()`, not sent in `_Open.close()`.** `ssl.close()` calls `SSL_shutdown`, which makes `SSL_read` return `SSL_ERROR_ZERO_RETURN` — buffered TLS records the read loop has not yet delivered are lost.\n\n- **`_set_unwriteable()` before `_asio.resubscribe_write()`.** The ponyc epoll backend's `pony_asio_event_resubscribe()` only includes `EPOLLOUT` when `!ev->writeable`. After `_dispatch_io_event()` sets writeable, the flag stays true, so a later resubscribe is a no-op for the write side. Clear the flag first.\n\n- **A stale foreign event is dropped once, in `_event_notify`, not in each state.** The check used to sit in each `foreign_event` instead. Three of those copies were removed because the suite still passed on Linux, where the second message does not arrive, and that shipped as a bug reachable only where kqueue is the backend. Do not push the check back down into the states.\n\n## Platform differences\n\nPOSIX and Windows share one readiness-based I/O path: one-shot readiness events (epoll/kqueue; `ProcessSocketNotifications` on Windows), resubscribe, then a synchronous `RuntimeBackend.receive`/`sendv`. Windows uses this path because ponyc removed IOCP; the floor is Windows 11 / Windows Server 2022.\n\nHow many messages one subscription delivers is platform-specific too. kqueue arms read and write as separate one-shot filters and sends a message from each (ponyc's `kqueue.c`), so a single subscribed socket can deliver two readiness messages; epoll and Windows combine both directions into one (`epoll.c`, `sock_notify.c`). Code written on the assumption of one message per subscription is wrong wherever kqueue is the backend — macOS is the one CI covers, but the BSDs use it too.\n\n## Conventions\n\n- `_Unreachable()` in a branch the compiler cannot prove impossible, rather than an empty `else`.\n- The runtime will not exit while an actor holds a live I/O resource, so a test has to dispose any actor that might still hold one when it ends, or CI hangs. That is the client a listener creates in `_on_listening` and every server actor returned from `_on_accept`. Happy Eyeballs can deliver more than one connection per client connect (one per resolved address), so store accepted servers in `let _servers: Array[Type]`, push in `_on_accept`, and iterate in `dispose`/`_on_closed`.\n- Tests bind listeners and sockets to port `\"0\"` (OS-assigned ephemeral port) and retrieve the actual port from `local_address()` in the ready callback (`_on_listening` for TCP, `_on_bound` for UDP, `_on_connected` for connected UDP). This avoids conflicts when multiple test runs execute concurrently on the same machine.\n- `\\nodoc\\` on test classes.\n- A new test goes in the `_test_*.pony` file for its functional area, registered in `Main.tests()` in `_test.pony`, which holds only the test runner.\n- Each example has a file-level docstring saying what it demonstrates, uses the Listener/Server/Client actor structure, and uses a unique port. Adding one means placing it under the appropriate `examples/networking/` subdirectory and adding it to `examples/README.md` under the Networking section.\n","packages/net/CLAUDE.md":"@AGENTS.md\n","tools/lib/README.md":"# tools/lib\n\nVendored library dependencies for the tools in the `tools/` directory.\n","tools/pony-agent-server/README.md":"# pony-agent-server\n\nA query server for AI coding agents that need Pony type information while editing code. It compiles a package and answers questions about types, definitions, capabilities, and structure.\n\nThe query interface is experimental and subject to change.\n\n## Usage\n\n```text\npony-agent-server <package-directory> [-D define]...\n```\n\nThe server compiles the package on startup, then reads one JSON query per line from stdin and writes one JSON response per line to stdout. Diagnostic messages go to stderr.\n\nPass `-D` flags the same way you would pass them to `ponyc`. For example, `-D openssl_3.0.x` for OpenSSL 3.x.\n\nRun `--queries` to print a summary of all queries and their input format.\n\n## Protocol\n\nEvery query is a JSON object with a `\"query\"` field. An optional `\"id\"` field (string or integer) is echoed back in the response.\n\nOn success, the response contains the query-specific fields described below. On error, the response contains an `\"error\"` field with a message.\n\n```json\n{\"query\":\"status\", \"id\":1}\n{\"id\":1, \"status\":\"ready\"}\n```\n\n## Queries\n\n### inspect\n\nType and definition of the AST node at a source position.\n\n```json\n{\"query\":\"inspect\", \"file\":\"/path/to/file.pony\", \"line\":10, \"column\":5}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `type` | string | The node's type as the compiler resolved it |\n| `node_kind` | string | AST node kind (e.g. `\"letref\"`, `\"call\"`) |\n| `line`, `column` | integer | Position of the node |\n| `definition` | object | Where the symbol is defined: `file`, `line`, `column`, `kind`, `declared_type` |\n| `enclosing` | object | Enclosing context: `method`, `receiver_cap`, `type` |\n| `in_recover` | boolean | Whether the position is inside a `recover` block |\n| `sendable` | boolean | Whether the type is sendable (iso, val, or tag) |\n\n### scope\n\nEvery name visible at a source position, with recover-block accessibility.\n\n```json\n{\"query\":\"scope\", \"file\":\"/path/to/file.pony\", \"line\":10, \"column\":5}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `in_recover` | boolean | Whether the position is inside a `recover` block |\n| `entries` | array | Each entry has `name`, `type`, `kind`, `accessible`, and optionally `reason` |\n\nWhen `accessible` is false, `reason` explains why (typically because a non-sendable reference is blocked by a recover expression).\n\n### exports\n\nPublic types exported by a package.\n\n```json\n{\"query\":\"exports\", \"package\":\"collections\"}\n```\n\nThe package can be a qualified name like `\"collections\"` or an absolute path.\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `package_path` | string | Resolved path to the package |\n| `entries` | array | Each entry has `name`, `kind` (class, actor, primitive, etc.), and optionally `docstring` |\n\n### type_api\n\nMethods callable on a type at a given capability. There are two ways to query: by type name with an explicit capability, or by source position (the type and capability are read from the compiler's AST).\n\nBy name:\n\n```json\n{\"query\":\"type_api\", \"type\":\"Array[String]\", \"cap\":\"ref\"}\n```\n\nBy position:\n\n```json\n{\"query\":\"type_api\", \"file\":\"/path/to/file.pony\", \"line\":10, \"column\":5}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `type` | string | The type name or expression |\n| `kind` | string | Entity kind: `\"class\"`, `\"actor\"`, `\"primitive\"`, `\"trait\"`, `\"interface\"`, `\"struct\"`, or `\"union\"` |\n| `callable` | array | Methods callable at this capability |\n| `not_callable` | array | Methods not callable, each with a `method` and `reason` |\n| `constructors` | array | Available constructors |\n| `traits` | array | Traits and interfaces the type provides |\n\nEach method object has: `name`, `receiver` (capability), `params` (array of `{name, type, has_default}`), `return`, `partial` (boolean), `kind` (`\"fun\"`, `\"be\"`, or `\"new\"`), and `source_type` (the type that defined the method).\n\n### check\n\nSubtype check between two types using the compiler's subtype checker.\n\n```json\n{\"query\":\"check\", \"source\":\"Collector ref\", \"target\":\"Describable ref\"}\n```\n\nTypes are a name optionally followed by a capability. Without a capability, the type's default is used (ref for classes, val for primitives, tag for actors).\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `compatible` | boolean | Whether the source is a subtype of the target |\n| `source_type` | string | Source type with resolved capability |\n| `target_type` | string | Target type with resolved capability |\n| `reason` | string | Explanation of the result |\n| `suggestions` | array | When incompatible, suggestions for making it work |\n\n### implementors\n\nTypes that implement a given trait or interface.\n\n```json\n{\"query\":\"implementors\", \"type\":\"Describable\"}\n```\n\nThe target must be a trait or interface.\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `type` | string | The queried trait or interface name |\n| `nominal` | array | Types that explicitly declare the trait/interface in their provides list |\n| `structural` | array | Types that match the interface's method signatures without declaring it (interfaces only; traits require nominal declaration) |\n\n### errors\n\nCompilation errors from the last compile or reload.\n\n```json\n{\"query\":\"errors\"}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `errors` | array | Each error has `message`, and optionally `file`, `line`, `column` |\n\n### reload\n\nRecompile the package. The server recompiles from the same directory it was started with and replaces the in-memory program on success. Queries issued after a failed reload get the previous successful compilation.\n\n```json\n{\"query\":\"reload\"}\n```\n\nThe response is the same as the initial compile: `{\"status\":\"ready\"}` on success, or `{\"status\":\"error\", \"errors\":[...]}` on failure.\n\n### status\n\nWhether the server has a compiled program ready for queries.\n\n```json\n{\"query\":\"status\"}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `status` | string | `\"ready\"` or `\"not_compiled\"` |\n","tools/pony-lsp/README.md":"# pony-lsp - the Pony Language Server\n\n`pony-lsp` implements the [Language Server Protocol] for Pony. It communicates with editors via stdout/stdin, and is built and distributed alongside `ponyc` via `ponyup`.\n\nFor user documentation — installation and editor configuration — see the [pony-lsp documentation] on the Ponylang website.\n\n[Language Server Protocol]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/\n[pony-lsp documentation]: https://www.ponylang.io/use/pony-lsp/\n\n## Feature Support\n\n| **Feature** | **Description** |\n| ------- | ----------- |\n| **[Diagnostics][spec-diagnostics]** (push, pull, refresh and change notifications) | Ponyc errors and related information is reported as LSP diagnostics. |\n| **[Hover][spec-hover]** | Additional information is shown for: entities, methods, fields, local variables and references. |\n| **[Signature Help][spec-signature-help]** | Parameter hints are shown when the cursor is inside a call expression, with the active parameter highlighted. Includes the method's docstring when present. Requires the file to be saved — signature help is not available while the file has unsaved edits. |\n| **[Go To Definition][spec-definition]** | For most language constructs, you can go from a reference to its definition. |\n| **[Go To Declaration][spec-declaration]** | Navigate from a reference to the declaration site of a symbol. |\n| **[Go To Type Definition][spec-type-definition]** | Navigate to the type definition of the symbol under the cursor. |\n| **[Document Symbols][spec-document-symbols]** | pony-lsp provides a list of available symbols for each opened document. |\n| **[Workspace Symbols][spec-workspace-symbols]** | Fuzzy search over all symbols across the entire workspace. |\n| **[Document Highlight][spec-document-highlight]** | All occurrences of the symbol under the cursor are highlighted simultaneously across the document. |\n| **[Inlay Hints][spec-inlay-hints]** | Three kinds of hints are shown inline. For `let`, `var`, and field declarations with no type annotation, the full inferred type appears after the variable name. For annotated types where the capability is omitted — in variable and field declarations, function parameter types, generic type arguments, and union and tuple members — the missing capability keyword appears after the type name. For `fun` declarations, the implicit receiver capability appears before the function name and the implicit return type appears after the closing parenthesis. |\n| **[Find References][spec-references]** | All references to the symbol under the cursor are returned, with optional inclusion of the declaration site. |\n| **[Rename][spec-rename]** | Rename a symbol and all its references across the workspace. |\n| **[Folding Range][spec-folding-range]** | Code folding ranges are provided for blocks, methods, classes, and other structured constructs. |\n| **[Selection Range][spec-selection-range]** | Smart expand/shrink selection based on the AST structure of the document. |\n| **[Type Hierarchy][spec-type-hierarchy]** | Navigate the type hierarchy of Pony entities. `prepareTypeHierarchy` identifies the entity under the cursor; `supertypes` returns the types in its `is` clause; `subtypes` returns all types across the compiled module that directly provide it. |\n| **[Call Hierarchy][spec-call-hierarchy]** | Navigate the call hierarchy of Pony methods. `prepareCallHierarchy` identifies the method under the cursor; `incomingCalls` returns all callers across the workspace with their call sites; `outgoingCalls` returns all callees with their call sites within the method. |\n\n[spec-diagnostics]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#pull-diagnostics\n[spec-hover]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#hover-request-leftwards_arrow_with_hook\n[spec-signature-help]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_signatureHelp\n[spec-definition]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#goto-definition-request-leftwards_arrow_with_hook\n[spec-declaration]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#goto-declaration-request-leftwards_arrow_with_hook\n[spec-type-definition]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#goto-type-definition-request-leftwards_arrow_with_hook\n[spec-document-symbols]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#document-symbols-request-leftwards_arrow_with_hook\n[spec-workspace-symbols]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace-symbols-request-leftwards_arrow_with_hook\n[spec-document-highlight]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#document-highlights-request-leftwards_arrow_with_hook\n[spec-inlay-hints]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#inlay-hint-request-leftwards_arrow_with_hook\n[spec-references]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#find-references-request-leftwards_arrow_with_hook\n[spec-rename]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#rename-request-leftwards_arrow_with_hook\n[spec-folding-range]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#folding-range-request-leftwards_arrow_with_hook\n[spec-selection-range]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#selection-range-request-leftwards_arrow_with_hook\n[spec-type-hierarchy]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareTypeHierarchy\n[spec-call-hierarchy]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareCallHierarchy\n\nNew features are actively being added. Contributions are welcome — we are happy to provide help and guidance.\n\n## Settings\n\n`pony-lsp` supports settings via the [`workspace/configuration`] request and [`workspace/didChangeConfiguration`] notification.\n\n[`workspace/configuration`]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_configuration\n[`workspace/didChangeConfiguration`]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeConfiguration\n\n| **Setting** | **Type** | **Example** | **Description** |\n| ------- | ---- | ------- | ----------- |\n| `defines` | `Array[String]` | `[\"FOO\", \"BAR\"]` | Defines active during compilation, equivalent to the `-D` flag in `ponyc` |\n| `ponypath` | `Array[String]` | `[\"/path/to/pony/module\"]` | Additional module search paths |\n\nExample settings in JSON:\n\n```json\n{\n  \"defines\": [\"FOO\", \"BAR\"],\n  \"ponypath\": [\"/path/to/pony/module\", \"/another/path\"]\n}\n```\n\n## Building from source\n\nFirst, follow the [ponyc build documentation] and make sure you have everything to build `ponyc`. Then building `pony-lsp` is as easy as:\n\n```bash\nmake pony-lsp\n```\n\n[ponyc build documentation]: ../../BUILD.md\n\n## Reporting Issues\n\nIf `pony-lsp` is not working as expected, please [open an issue]. Include a minimal Pony program that reproduces the problem, the `pony-lsp` version, and the LSP client/editor you are using.\n\n[open an issue]: https://github.com/ponylang/ponyc/issues/new\n"},"items":[{"name":"AGENTS.md","path":".ci-scripts/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/.ci-scripts/AGENTS.md","title":".ci-scripts - AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# .ci-scripts\n\nHelper scripts that CI workflows call (Python and shell). Not part of the ponyc\nbuild, the runtime, or the stdlib — these only run in GitHub Actions (and locally\nwhen you want to exercise them).\n\n## Python conventions\n\n- **Stdlib only.** CI runners have no `pip install` step, so scripts must not\n  import third-party packages. Use `urllib`, `json`, `subprocess`, `tarfile`,\n  etc. — never `requests`, `pyyaml`, `pytest`, …\n- **ruff lints everything here** (`.github/workflows/lint-python.yml`,\n  which now only lints — the tests live in `test-ci-scripts.yml` and\n  `test-rt-stress.yml`). There is no ruff config file, so the defaults apply. A\n  `lint` job runs `ruff check .ci-scripts`; a separate `lint-rt-stress` job runs\n  `ruff check test/rt-stress` — these conventions also cover `test/rt-stress/`,\n  whose generative stress harness carries Python (an orchestrator) under the same\n  rules even though it lives with a test rather than in `.ci-scripts/`.\n- **Tests are `*_test.py` files, self-contained, no pytest.** Each is runnable as\n  `python3 <path>/<name>_test.py`, exits 0 on pass / 1 on fail, and prints a\n  one-line summary (see `pr_changed_suites_test.py` for the shape).\n  **`test-ci-scripts.yml` discovers every `*_test.py` under `.ci-scripts/` (via\n  `find … -name '*_test.py'`) and runs each one; `test-rt-stress.yml` does the\n  same for `test/rt-stress/`, on Linux and Windows** — so a new `*_test.py` is\n  picked up automatically — you do not register it anywhere. When you add or\n  change a script, add or update its `*_test.py`; it will run in CI on the next\n  PR.\n- After writing a test, break each assertion once to confirm it actually fires\n  (counterfactual check) before trusting it.\n\n## Subdirectory and import conventions\n\n- Related scripts are grouped in a subdirectory (e.g. `libs-cache/`). A script\n  imports its **siblings by module name** (`import cache_arch`); this resolves\n  because the scripts are invoked by full path, so `sys.path[0]` is the script's\n  own directory. **Move a script together with its siblings and its importers**,\n  or the imports break.\n- A subdirectory mixes **entry scripts** (runnable by full path, each with a\n  `main` and a CLI) with **support-library modules** (import-only, no `main`). In\n  `libs-cache/` the library modules are `common.py` (die/info/`require_env`/the\n  orchestrator helpers, plus the shared entry-script path constants\n  `MAIN_CACHE`/`BRANCH_CACHE`/`PROMOTE`), `registry.py` (the GHCR registry-v2\n  push/pull client, including `copy` — the cross-namespace promote the warmer uses\n  to reuse a branch build), `ghpackages.py` (the GitHub-packages REST client), and\n  `cache_arch.py` (arch normalization); the entry scripts (`*_libs_cache.py`,\n  `resolve_libs_cache.py`) are thin layers over them — `promote_libs_cache.py`\n  resolves the branch (source) and main (destination) names and calls\n  `registry.copy`. Shared logic belongs in a library module, not copied across\n  entry scripts.\n- A module that derives the **repo root** from `__file__` (e.g.\n  `libs-cache/registry.py`'s `repo_root()`) hardcodes the directory depth with a\n  fixed number of `os.path.dirname` calls. If you move it to a different depth,\n  update that count — `libs-cache/repo_root_test.py` pins it, so a wrong depth\n  fails the test job rather than only surfacing at runtime in CI.\n\n## Pointers\n\n- The libs cache (the `libs-cache/` scripts: warmer, consumers, branch cache,\n  retention, clear) is documented in `libs-cache/README.md`.\n- The `bsd/` scripts provision the BSD CI VMs and run the in-VM libs handling,\n  both shared by `update-lib-cache.yml` and `ponyc-tier3.yml`, one script per\n  platform: `{freebsd,openbsd,dragonfly}-provision.bash` (+ `dfly_configure_vm.py`)\n  boot and set up the VM, and `{freebsd,openbsd,dragonfly}-libs-cache.bash\n  <operation>` restores or builds-and-pushes the libs cache over ssh. See\n  `libs-cache/README.md`.\n- `windows-install-deps.ps1` installs the pinned msys2 lldb and the libraries it\n  loads against. It holds the only copy of that pin, and every Windows job\n  needing lldb calls it. Don't inline the `pacman` block into a workflow: the\n  PR job that checks lldb's behavior has to install the same lldb the\n  scheduled lanes do, or it guards a version nothing runs. The script's header\n  carries the pin history.\n","isInternal":false,"tokens":1166,"sizeBytes":4356},{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# ponyc\n\n<!-- contributor-only -->\n## Contributing with an AI assistant\n\nThis is a Pony project. The ponylang org maintains a set of LLM coding skills. Get set up with them before contributing:\n\n- **Not set up yet?** Install them once:\n\n  ```bash\n  git clone https://github.com/ponylang/llm-skills.git\n  cd llm-skills\n  python install.py\n  ```\n\n- **Already set up?** Make sure you're on the latest. If you installed with the script above, `git pull` in the directory where you cloned `llm-skills` and the symlinked skills update automatically — if you set them up another way, refresh them however that setup expects.\n\nSee the [llm-skills README](https://github.com/ponylang/llm-skills) for details and other harnesses.\n\nWhen you start working on this project, load the `pony-skills` skill — it tells your assistant which Pony skill to use for each task.\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md).\n<!-- /contributor-only -->\n\n## Building\n\nThe build uses CMake presets. See [BUILD.md](BUILD.md) for platform-specific instructions and build options.\n\nBuild ponyc in debug mode:\n\n```bash\ncmake --build --preset debug\n```\n\nThe output goes in `build/debug`. Use `--preset release` for a release build. The vendored LLVM libraries must be built first with `cmake -P lib/build-libs.cmake` — this only needs to run once (or when the LLVM submodule changes).\n\n## Testing\n\nTests are registered with ctest and grouped by label. Two labels matter:\n\n- **`ci-core`** — built by a normal `cmake --build --preset debug`. The C/C++ compiler tests (`libponyc.tests`), the runtime tests (`libponyrt.tests`), the stdlib suite, full-program integration tests, and grammar validation.\n- **`tools`** — **not** built by a normal `cmake --build --preset debug`. The self-hosted tool test suites: pony-compiler, pony-lsp, pony-lint, pony-doc, pony-dep, and pony-agent-server.\n\n### Core tests (ci-core)\n\nRun the full core suite:\n\n```bash\nctest --preset debug -L ci-core\n```\n\nTwo environment variables control Pony compilation features at test time:\n\n- `PONY_DEBUG=1` — compile Pony sources with `-d` (debug codegen)\n- `PONY_THIN_LTO=1` — compile Pony sources with `--thin-lto`\n\nThese apply to stdlib tests, full-program tests, examples, and tool builds. Set them as command-line prefixes (`PONY_DEBUG=1 ctest ...`) or `export` them. CI runs tests in both codegen modes — the first `ctest` pass sets `PONY_DEBUG=1`, the second runs only the Pony-compilation tests (`stdlib`, `full-programs`) without it. PR builds run only debug codegen; release-mode-checks, weekly-checks, tier-3, and rolling-releases run both.\n\nRun individual tests by name:\n\n- `ctest --preset debug -R libponyc.tests` — compiler C/C++ unit tests (GTest)\n- `ctest --preset debug -R libponyrt.tests` — runtime C/C++ unit tests (GTest)\n- `ctest --preset debug -R stdlib` — stdlib test suite\n- `ctest --preset debug -R full-programs` — compile-and-run integration tests\n- `ctest --preset debug -R validate-grammar` — checks `pony.g` against the compiler\n- `ctest --preset debug -R examples` — compiles all examples (not part of `ci-core`; runs locally and in the weekly `build-examples.yml` workflow)\n\n#### Per-module stdlib tests\n\nA single module's tests can be compiled and run without rebuilding the full stdlib suite:\n\n```bash\ncd build/debug && ./ponyc -b stdlib --checktree -Dopenssl_3.0.x --pic ../../packages/collections\n./stdlib --sequential\n```\n\nTo compile with debug codegen, add `-d`: `./ponyc -d -b stdlib ...`. To compile with thin LTO, add `--thin-lto`.\n\nThe SSL flag must match the installed SSL library: `-Dopenssl_3.0.x` for OpenSSL 3.x, `-Dopenssl_1.1.x` for OpenSSL 1.1.x, `-Dlibressl` for LibreSSL. CMake detects this automatically for `ctest` runs; the manual command needs it explicitly.\n\n### Tool tests\n\nTool test binaries must be built explicitly before running. Build one target, then run through ctest:\n\n- `cmake --build --preset debug --target pony-compiler-tests && ctest --preset debug -R pony-compiler-tests`\n- `cmake --build --preset debug --target pony-lsp-tests && ctest --preset debug -R pony-lsp-tests`\n- `cmake --build --preset debug --target pony-lint-tests && ctest --preset debug -R pony-lint-tests`\n- `cmake --build --preset debug --target pony-doc-tests && ctest --preset debug -R pony-doc-tests`\n- `cmake --build --preset debug --target pony-dep-tests && ctest --preset debug -R pony-dep-tests`\n- `cmake --build --preset debug --target pony-agent-server-tests && ctest --preset debug -R pony-agent-server-tests`\n\nBuild all tool test binaries at once with `cmake --build --preset debug --target tool-tests`, then run them with `ctest --preset debug -L tools`.\n\nThe first build of a tool test binary compiles from Pony source (~60s); the binary is not recompiled when nothing under its source tree changed. On Windows, use the `windows-x86-64-debug` preset the same way.\n\n### Linting tool source\n\nRun the `pony-lint` binary (built by a normal `cmake --build --preset debug`) against a tool's directory:\n\n```bash\ncd build/debug && PONYPATH=../../tools/lib/ponylang/pony_compiler ./pony-lint ../../tools/pony-lint/\n```\n\nThe same works for `../../tools/pony-lsp/`, `../../tools/pony-doc/`, `../../tools/pony-dep/`, and `../../tools/pony-agent-server/`.\n\n## Opening PRs\n\nBefore opening a PR, verify:\n\n- **Debug build succeeds** (if compiled code was changed): `cmake --build --preset debug` completes without errors.\n- **Relevant tests pass locally**: Run the tests that cover your change — see the Testing section above for the right commands. Don't skip a test suite because \"CI will catch it.\"\n- **pony-lint passes** (if Pony source was changed): Lint any Pony code you touched. See \"Linting tool source\" above for the command.\n- **Grammar validation passes** (if the parser or `pony.g` was changed): `ctest --preset debug -R validate-grammar`.\n- **Release notes updated** (if the change is user-facing): Load the `pony-release-notes` skill for what needs updating and how.\n\n## Adding threads or locks\n\nNever add a mutex or a new thread unless the idea came from the human operator or a committer expressly approved it. Pony's runtime is built on a deliberate concurrency model, and adding either is an architectural decision, not an implementation detail. If a change looks like it needs one, stop and raise it rather than writing it.\n\n## Declaring runtime FFI functions on Windows\n\nOn the Windows MSVC build, libponyrt's `.c` files compile as C++ (`src/libponyrt/CMakeLists.txt` sets `LANGUAGE CXX` on them). A `PONY_API` function whose definition is not inside a `PONY_EXTERN_C_BEGIN`/`PONY_EXTERN_C_END` region gets a C++-mangled symbol, which the stdlib FFI — referencing the plain C name — can't resolve at link (`lld-link: undefined symbol`). Linux, macOS, and BSD compile `.c` as C, so the failure is Windows-only and won't show up in a local build there. Every runtime `.c` already wraps its definitions in that region (see `lang/stat.c`, `lang/socket.c`); put any new `PONY_API` function inside it. To check a symbol's linkage, run `dumpbin /SYMBOLS <lib> | findstr <name>` from a vcvars64 shell: a C-linkage function shows the plain name, a mangled one shows the C++ form.\n\n## Dispatching workflows on a branch\n\n`gh workflow run <workflow> --ref <branch> -f ref=<branch>` — both flags are needed. `--ref` selects which branch the workflow YAML is read from; `-f ref=` sets the `inputs.ref` that the checkout step uses. Without `-f ref=`, the job checks out main regardless of `--ref`.\n","isInternal":false,"tokens":1927,"sizeBytes":7508},{"name":"AGENTS.md","path":"benchmark/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/benchmark/AGENTS.md","title":"benchmark - AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# benchmark/\n\n## Conventions\n\n- Do not add coupling comments in `src/libponyrt/` pointing back to\n  `benchmark/`. Benchmarks depend on runtime constants — the coupling\n  risk is theirs, and the note goes on the benchmark side alone.\n","isInternal":false,"tokens":56,"sizeBytes":235},{"name":"CLAUDE.md","path":".ci-scripts/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/.ci-scripts/CLAUDE.md","title":".ci-scripts - Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"@AGENTS.md\n","isInternal":false,"tokens":5,"sizeBytes":11},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"@AGENTS.md\n","isInternal":false,"tokens":5,"sizeBytes":11},{"name":"SKILL.md","path":".claude/skills/32-bit-support-validation/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/.claude/skills/32-bit-support-validation/SKILL.md","title":"32-bit-support-validation","category":"anthropic-skill","format":"markdown","content":"---\nname: 32-bit-support-validation\ndescription: Load when validating ponyc 32-bit support. Covers syncing the test machine, building LLVM and ponyc, and running the full test suite on a 32-bit ARM RPi4.\ndisable-model-invocation: false\n---\n\n# Validating 32-bit support\n\nRun the test suite on a 32-bit ARM machine to verify nothing is broken.\nThe default test machine is `pony-rpi4-32` (user `pi`, checkout at\n`/home/pi/code/ponylang/ponyc`). It has a persistent checkout and a built LLVM\nthat only needs rebuilding when the vendored LLVM changes.\n\nThis is slow — LLVM builds take many hours, ponyc builds take roughly an hour each,\nand the test suites add more on top. Run long steps detached and poll their logs.\n\n## Known ILP32 failures\n\nThe 32-bit address space (~3 GB user) constrains what can be compiled and\nlinked as a single binary. These are current failures, not accepted\nlimitations — they need to be fixed.\n\n- **The monolithic stdlib test** (`ctest -R stdlib`) compiles every stdlib\n  package into one binary. LLVM runs out of memory during code generation\n  (the \"Function prototypes\" phase), regardless of LTO mode, jemalloc, or\n  debug/release codegen. Stdlib tests are run per-package instead\n  (step 4c / 5c) as a workaround.\n\n## Gotchas (read first)\n\n- **Check the login shell.** If the machine's login shell is not bash (e.g. fish),\n  every SSH command must go through bash explicitly — either\n  `ssh pi@pony-rpi4-32 bash << 'ENDSSH'` for heredocs or\n  `ssh pi@pony-rpi4-32 bash -c '...'` for one-liners.\n- **Check the compiler.** The cmake presets default to `clang`/`clang++`. If the\n  machine only has GCC, override on every cmake configure:\n  `-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++`.\n- **`-mtune=generic` is invalid on ARM GCC.** The `native` preset sets\n  `-march=native -mtune=generic`, but GCC on ARM rejects `generic`. Override:\n  `\"-DCMAKE_C_FLAGS=-march=native\" \"-DCMAKE_CXX_FLAGS=-march=native\"`.\n  The LLVM build (`lib/build-libs.cmake`) does not set these flags, so it is not\n  affected.\n- **Don't run concurrent builds to the same build directory.** If an SSH session\n  times out mid-build, kill the orphaned cmake process before starting another —\n  two builds writing to the same directory corrupt it.\n- **Detach long builds.** Write a bash script to the machine, run it with `nohup`,\n  and poll the log file. Don't use `tee` over SSH — it keeps the connection alive\n  and the SSH session will time out. Redirect to a file instead.\n- **OpenSSL version matters for per-package tests.** cmake auto-detects the SSL\n  library for ctest, but per-package compilation (steps 4c/5c) needs the flag\n  passed explicitly. The scripts detect it via `openssl version`.\n- **LLVM build parallelism.** LLVM is memory-hungry. On machines with limited RAM,\n  use `-DJOBS=2` (or lower) to avoid OOM during the LLVM build.\n\n\n## Step 1 — verify the machine is reachable\n\n```bash\nssh -o ConnectTimeout=5 pi@pony-rpi4-32 bash -c '\"uname -m && cat /etc/os-release | head -2\"'\n```\n\nIf the machine is not reachable, stop and ask where to run the 32-bit validation.\nYou need: an SSH-accessible 32-bit ARM Linux machine with a ponyc checkout, GCC,\ncmake ≥ 3.25, and Python 3.\n\n## Step 2 — update the repo\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\ngit fetch origin\ngit checkout main\ngit pull origin main\ngit submodule update --init lib/llvm/src\ngit log --oneline -3\ngit -C lib/llvm/src rev-parse HEAD\nENDSSH\n```\n\nConfirm the log shows the expected HEAD of main, and the submodule hash matches\n`LLVM_DESIRED_HASH` in `lib/CMakeLists.txt`.\n\n## Step 3 — ensure LLVM is built and current\n\nLLVM is \"current\" when all three of these are true:\n\n1. The submodule at `lib/llvm/src` is at the commit specified by `LLVM_DESIRED_HASH`\n   in `lib/CMakeLists.txt`.\n2. The patches in `lib/llvm/patches/` have the hash specified by\n   `PATCHES_DESIRED_HASH` in `lib/CMakeLists.txt`.\n3. `build/libs/` exists and contains the LLVM built from that source with those\n   patches applied (subdirectories `lib/`, `bin/`, `include/`).\n\n### Check the submodule hash\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\necho \"desired: $(grep 'set(LLVM_DESIRED_HASH' lib/CMakeLists.txt | grep -oP '(?<=\")[^\"]+(?=\")')\"\necho \"actual:  $(git -C lib/llvm/src rev-parse HEAD)\"\nENDSSH\n```\n\nThese must match. If they don't, `git submodule update --init` in step 2 didn't work\n— re-run it, or investigate why the submodule is at the wrong commit.\n\n### Check whether build artifacts exist\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"ls /home/pi/code/ponylang/ponyc/build/libs/lib/ 2>/dev/null | head -3 || echo NO_LIBS\"'\n```\n\nIf `NO_LIBS`, a full rebuild is needed (8+ hours on the RPi — see \"Full rebuild\"\nbelow).\n\n### Validate the built LLVM against current source and patches\n\nEven when `build/libs/` exists and the submodule hash matches, the built LLVM might\nbe stale — the patches could have changed, or the build could be left over from a\ndifferent branch. Run `lib/build-libs.cmake` to validate everything. It checks both\n`LLVM_DESIRED_HASH` and `PATCHES_DESIRED_HASH` internally, resets the submodule\nsource tree, applies the current patches, and does an incremental build. If the source\nand patches haven't changed, the build step is a no-op (minutes, not hours).\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\ncmake -DJOBS=2 -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ -P lib/build-libs.cmake 2>&1\nENDSSH\n```\n\nThree outcomes:\n\n- **Completes with no errors** → LLVM is current, proceed to step 4.\n- **Fails with a hash mismatch** (`Patch hash actual '...' does not match\n  desired '...'`) → the patches changed since the last build. Do a full rebuild\n  (see below).\n- **Fails with a submodule hash error** → the submodule is wrong. Re-run\n  `git submodule update --init lib/llvm/src` in step 2 and try again.\n\nIf this step takes more than a few minutes, it is doing a real rebuild — detach it\n(see \"Full rebuild\" below) and poll.\n\n### Full rebuild\n\nWhen a clean LLVM build is needed (no `build/libs/`, or a hash mismatch), remove\nthe old build artifacts and rebuild from scratch. This takes 8+ hours on the RPi.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-libs-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\nrm -rf build/libs build/build_libs\ncmake -DJOBS=2 -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ -P lib/build-libs.cmake > /home/pi/libs-build.log 2>&1\necho \"LIBS_BUILD_EXIT_CODE=$?\" >> /home/pi/libs-build.log\nSCRIPT\nchmod +x /home/pi/run-libs-build.sh\nnohup /home/pi/run-libs-build.sh > /dev/null 2>&1 &\necho \"libs build started pid $!\"\nENDSSH\n```\n\nPoll until done:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -3 /home/pi/libs-build.log\"'\n```\n\nWait for `LIBS_BUILD_EXIT_CODE=0`. A nonzero exit code means the build failed —\nread the log above the exit code line for the error.\n\n## Test matrix\n\nTwo runtime builds (debug and release), each running the tests twice: once with\n`PONY_DEBUG=1` (debug codegen) and once without (release codegen). The env var\ncontrols whether ponyc compiles Pony sources with `-d`.\n\nEach runtime build runs these tests in both codegen modes:\n\n**ci-core minus stdlib** (run via ctest, excludes the monolithic stdlib test):\n\n- **check-version** — ponyc `--version` exits successfully\n- **libponyc.tests** — compiler C/C++ unit tests (GTest)\n- **libponyrt.tests** — runtime C/C++ unit tests (GTest)\n- **full-programs** — compile-and-run integration tests\n- **full-program-runner-rejects-broken-config** — verifies the test runner rejects bad config\n- **validate-grammar** — `pony.g` validated against the compiler\n\n**stdlib per-package** (run via ponyc directly, one package at a time):\n\nEach stdlib package is compiled and tested individually to stay within the\nILP32 address space limit. The full list of testable packages is derived from\n`packages/stdlib/_test.pony`.\n\n**tools** (all 5 tool test suites):\n\n- **pony-compiler-tests** — self-hosted compiler tool tests\n- **pony-dep-tests** — dependency tool tests\n- **pony-doc-tests** — documentation tool tests\n- **pony-lint-tests** — linter tool tests\n- **pony-lsp-tests** — language server tool tests\n\n## Step 4 — build and test with debug runtime\n\n### 4a. Configure\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\nrm -rf build/build_debug\ncmake --preset debug \\\n  -DCMAKE_C_COMPILER=gcc \\\n  -DCMAKE_CXX_COMPILER=g++ \\\n  \"-DCMAKE_C_FLAGS=-march=native\" \\\n  \"-DCMAKE_CXX_FLAGS=-march=native\"\nENDSSH\n```\n\nVerify \"Configuring done\" and \"Build files have been written\" in the output.\n\n### 4b. Build ponyc (detached)\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset debug > /home/pi/debug-build.log 2>&1\necho \"DEBUG_BUILD_EXIT_CODE=$?\" >> /home/pi/debug-build.log\nSCRIPT\nchmod +x /home/pi/run-debug-build.sh\nnohup /home/pi/run-debug-build.sh > /dev/null 2>&1 &\necho \"debug build started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -3 /home/pi/debug-build.log\"'\n```\n\nWait for `DEBUG_BUILD_EXIT_CODE=0`. Then verify all binaries built:\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc/build/debug\nfor bin in ponyc pony-compiler pony-dep pony-doc pony-lint pony-lsp; do\n  test -x \"$bin\" && echo \"$bin: OK\" || echo \"$bin: MISSING\"\ndone\nENDSSH\n```\n\nAll six must show OK.\n\n### 4c. Run stdlib tests per-package (debug runtime)\n\nDetect the SSL flag, then compile and run each testable package individually\nin both codegen modes. Run detached — this iterates over ~30 packages.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-stdlib-perpkg.sh << 'SCRIPT'\n#!/bin/bash\nset -u\ncd /home/pi/code/ponylang/ponyc\n\nPONYC=./build/debug/ponyc\nOUTDIR=./build/debug\nPKGDIR=./packages\n\n# Detect OpenSSL version for the SSL flag\nssl_ver=$(openssl version 2>/dev/null | head -1)\ncase \"$ssl_ver\" in\n  OpenSSL\\ 3.*) SSL_FLAG=-Dopenssl_3.0.x ;;\n  OpenSSL\\ 1.1.*) SSL_FLAG=-Dopenssl_1.1.x ;;\n  LibreSSL*) SSL_FLAG=-Dlibressl ;;\n  *) echo \"ERROR: cannot detect OpenSSL version: $ssl_ver\" >&2; exit 1 ;;\nesac\necho \"Detected SSL: $ssl_ver -> $SSL_FLAG\"\n\n# Packages with tests, from packages/stdlib/_test.pony\nPACKAGES=(\n  actor_pinning\n  encode/base64\n  buffered\n  builtin_test\n  bureaucracy\n  cli\n  collections\n  collections/persistent\n  constrained_types\n  crypto\n  files\n  format\n  http_client\n  ini\n  iregex\n  itertools\n  json\n  math\n  net\n  pony_test\n  process\n  promises\n  random\n  runtime_info\n  signals\n  strings\n  term\n  time\n  uri\n)\n\nLOGFILE=/home/pi/debug-stdlib-perpkg.log\n> \"$LOGFILE\"\noverall_rc=0\n\nfor codegen in debug release; do\n  echo \"=== $codegen codegen ===\" >> \"$LOGFILE\"\n  for pkg in \"${PACKAGES[@]}\"; do\n    name=$(echo \"$pkg\" | tr '/' '_')\n    debug_flag=\"\"\n    if [ \"$codegen\" = \"debug\" ]; then\n      debug_flag=\"-d\"\n    fi\n\n    echo -n \"  $pkg ($codegen): \" >> \"$LOGFILE\"\n\n    # Compile\n    $PONYC $debug_flag -b stdlib_test --checktree $SSL_FLAG --pic \\\n      \"$PKGDIR/$pkg\" -o \"$OUTDIR\" >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"COMPILE FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    # Run\n    \"$OUTDIR/stdlib_test\" --sequential >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"TEST FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    echo \"PASS\" >> \"$LOGFILE\"\n    rm -f \"$OUTDIR/stdlib_test\"\n  done\ndone\n\necho \"DEBUG_STDLIB_PERPKG_EXIT_CODE=$overall_rc\" >> \"$LOGFILE\"\nSCRIPT\nchmod +x /home/pi/run-debug-stdlib-perpkg.sh\nnohup /home/pi/run-debug-stdlib-perpkg.sh > /dev/null 2>&1 &\necho \"debug per-package stdlib tests started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -5 /home/pi/debug-stdlib-perpkg.log\"'\n```\n\nWait for `DEBUG_STDLIB_PERPKG_EXIT_CODE=0`.\n\n### 4d. Run ci-core tests minus stdlib (debug runtime)\n\nRun the ci-core tests excluding the monolithic stdlib, in both codegen modes.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-ci-core.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\necho \"=== debug codegen ===\" > /home/pi/debug-ci-core.log\nPONY_DEBUG=1 ctest --preset debug -L ci-core -E \"^stdlib$\" >> /home/pi/debug-ci-core.log 2>&1\nrc1=$?\necho \"=== release codegen ===\" >> /home/pi/debug-ci-core.log\nctest --preset debug -R \"^full-programs$\" >> /home/pi/debug-ci-core.log 2>&1\nrc2=$?\n\nif [ $rc1 -eq 0 ] && [ $rc2 -eq 0 ]; then\n  echo \"DEBUG_CI_CORE_EXIT_CODE=0\" >> /home/pi/debug-ci-core.log\nelse\n  echo \"DEBUG_CI_CORE_EXIT_CODE=1 (debug_codegen=$rc1 release_codegen=$rc2)\" >> /home/pi/debug-ci-core.log\nfi\nSCRIPT\nchmod +x /home/pi/run-debug-ci-core.sh\nnohup /home/pi/run-debug-ci-core.sh > /dev/null 2>&1 &\necho \"ci-core tests started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -5 /home/pi/debug-ci-core.log\"'\n```\n\nWait for `DEBUG_CI_CORE_EXIT_CODE=0`.\n\n### 4e. Build tool test binaries (after ponyc build completes)\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-tool-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset debug --target tool-tests > /home/pi/debug-tool-build.log 2>&1\necho \"TOOL_BUILD_EXIT_CODE=$?\" >> /home/pi/debug-tool-build.log\nSCRIPT\nchmod +x /home/pi/run-debug-tool-build.sh\nnohup /home/pi/run-debug-tool-build.sh > /dev/null 2>&1 &\necho \"tool build started pid $!\"\nENDSSH\n```\n\nPoll and wait for `TOOL_BUILD_EXIT_CODE=0`.\n\n### 4f. Run tool tests (debug runtime)\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-tools.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\nrc=0\nctest --preset debug -L tools >> /home/pi/debug-tools.log 2>&1\nrc=$?\necho \"DEBUG_TOOLS_EXIT_CODE=$rc\" >> /home/pi/debug-tools.log\nSCRIPT\nchmod +x /home/pi/run-debug-tools.sh\nnohup /home/pi/run-debug-tools.sh > /dev/null 2>&1 &\necho \"tool tests started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -5 /home/pi/debug-tools.log\"'\n```\n\nWait for `DEBUG_TOOLS_EXIT_CODE=0`.\n\n## Step 5 — build and test with release runtime\n\nRepeat the full test matrix with a release-built runtime and compiler.\n\n### 5a. Configure\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\nrm -rf build/build_release\ncmake --preset release \\\n  -DCMAKE_C_COMPILER=gcc \\\n  -DCMAKE_CXX_COMPILER=g++ \\\n  \"-DCMAKE_C_FLAGS=-march=native\" \\\n  \"-DCMAKE_CXX_FLAGS=-march=native\"\nENDSSH\n```\n\n### 5b. Build ponyc (detached)\n\nSame as step 4b but using the release preset.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset release > /home/pi/release-build.log 2>&1\necho \"RELEASE_BUILD_EXIT_CODE=$?\" >> /home/pi/release-build.log\nSCRIPT\nchmod +x /home/pi/run-release-build.sh\nnohup /home/pi/run-release-build.sh > /dev/null 2>&1 &\necho \"release build started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_BUILD_EXIT_CODE=0`. Verify binaries as in 4b\n(using `build/release` instead of `build/debug`).\n\n### 5c. Run stdlib tests per-package (release runtime)\n\nSame as step 4c but using the release ponyc.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-stdlib-perpkg.sh << 'SCRIPT'\n#!/bin/bash\nset -u\ncd /home/pi/code/ponylang/ponyc\n\nPONYC=./build/release/ponyc\nOUTDIR=./build/release\nPKGDIR=./packages\n\n# Detect OpenSSL version for the SSL flag\nssl_ver=$(openssl version 2>/dev/null | head -1)\ncase \"$ssl_ver\" in\n  OpenSSL\\ 3.*) SSL_FLAG=-Dopenssl_3.0.x ;;\n  OpenSSL\\ 1.1.*) SSL_FLAG=-Dopenssl_1.1.x ;;\n  LibreSSL*) SSL_FLAG=-Dlibressl ;;\n  *) echo \"ERROR: cannot detect OpenSSL version: $ssl_ver\" >&2; exit 1 ;;\nesac\necho \"Detected SSL: $ssl_ver -> $SSL_FLAG\"\n\nPACKAGES=(\n  actor_pinning\n  encode/base64\n  buffered\n  builtin_test\n  bureaucracy\n  cli\n  collections\n  collections/persistent\n  constrained_types\n  crypto\n  files\n  format\n  http_client\n  ini\n  iregex\n  itertools\n  json\n  math\n  net\n  pony_test\n  process\n  promises\n  random\n  runtime_info\n  signals\n  strings\n  term\n  time\n  uri\n)\n\nLOGFILE=/home/pi/release-stdlib-perpkg.log\n> \"$LOGFILE\"\noverall_rc=0\n\nfor codegen in debug release; do\n  echo \"=== $codegen codegen ===\" >> \"$LOGFILE\"\n  for pkg in \"${PACKAGES[@]}\"; do\n    name=$(echo \"$pkg\" | tr '/' '_')\n    debug_flag=\"\"\n    if [ \"$codegen\" = \"debug\" ]; then\n      debug_flag=\"-d\"\n    fi\n\n    echo -n \"  $pkg ($codegen): \" >> \"$LOGFILE\"\n\n    $PONYC $debug_flag -b stdlib_test --checktree $SSL_FLAG --pic \\\n      \"$PKGDIR/$pkg\" -o \"$OUTDIR\" >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"COMPILE FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    \"$OUTDIR/stdlib_test\" --sequential >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"TEST FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    echo \"PASS\" >> \"$LOGFILE\"\n    rm -f \"$OUTDIR/stdlib_test\"\n  done\ndone\n\necho \"RELEASE_STDLIB_PERPKG_EXIT_CODE=$overall_rc\" >> \"$LOGFILE\"\nSCRIPT\nchmod +x /home/pi/run-release-stdlib-perpkg.sh\nnohup /home/pi/run-release-stdlib-perpkg.sh > /dev/null 2>&1 &\necho \"release per-package stdlib tests started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_STDLIB_PERPKG_EXIT_CODE=0`.\n\n### 5d. Run ci-core tests minus stdlib (release runtime)\n\nSame as step 4d but using the release preset.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-ci-core.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\necho \"=== debug codegen ===\" > /home/pi/release-ci-core.log\nPONY_DEBUG=1 ctest --preset release -L ci-core -E \"^stdlib$\" >> /home/pi/release-ci-core.log 2>&1\nrc1=$?\necho \"=== release codegen ===\" >> /home/pi/release-ci-core.log\nctest --preset release -R \"^full-programs$\" >> /home/pi/release-ci-core.log 2>&1\nrc2=$?\n\nif [ $rc1 -eq 0 ] && [ $rc2 -eq 0 ]; then\n  echo \"RELEASE_CI_CORE_EXIT_CODE=0\" >> /home/pi/release-ci-core.log\nelse\n  echo \"RELEASE_CI_CORE_EXIT_CODE=1 (debug_codegen=$rc1 release_codegen=$rc2)\" >> /home/pi/release-ci-core.log\nfi\nSCRIPT\nchmod +x /home/pi/run-release-ci-core.sh\nnohup /home/pi/run-release-ci-core.sh > /dev/null 2>&1 &\necho \"ci-core tests started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_CI_CORE_EXIT_CODE=0`.\n\n### 5e. Build and run tool tests (release runtime)\n\nSame as steps 4e/4f but using the release preset.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-tool-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset release --target tool-tests > /home/pi/release-tool-build.log 2>&1\necho \"TOOL_BUILD_EXIT_CODE=$?\" >> /home/pi/release-tool-build.log\nSCRIPT\nchmod +x /home/pi/run-release-tool-build.sh\nnohup /home/pi/run-release-tool-build.sh > /dev/null 2>&1 &\necho \"tool build started pid $!\"\nENDSSH\n```\n\nPoll and wait for `TOOL_BUILD_EXIT_CODE=0`.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-tools.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\nctest --preset release -L tools > /home/pi/release-tools.log 2>&1\nrc=$?\necho \"RELEASE_TOOLS_EXIT_CODE=$rc\" >> /home/pi/release-tools.log\nSCRIPT\nchmod +x /home/pi/run-release-tools.sh\nnohup /home/pi/run-release-tools.sh > /dev/null 2>&1 &\necho \"tool tests started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_TOOLS_EXIT_CODE=0`.\n\n## Step 6 — report results\n\nSummarize which steps passed and which failed. Report each result individually:\n\n- LLVM version and whether it needed rebuilding\n- Debug runtime build: pass/fail (ponyc, pony-compiler, pony-dep, pony-doc,\n  pony-lint, pony-lsp built)\n- Debug runtime — ci-core minus stdlib (debug codegen): pass/fail\n- Debug runtime — ci-core minus stdlib (release codegen): pass/fail\n- Debug runtime — stdlib per-package (debug codegen): pass/fail per package\n- Debug runtime — stdlib per-package (release codegen): pass/fail per package\n- Debug runtime — tool tests: pass/fail\n- Release runtime build: pass/fail\n- Release runtime — ci-core minus stdlib (debug codegen): pass/fail\n- Release runtime — ci-core minus stdlib (release codegen): pass/fail\n- Release runtime — stdlib per-package (debug codegen): pass/fail per package\n- Release runtime — stdlib per-package (release codegen): pass/fail per package\n- Release runtime — tool tests: pass/fail\n\n## Cleanup\n\nKill any orphaned build processes and remove log files when done:\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\npkill -f \"cmake --build\" 2>/dev/null\npkill -f \"ctest --preset\" 2>/dev/null\npkill -f \"run-.*\\.sh\" 2>/dev/null\nrm -f /home/pi/run-*.sh /home/pi/*-build.log /home/pi/*-tool-build.log /home/pi/*-ci-core.log /home/pi/*-tools.log /home/pi/*-stdlib-perpkg.log\nENDSSH\n```\n\nDo not remove the `build/` directory — it holds the built LLVM and ponyc, which\npersist for the next validation run.\n","frontmatter":{"name":"32-bit-support-validation","description":"Load when validating ponyc 32-bit support. Covers syncing the test machine, building LLVM and ponyc, and running the full test suite on a 32-bit ARM RPi4.","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":6218,"sizeBytes":20859},{"name":"SKILL.md","path":".claude/skills/add-linux-release-target/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/.claude/skills/add-linux-release-target/SKILL.md","title":"add-linux-release-target","category":"anthropic-skill","format":"markdown","content":"---\nname: add-linux-release-target\ndescription: Load when adding a new Linux distro/version (e.g. Ubuntu 26.04, Alpine 3.24) as a ponyc release target. Covers builder Dockerfile creation, image build dispatch and tag discovery, workflow updates, docs, release notes, and the post-merge Last Week in Pony announcement.\ndisable-model-invocation: false\n---\n\n# Adding a New Linux Distro/Version as a ponyc Release Target\n\n## Scope\n\nUse this skill when adding a new distro/version (e.g. Ubuntu 26.04) so ponyc produces release and nightly artifacts users can install via ponyup.\n\nThe procedure encodes the **multi-platform builder pattern** — a single `.ci-dockerfiles/<distro><version>-builder/` directory containing one Dockerfile and one `build-and-push.bash` that builds and publishes a multi-arch image (linux/amd64 + linux/arm64) under `ghcr.io/ponylang/ponyc-ci-<distro><version>-builder`. This is the pattern used by Alpine 3.23+. New release-target adds — including new Ubuntu versions — should use it. Older builders still use a per-arch pattern (separate `x86-64-unknown-linux-...-builder/` and `arm64-unknown-linux-...-builder/` directories); leave those alone, don't migrate them as part of an add.\n\n**Out of scope:**\n- CI test-only workflows: `pr.yml`, `ponyc-tier2.yml`, `ponyc-weekly-checks.yml`, `stress-test-*.yml`. These reference Linux images for testing ponyc itself, not for producing release artifacts. Don't touch them.\n- `pr-docker-image-validation.yml` validates the user-facing `.dockerfiles/{nightly,release}/Dockerfile` images (Alpine-based). Not affected by adding a release target.\n- Dropping older releases. We support only the two newest releases of a distro, so adding a newer one eventually means dropping the now-oldest — but that drop is a separate, by-hand procedure, not covered here.\n- Migrating an existing per-arch builder to the multi-platform pattern. Different procedure, not covered here.\n\n## Guiding facts\n\n- **Adds only; the policy is newest-two.** This skill only adds a target. Our policy is to support the two newest releases of a distro, so adding a newer release usually pairs with dropping the now-oldest — but that drop is a separate, by-hand procedure (not covered here). Don't drop anything as part of this work.\n- **Both architectures by default.** Multi-platform images build linux/amd64 and linux/arm64 from one Dockerfile. Don't restrict arches without a concrete reason.\n- **GHCR builder packages are repo-scoped.** No manual \"make public\" step is needed for new packages — same-repo CI pulls them automatically.\n- **Mental model.** One multi-arch builder image gets pushed to GHCR with a date-stamp tag. Workflow files (`release.yml`, `nightlies.yml`, `update-lib-cache.yml`) reference that one image by exact tag from both their x86_64-linux and arm64-linux matrices — Docker pulls the matching arch on each runner. Adding a new release target = create the Dockerfile, build/push the image, then thread the resulting tag into every workflow that consumes it, plus update the user-facing docs.\n\n## File inventory\n\nNew files (a single builder directory with two files):\n- `.ci-dockerfiles/<distro><version>-builder/Dockerfile`\n- `.ci-dockerfiles/<distro><version>-builder/build-and-push.bash`\n\nExisting files to edit:\n- `.github/workflows/build-builder-image.yml` — one dropdown option + one job block\n- `.github/workflows/release.yml` — `x86_64-linux` and `arm64-linux` matrices\n- `.github/workflows/nightlies.yml` — same matrices\n- `.github/workflows/update-lib-cache.yml` — same matrices (simpler shape: just `image:`)\n- `INSTALL.md` — supported-distro list + PLATFORM string table\n- `RELEASE_PROCESS.md` — package names list\n\nNew release notes file:\n- `.release-notes/add-<distro>-<version>.md`\n\n`BUILD.md` and `README.md` need no changes — neither contains version-specific content for this kind of add.\n\n## Before you begin\n\n- **Branch base:** start from a fresh `main`. `git checkout main && git pull`.\n- **Tools:** Docker daemon running (`docker info` succeeds), `gh` authenticated, `git` configured to push to origin.\n- **`gh` token scopes needed:** `workflow` (for dispatching builds) and `read:packages` (for querying GHCR tags). Check with `gh auth status`. If missing: `gh auth refresh -s workflow,read:packages`.\n- **Placeholders in this document:** every `<distro>`, `<version>`, `<tag>`, etc. is a placeholder for substitution. The angle brackets are not part of any command. Example for Ubuntu 26.04: `<distro>` → `ubuntu`, `<version>` → `26.04`.\n\n## Procedure\n\nThe **structural exemplar** — directory layout, `build-and-push.bash`, `build-builder-image.yml` job block, workflow matrix entries — is the most recent Alpine multi-platform builder. As of writing, that's `alpine3.24-builder/`. Before starting, glance at `.ci-dockerfiles/` and pick whichever Alpine multi-platform builder dir is newest (`alpineX.Y-builder/`, no arch prefix); use it in place of `alpine3.24-builder` throughout this document.\n\nThe **Dockerfile-content exemplar** is the most recent same-distro builder, regardless of pattern (per-arch is fine — only the package list and base image are reused). For Ubuntu adds at the time this skill was written, that's `x86-64-unknown-linux-ubuntu24.04-builder/Dockerfile`.\n\n### 1. Branch\n\n```bash\ngit checkout main && git pull\ngit checkout -b add-<distro>-<version>-release-target\n```\n\n### 2. Create the builder Dockerfile and build-and-push script\n\n```bash\nmkdir -p .ci-dockerfiles/<distro><version>-builder\n```\n\n**Dockerfile.** Copy the body from the most recent same-distro builder's Dockerfile (for Ubuntu, the per-arch x86-64 and arm64 Ubuntu 24.04 Dockerfiles are byte-identical, so either is fine), change the `FROM` line to the new version. The base image (e.g. `ubuntu:26.04`) must be a multi-arch manifest — it already is for the official `ubuntu` and `alpine` images. If adding a different distro, verify with:\n\n```bash\ndocker manifest inspect <distro>:<version> | jq '.manifests[].platform'\n```\n\nBoth `linux/amd64` and `linux/arm64` must appear.\n\n**build-and-push.bash.** Copy from the structural-exemplar multi-platform builder (e.g. `alpine3.24-builder/build-and-push.bash`) and change only the `NAME` and the `BUILDER` prefix. The shape:\n\n```bash\n#!/bin/bash\n\nset -o errexit\nset -o nounset\n\n#\n# *** You should already be logged in to GHCR when you run this ***\n#\n\nNAME=\"ghcr.io/ponylang/ponyc-ci-<distro><version>-builder\"\nTODAY=$(date +%Y%m%d)\nDOCKERFILE_DIR=\"$(dirname \"$0\")\"\nBUILDER=\"<distro><version>-builder-$(date +%s)\"\n\ndocker buildx create --use --name \"${BUILDER}\"\ndocker buildx build --provenance false --sbom false --platform linux/arm64,linux/amd64 --pull --push -t \"${NAME}:${TODAY}\" \"${DOCKERFILE_DIR}\"\ndocker buildx rm \"${BUILDER}\"\n```\n\nDon't modify the package list, the `LABEL`, the user setup, or anything else without a concrete reason. If a package name has changed in the new distro version, the local smoke test in step 3 will surface it — don't pre-emptively edit.\n\n### 3. Local smoke test\n\nBefore pushing or dispatching anything, build the Dockerfile locally for amd64 explicitly:\n\n```bash\ndocker build --pull --platform linux/amd64 .ci-dockerfiles/<distro><version>-builder/\n```\n\n`--platform linux/amd64` makes the smoke test deterministic regardless of host architecture (matters on Apple Silicon, where host-default would be `linux/arm64` and use a different package set). `--pull` forces Docker to fetch the latest base image rather than use a stale local cache. This is a smoke test only — don't run `build-and-push.bash` locally (that would push to GHCR with today's date stamp).\n\nIf the build fails because Docker isn't running or the package manager can't reach the network, that's an environment problem — fix it and retry, don't escalate. If the build fails inside the image (package gone, repo URL invalid for the new distro, base image not yet published, etc.): **STOP**. Surface the error to the human. Don't retry. Don't \"fix\" by tweaking the package list without confirmation — that's a design discussion.\n\nThe remote multi-platform build in step 6 cross-builds arm64 from x86-64 via QEMU. Local x86-64 success is a strong signal but not a guarantee for arm64; the GHA dispatch is the real arm64 verification.\n\n### 4. Update `build-builder-image.yml`\n\nTwo edits, both mechanical:\n\n**a. Add one dropdown option** (alphabetical across the whole list):\n\n```yaml\noptions:\n  ...\n  - <distro><version>-builder\n  ...\n```\n\n**b. Add one job block** by copying the structural-exemplar job (e.g. `alpine3_24-builder`) and substituting the version. Place the new job alphabetically among the sibling jobs.\n\nThe job map key replaces `.` with `_` (YAML map keys can't contain `.`): a version of `26.04` becomes `26_04` in the job key only — the `if:` filter value, the `name:` field, and the `bash` script path all keep the dot form. Reference shape:\n\n```yaml\n<distro><version_underscore>-builder:\n  if: ${{ github.event.inputs.builder-name == '<distro><version>-builder' }}\n  runs-on: ubuntu-latest\n\n  name: <distro><version>-builder\n  steps:\n    - name: Checkout\n      uses: actions/checkout@v6.0.2\n    - name: Set up Docker Buildx\n      uses: docker/setup-buildx-action@v4\n      with:\n        version: v0.23.0\n    - name: Login to GitHub Container Registry\n      uses: docker/login-action@v4\n      with:\n        registry: ghcr.io\n        username: ${{ github.repository_owner }}\n        password: ${{ secrets.GITHUB_TOKEN }}\n    - name: Build and push\n      run: bash .ci-dockerfiles/<distro><version>-builder/build-and-push.bash\n```\n\n`runs-on: ubuntu-latest` is correct — the multi-platform build cross-compiles arm64 via QEMU on an x86-64 runner. Don't change it.\n\n### 5. Push branch (WIP commit is fine)\n\n```bash\ngit add .ci-dockerfiles .github/workflows/build-builder-image.yml\ngit commit -m \"WIP: add <distro> <version> builder\"\ngit push -u origin add-<distro>-<version>-release-target\n```\n\n### 6. Dispatch the builder workflow from the branch\n\nGHA `workflow_dispatch` runs the workflow file from the chosen ref, so the new dropdown option becomes available when targeting the branch.\n\n```bash\ngh workflow run build-builder-image.yml \\\n  --ref add-<distro>-<version>-release-target \\\n  -f builder-name=<distro><version>-builder\n```\n\nIf dispatch returns 403, the `gh` token lacks `workflow` scope — see \"Before you begin.\"\n\n### 7. Wait for the build to finish\n\nThe multi-platform build takes ~20–40 minutes (arm64 via QEMU is the slow leg). Capture the run id, then watch it:\n\n```bash\nRUN_ID=$(gh run list --workflow=build-builder-image.yml \\\n  --branch=add-<distro>-<version>-release-target \\\n  --limit=1 --json databaseId --jq '.[].databaseId')\n\ngh run watch \"$RUN_ID\" --exit-status\n```\n\nIf the build fails: **STOP**. Read the failed logs with `gh run view \"$RUN_ID\" --log-failed` and surface the failure to the human. A common cause is a package or repo change for the new distro — that's a discussion, not something to silently work around.\n\n### 8. Discover the published tag\n\nThe successful build pushes `ghcr.io/ponylang/ponyc-ci-<distro><version>-builder:YYYYMMDD`. Query GHCR:\n\n```bash\ngh api -X GET /orgs/ponylang/packages/container/ponyc-ci-<distro><version>-builder/versions \\\n  --jq '[.[].metadata.container.tags[]] | map(select(test(\"^[0-9]{8}$\"))) | max'\n```\n\nThe `select(test(\"^[0-9]{8}$\"))` filter restricts to date-form tags (rejects `latest` or other future tags); `max` picks the most recent.\n\nIf the API returns 403, the `gh` token lacks `read:packages` — see \"Before you begin.\"\n\n### 9. Apply the tag to workflow files\n\nAdd new matrix entries alongside the existing entries. The same image string goes into both the `x86_64-linux` and `arm64-linux` matrices — Docker pulls the matching arch on each runner.\n\n**Use the structural-exemplar entry (Alpine 3.24) from the same file/matrix as your template.** Existing same-distro entries are inconsistent (per-arch builders use different image-name shapes; some files have `triple-vendor: unknown` on arm64, others omit it). Avoid that ambiguity: copy the alpine3.24 entry from the file/matrix you're editing, then substitute `image:`, `name:`, and `triple-os:` with the new distro/version values. Keep all other fields (including `triple-vendor`'s presence or absence) exactly as alpine3.24 has them in that location.\n\nFiles and matrices to edit:\n- `.github/workflows/release.yml` — `x86_64-linux` and `arm64-linux` matrices.\n- `.github/workflows/nightlies.yml` — `x86_64-linux` and `arm64-linux` matrices.\n- `.github/workflows/update-lib-cache.yml` — `x86_64-linux` and `arm64-linux` matrices (simpler shape: just `image:`).\n\nWorked example for Ubuntu 26.04, x86_64-linux matrices in `release.yml` and `nightlies.yml`:\n\n```yaml\n- image: ghcr.io/ponylang/ponyc-ci-ubuntu26.04-builder:<tag>\n  name: x86-64-unknown-linux-ubuntu26.04\n  triple-os: linux-ubuntu26.04\n  triple-vendor: unknown\n```\n\nThe arm64-linux matrix entry in those files uses the **same** `image:` string but `name: arm64-unknown-linux-ubuntu26.04`. Because the structural-exemplar (Alpine 3.24) arm64-linux entries omit `triple-vendor`, this entry omits it too.\n\n(In `release.yml` the arm64-linux job uses `docker run` directly rather than a `container:` job; ignore the surrounding job structure — the matrix entry shape is what matters.)\n\n### 10. Update docs\n\n`INSTALL.md`:\n- \"Supported Glibc distributions\" list (only relevant for distros listed there; Alpine adds use a different section): extend the existing comma-separated Ubuntu line in place. Add the new version to that line, don't add a new bullet.\n- If a corresponding Pop!_OS or Linux Mint release based on this Ubuntu version exists at this time, add it to its line and to the PLATFORM string table too. If it doesn't exist yet, skip — those rows can be added in a later PR when they ship.\n- PLATFORM string table: add two rows (amd64 and arm64). The existing rows for arm64 Ubuntu use `arm-linux-` (not `arm64-linux-`) — match that historical convention; don't \"fix\" it. PLATFORM strings (in INSTALL.md) and package names (in RELEASE_PROCESS.md) intentionally use different arch prefixes — match existing rows in each file rather than imposing consistency between them.\n\n`RELEASE_PROCESS.md`:\n- \"Package names will be:\" list: add two lines (alphabetical). Artifact naming always uses the arch-prefixed form, regardless of how the builder image is named:\n  - `* ponyc-arm64-unknown-linux-<distro><version>.tar.gz`\n  - `* ponyc-x86-64-unknown-linux-<distro><version>.tar.gz`\n\n### 11. Add release note\n\nCreate `.release-notes/add-<distro>-<version>.md`:\n\n```markdown\n## <Distro> <Version> added as a supported platform\n\nWe've added arm64 and amd64 builds for <Distro> <Version>. We build ponyc releases for the two newest <Distro> releases, so we'll keep building for <Version> until two newer <Distro> releases have shipped, at which point we'll stop.\n```\n\nThis combined-arch form matches the precedent for fresh adds (e.g. `.release-notes/0.60.4.md`). The per-arch form (\"X on arm64 added as a supported platform\") is for the historical case of adding an arch to an already-supported version — not what this skill covers.\n\nAfter this step, sanity-check coverage:\n\n```bash\ngit diff --stat main\ngrep -rn \"<distro><version>\" .github/workflows/ INSTALL.md RELEASE_PROCESS.md .ci-dockerfiles/ .release-notes/\n```\n\n`git diff --stat` should show changes only in the files listed in the \"File inventory\" section above (plus the two new Dockerfile/script files and the new release-notes file) — investigate any unexpected location. The `grep` confirms the placeholder was substituted; note that `build-builder-image.yml` job-key `<distro><version_underscore>` (e.g. `ubuntu26_04`) won't match the dotted form, so check that file separately.\n\n### 12. Squash, push, open PR\n\nThe PR title must exactly match the release-note title from step 11 (`<Distro> <Version> added as a supported platform`) — both end up as public-facing CHANGELOG content, so they must agree. The commit message matches too (it becomes the squash-merge commit subject).\n\nThe PR also needs the `changelog - added` label so release-notes aggregation picks up the entry under the right CHANGELOG section.\n\n```bash\ngit fetch origin main\ngit reset --soft origin/main\ngit add -A\ngit commit -m \"<Distro> <Version> added as a supported platform\"\ngit push --force-with-lease\n\nPR_URL=$(gh pr create \\\n  --title \"<Distro> <Version> added as a supported platform\" \\\n  --label \"changelog - added\" \\\n  --body \"$(cat <<'EOF'\nAdds <Distro> <Version> as a supported release target. Builder image, release/nightly/lib-cache workflow entries, INSTALL/RELEASE_PROCESS docs, and a release note are all included.\nEOF\n)\")\nPR_NUM=${PR_URL##*/}\n```\n\n### 13. Wait for CI, then squash-merge\n\nBlock until all PR checks complete, then merge if they pass:\n\n```bash\ngh pr checks \"$PR_NUM\" --watch --exit-status\ngh pr merge \"$PR_NUM\" --squash --delete-branch\ngit checkout main && git pull\n```\n\n`--watch --exit-status` blocks until checks finish and exits non-zero if any failed. If checks fail: **STOP**. Read the failure with `gh pr checks \"$PR_NUM\"` to see which check, drill into its logs, and surface to the human. Don't retry the merge.\n\n`--squash --delete-branch` matches the ponylang merge convention (repos under the ponylang and seantallen-org GitHub orgs only allow squash merges) and removes both the remote and local branch. The `git checkout main && git pull` afterward returns you to an up-to-date main.\n\n### 14. Post-merge: Last Week in Pony comment\n\nAfter the PR merges, find the open LWIP issue and post a comment.\n\n```bash\nLWIP=$(gh issue list -R ponylang/ponylang-website \\\n  --label last-week-in-pony --state open --limit 1 --json number --jq '.[0].number // empty')\n\nif [ -z \"$LWIP\" ]; then\n  echo \"No open LWIP issue. Wait until the next one is created (typically within a week) and post then. Don't open a new LWIP issue yourself.\"\nelse\n  gh issue comment \"$LWIP\" -R ponylang/ponylang-website --body \"$(cat <<'EOF'\n<Distro> <Version> has been added as a supported platform for ponyc. Prebuilt binaries are available via [ponyup](https://github.com/ponylang/ponyup). We build releases for the two newest <Distro> releases, so we'll keep building for <Version> until two newer <Distro> releases ship.\nEOF\n)\"\nfi\n```\n\nThe comment substance matches the release note: announcing the new platform and our policy of supporting the two newest releases. Format is short, single-paragraph prose matching existing LWIP comments.\n\nAfter this step, the add-a-release-target work is complete.\n\n## Failure modes — when to stop and ask\n\n- **Local Dockerfile build fails inside the image.** Package gone, repo URL invalid, base image not yet published. Don't tweak the package list or \"fix forward\" without confirmation.\n- **GHA builder dispatch fails.** Read run logs with `gh run view <id> --log-failed`. Most causes are the same as above.\n- **GHCR tag query returns no result.** Build may not have pushed (auth failure, network error). Check the workflow run logs.\n- **arm64 build fails but amd64 succeeds (or vice versa).** Multi-platform `docker buildx` produces a single manifest covering both arches; if either platform fails, the whole build fails and nothing gets pushed. Triage from the failed-platform logs.\n- **PR CI checks fail.** Don't retry the merge. Read the failed check's logs, surface to the human. Possible causes range from a flaky test (rare on a docs/config-only PR like this) to a real interaction with workflows you didn't expect to touch.\n\n## Anti-patterns\n\n- **Don't conflate this with CI test workflows.** PR validation, tier 2, weekly checks, and stress tests are separate concerns. They may also need updating for new distros, but not as part of \"add a release target.\"\n- **Don't migrate existing per-arch builders to multi-platform as a side effect.** That's a separate change.\n- **Don't skip the smoke test to save time.** A failed GHA multi-platform build is a 30-minute round-trip plus the human-attention cost of triaging it. A failed local build is 30 seconds.\n- **Don't run `build-and-push.bash` locally.** It pushes. The smoke test is `docker build` only.\n","frontmatter":{"name":"add-linux-release-target","description":"Load when adding a new Linux distro/version (e.g. Ubuntu 26.04, Alpine 3.24) as a ponyc release target. Covers builder Dockerfile creation, image build dispatch and tag discovery, workflow updates, docs, release notes, and the post-merge Last Week in Pony announcement.","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":5126,"sizeBytes":20248},{"name":"SKILL.md","path":".claude/skills/create-dragonfly-dev-env/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/.claude/skills/create-dragonfly-dev-env/SKILL.md","title":"create-dragonfly-dev-env","category":"anthropic-skill","format":"markdown","content":"---\nname: create-dragonfly-dev-env\ndescription: Load when you need to reproduce or validate a gcc-specific or DragonFly-specific ponyc issue on a local VM, or to stand up a local DragonFly BSD development VM that matches ponyc tier-3 CI. Covers the prerequisite check, the sudo-free QEMU/KVM setup, the reused console automation, the in-VM gcc13 build env, and the gotchas (csh, screendump paths, boot timing, detached builds, per-block shells) that make hand-rolling one error-prone.\ndisable-model-invocation: false\n---\n\n# Create a DragonFly BSD dev/test VM for ponyc\n\nStand up a local DragonFly BSD VM that matches the ponyc tier-3 CI job, so you can\nbuild and test ponyc on DragonFly without GitHub Actions.\n\nDragonFly only builds ponyc with gcc (the `gcc13` package), so it is the authoritative\nlocal environment for **gcc-specific** ponyc issues — the ones clang-based CI never\nsees. The setup is fiddly and timing-sensitive; the steps below are the known-good\nsequence. Follow them in order.\n\nRun the repo-relative commands (`.ci-scripts/bsd/...`, `rsync` of the source) from your\nponyc checkout root. The VM itself lives in a separate directory (`$VMDIR`).\n\n**Each fenced `sh` block below is self-contained.** It re-sets `VMDIR`/`DFLY_VER` (and,\nwhere needed, the `SSH` command) at its top, because an agent runs each block as a fresh\nshell — environment variables and the working directory do NOT carry from one block to\nthe next. Do not \"optimize\" by setting them once and dropping them from later blocks: an\nempty `$SSH` would make a `$SSH /bin/sh <<'EOF' ... EOF` heredoc run **on your host**\n(silently, exit 0) — and Step 7 contains `rm -rf /usr/local`. Keep every block whole.\nPick your `VMDIR`/`DFLY_VER` once and use the same values in every block.\n\n## Gotchas that will bite you (read first)\n\nEach of these cost real debugging. Internalize them before you start — they recur at\nseveral steps:\n\n- **Per-block fresh shells.** See above: re-establish `VMDIR`/`DFLY_VER`/`SSH` in every\n  block; never let `$SSH` be empty in front of a `/bin/sh` heredoc.\n- **Root's login shell is csh, not sh.** Any remote command with `$(...)`, `export`, or\n  a here-string MUST go through an explicit `/bin/sh`:\n  `ssh ... root@localhost /bin/sh <<'EOF' ... EOF`. A bare\n  `ssh ... root@localhost 'cmd with $(...)'` fails with `Illegal variable name.`\n- **Boot timing is handled by the script.** `dfly_configure_vm.py` takes periodic VGA\n  screendumps and waits for the screen to stabilize before attempting login. It then types\n  three short commands via sendkey (login, mount the seed ISO, run the setup script) and\n  verifies SSH becomes reachable. If something goes wrong, check the diagnostic screendump\n  the script saves, or use the manual screendump helper (Step 5) to see the VM state.\n- **A daemonized QEMU `chdir`s to `/`.** The monitor `screendump` (and any relative path\n  the daemon writes) needs an **absolute** path, or it fails `Permission denied`.\n- **No `sudo` needed (and often unavailable).** CI loop-mounts the ISO with `sudo` to get\n  `/usr/include`; locally, extract it with `bsdtar` instead — same result, no privilege.\n- **Detach long in-VM builds.** The libs build (LLVM) takes hours; run it\n  `nohup … > /build/x.log 2>&1 &` and poll the log, so an ssh drop doesn't kill it.\n- **Reuse the CI console script verbatim.** `.ci-scripts/bsd/dfly_configure_vm.py`\n  types login + mount + setup via QEMU sendkey, then verifies SSH is reachable. The setup\n  script lives on the seed ISO created in Step 4. Copy the Python script, don't reimplement\n  the bootstrap or setup.\n\n## Step 0 — verify prerequisites (do NOT assume they're installed)\n\nThis skill needs, on the host:\n\n- an existing ponyc checkout (you run the repo-relative commands from it)\n- hardware-accelerated virtualization for QEMU (KVM on Linux, HVF on macOS)\n- enough free disk for the VM's sparse qcow2 disks (provisioned at 60 GB + 50 GB nominal;\n  they grow only as used, and the libs build plus a build fill a large chunk of the data\n  disk), ~2 GB for the image/ISO files, and network access to mirror-master.dragonflybsd.org\n- `qemu-system-x86_64` and `qemu-img`\n- `bsdtar` (a libarchive tar that reads ISO9660; the default `tar` on macOS, FreeBSD, and DragonFly)\n- `genisoimage` (or `mkisofs`) for creating the seed ISO\n- `bunzip2`, `rsync`, `git`, `curl`, `python3`\n- an OpenSSH client (`ssh`, `scp`, `ssh-keygen`)\n- a PPM-to-PNG converter: any one of magick/convert, ffmpeg, or pnmtopng\n\nCheck that every required tool is present. Do not assume the environment has them, and do\nnot install anything yourself — installing them is the user's call (it needs privilege,\nand the package names vary by OS, so naming a specific package would be wrong on half the\nplatforms this runs on). If anything is missing, **stop, tell the user which\ntools/capabilities are missing, and ask them to install them with their platform's\npackage manager**, then re-run the check before proceeding.\n\n```sh\nmissing=\"\"\nfor t in qemu-system-x86_64 qemu-img bsdtar bunzip2 rsync git ssh scp ssh-keygen curl python3 genisoimage; do\n  command -v \"$t\" >/dev/null 2>&1 || missing=\"$missing $t\"\ndone\ncommand -v magick >/dev/null 2>&1 || command -v convert >/dev/null 2>&1 \\\n  || command -v ffmpeg >/dev/null 2>&1 || command -v pnmtopng >/dev/null 2>&1 \\\n  || missing=\"$missing ppm-converter(magick|ffmpeg|pnmtopng)\"\n# Hardware acceleration is host-specific — check the right accelerator for this OS.\ncase \"$(uname -s)\" in\n  Linux)  [ -w /dev/kvm ] || missing=\"$missing kvm(/dev/kvm-not-writable)\" ;;\n  Darwin) qemu-system-x86_64 -accel help 2>&1 | grep -q hvf || missing=\"$missing hvf-accelerator\" ;;\n  *)      echo \"NOTE: confirm this host has a QEMU hardware accelerator and adjust the boot accel= below\" ;;\nesac\n[ -n \"$missing\" ] && echo \"MISSING:$missing\" || echo \"all prerequisites present\"\n```\n\nIf the output is anything but `all prerequisites present`, name the missing\ntools/capabilities to the user and ask them to install them however their OS does — do\nNOT guess a distro package name. (`bsdtar` is the libarchive tar; on macOS/FreeBSD/\nDragonFly the system `tar` is already bsdtar, elsewhere it comes from libarchive.) A\nmissing hardware accelerator — no writable `/dev/kvm` on Linux, no HVF on macOS — is a\nhost/BIOS/virtualization fix only the user can make; the VM is unusably slow without it,\nso don't fall back to TCG emulation.\n\nThis flow is verified on Linux+KVM. The guest side is OS-agnostic, so macOS goes through\nthe same steps with the HVF accelerator (`accel=kvm:hvf` in Step 4 selects it).\n\n## Setup\n\nPick a persistent VM directory and a DragonFly version that **matches ponyc CI** (check\nthe image URL in `.ci-scripts/bsd/dragonfly-provision.bash`; currently 6.4.2). Use the\nsame two values in every block below.\n\n### 1. Download the image + ISO (one time; keep the `.bz2` to avoid re-downloading)\n\nThe raw `.img` boots; the `.iso` is only mined for system headers (the raw image ships\nwithout `/usr/include`).\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; DFLY_VER=6.4.2\nmkdir -p \"$VMDIR\" && cd \"$VMDIR\"\nbase=\"https://mirror-master.dragonflybsd.org/iso-images\"\ncurl -fL --retry 3 -o dfly.img.bz2 \"$base/dfly-x86_64-${DFLY_VER}_REL.img.bz2\"\ncurl -fL --retry 3 -o dfly.iso.bz2 \"$base/dfly-x86_64-${DFLY_VER}_REL.iso.bz2\"\n```\n\n### 2. Build the disks\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\nbunzip2 -k -f dfly.img.bz2 dfly.iso.bz2          # -k keeps the .bz2 originals\nqemu-img convert -f raw -O qcow2 dfly.img dfly.qcow2\nqemu-img resize dfly.qcow2 60G\nqemu-img create -f qcow2 dfly-data.qcow2 50G     # build workspace; root part is ~1.8G\nrm -f dfly.img                                    # keep dfly.qcow2 + the .bz2\n```\n\n### 3. Extract system headers from the ISO — no sudo (replaces CI's `mount -o loop`)\n\n`bsdtar` reads ISO9660 directly. The tar must have `include/` as its top entry so the\nin-VM extraction matches CI. (Both the extract and the repack use `bsdtar`, so no\nseparate `tar` is needed on the host.)\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\nrm -rf iso-stage && mkdir iso-stage\nbsdtar -xf dfly.iso -C iso-stage usr/include\nbsdtar -cf dfly-include.tar -C iso-stage/usr include\nrm -rf iso-stage\n```\n\n### 4. Key + seed ISO + boot (daemonized, persistent)\n\nCreate the SSH key, a seed ISO with the key and a setup script, then boot with the ISO\nattached as a CD-ROM. The setup script on the ISO configures networking, sshd, and SSH\nkeys — the sendkey bootstrap only needs to type three short commands (login, mount, run\nscript) instead of piping long commands through the fragile VGA console.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; DFLY_VER=6.4.2; cd \"$VMDIR\"\ntest -f vm_key || ssh-keygen -t ed25519 -f vm_key -N \"\" -q\ncat > setup.sh <<'SETUP'\n#!/bin/sh\nset -e\nif pgrep -x sshd >/dev/null 2>&1; then\n  exit 0\nfi\ndhclient vtnet0\necho \"PermitRootLogin yes\" >> /etc/ssh/sshd_config\necho \"PermitEmptyPasswords yes\" >> /etc/ssh/sshd_config\nmkdir -p /root/.ssh && chmod 700 /root/.ssh\ncp /mnt/authorized_keys /root/.ssh/authorized_keys\nchmod 600 /root/.ssh/authorized_keys\nssh-keygen -A\n/usr/sbin/sshd\nSETUP\ncp vm_key.pub authorized_keys\ngenisoimage -output seed.iso -volid CIDATA -joliet -rock setup.sh authorized_keys\nqemu-system-x86_64 \\\n  -name dragonfly-${DFLY_VER} \\\n  -machine pc,accel=kvm:hvf -cpu host -smp 8 -m 12G \\\n  -drive file=dfly.qcow2,format=qcow2,if=virtio \\\n  -drive file=dfly-data.qcow2,format=qcow2,if=virtio \\\n  -drive file=seed.iso,media=cdrom \\\n  -netdev user,id=net0,hostfwd=tcp::2222-:22 \\\n  -device virtio-net-pci,netdev=net0 \\\n  -object rng-random,id=rng0,filename=/dev/urandom \\\n  -device virtio-rng-pci,rng=rng0 \\\n  -monitor unix:dfly-monitor.sock,server,nowait \\\n  -serial unix:dfly-serial.sock,server,nowait \\\n  -display none -pidfile dfly.pid -daemonize\n```\n\n(`accel=kvm:hvf` picks KVM on Linux or HVF on macOS and errors if neither is available —\nit never silently falls back to slow TCG. `-smp`/`-m` are speed knobs; CI uses 4 CPUs /\n12G. `hostfwd 2222->22` is the ssh port.)\n\n### 5. Run the console setup script\n\nThe CI console script takes periodic VGA screendumps to detect when boot finishes, then\ntypes three short commands via sendkey (login as root, mount the seed ISO, run the setup\nscript). It verifies SSH becomes reachable after each attempt, retrying if needed.\n\nThe script lives in the repo at `.ci-scripts/bsd/dfly_configure_vm.py`. Copy it into\n`$VMDIR` (it connects to `dfly-monitor.sock` in its working directory) and run it there.\nSet `DFLY_ARTIFACTS_DIR` to the VM directory so the script can write screendumps for boot\ndetection and diagnostics:\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\ncp \"$(git rev-parse --show-toplevel)/.ci-scripts/bsd/dfly_configure_vm.py\" \"$VMDIR/\"\nexport DFLY_ARTIFACTS_DIR=\"$VMDIR\"\npython3 dfly_configure_vm.py\n```\n\nA `monitor_cmd.py` helper is still useful for ad-hoc screendumps when debugging boot\nproblems. Create one if needed:\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\ncat > monitor_cmd.py <<'PY'\nimport socket, sys, time\ns = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.connect('dfly-monitor.sock')\ntime.sleep(0.4); s.recv(65536)\ns.sendall((sys.argv[1] + '\\n').encode()); time.sleep(0.5); s.settimeout(1.0)\ntry:\n    while True:\n        d = s.recv(65536)\n        if not d: break\n        sys.stdout.write(d.decode(errors='replace'))\nexcept socket.timeout: pass\ns.close()\nPY\nto_png() {  # convert PPM->PNG with whichever converter is installed\n  if   command -v magick   >/dev/null 2>&1; then magick \"$1\" \"$2\"\n  elif command -v convert  >/dev/null 2>&1; then convert \"$1\" \"$2\"\n  elif command -v ffmpeg   >/dev/null 2>&1; then ffmpeg -y -loglevel error -i \"$1\" \"$2\"\n  else pnmtopng \"$1\" > \"$2\"; fi\n}\npython3 monitor_cmd.py \"screendump $VMDIR/console1.ppm\"   # absolute path is required\nto_png \"$VMDIR/console1.ppm\" \"$VMDIR/console1.png\"\n```\n\n### 6. Wait for ssh\n\nStep 5 already verifies SSH is reachable, so this is a belt-and-suspenders check. If it\nfails, re-screendump to check the console state, and re-run Step 5.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\nup=\"\"\nfor i in $(seq 1 150); do   # ~5 min, matching CI's timeout\n  if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=2 -i vm_key -p 2222 \\\n       root@localhost true 2>/dev/null; then up=1; echo \"SSH up\"; break; fi\n  sleep 2\ndone\n[ -n \"$up\" ] || echo \"SSH NEVER CAME UP — check the console screendump (Step 5) and rerun if needed\"\n```\n\nFrom here, **always** drive the VM through `/bin/sh` (root's shell is csh — see Gotchas),\nand define `$SSH` in the same block you use it (it does not carry between blocks):\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nuname -a\nEOF\n```\n\n### 7. Format and mount the build disk\n\nThe root partition is only ~1.8G — not enough for the build or packages. Format the\nsecond disk and mount it at `/build`.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\nnewfs /dev/vbd1\nmkdir -p /build\nmount /dev/vbd1 /build\nEOF\n```\n\n### 8. Move `/usr/local` to the build disk, install headers, install deps\n\n`gcc13` alone is ~418M, so package installs must land on `/build`.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncpdup /usr/local /build/usr_local\nrm -rf /usr/local\nln -s /build/usr_local /usr/local\nEOF\n\nscp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i \"$VMDIR/vm_key\" -P 2222 \"$VMDIR/dfly-include.tar\" root@localhost:/build/\n$SSH /bin/sh <<'EOF'\nset -e\ntar xf /build/dfly-include.tar -C /build\nln -s /build/include /usr/include\nrm /build/dfly-include.tar\nEOF\n\n$SSH /bin/sh <<'EOF'\nset -e\npkg install -y cmake gmake git python3 cxx_atomics rsync gcc13 ca_root_nss\npkg clean -ay\ngit config --global --add safe.directory /build/ponyc\nEOF\n```\n\n### 9. Rsync the ponyc checkout in (exclude the host `build/`)\n\nRun from your ponyc checkout. Exclude the top-level `build/` (host artifacts; the VM\nbuilds its own). Keep `.git` (CMake runs `git rev-parse`). The vendored LLVM submodule\nunder `lib/llvm/src` IS transferred — the VM needs it for the libs build.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nrsync -az --exclude='/build' \\\n  -e \"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222\" \\\n  \"$(git rev-parse --show-toplevel)/\" root@localhost:/build/ponyc/\n```\n\n## Using the VM\n\nAlways set the gcc13 build env first (matches the tier-3 `dragonflybsd` job). `cmake -P lib/build-libs.cmake`\nbuilds the vendored LLVM and is the multi-hour long pole — run it detached, then poll the\nlog until it ends with `libs DONE rc=0` (a nonzero rc means it failed — read the log\nabove the marker):\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncat > /build/run-libs.sh <<'S'\n#!/bin/sh\nexport CC=/usr/local/bin/gcc13 CXX=/usr/local/bin/g++13\nexport LD_LIBRARY_PATH=/usr/local/lib/gcc13\nexport SSL_CERT_FILE=/usr/local/share/certs/ca-root-nss.crt\ncd /build/ponyc && cmake -DJOBS=4 -DCMAKE_C_COMPILER=/usr/local/bin/gcc13 -DCMAKE_CXX_COMPILER=/usr/local/bin/g++13 -P lib/build-libs.cmake\necho \"libs DONE rc=$?\"\nS\nchmod +x /build/run-libs.sh\nnohup /build/run-libs.sh > /build/libs-build.log 2>&1 &\necho \"libs building pid $!\"\nEOF\n# poll until done: $SSH /bin/sh -c 'tail -3 /build/libs-build.log'  -> wait for \"libs DONE rc=0\"\n```\n\nOnce `libs` is built (it persists in the VM), build and test with the same env:\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\nexport CC=/usr/local/bin/gcc13 CXX=/usr/local/bin/g++13\nexport LD_LIBRARY_PATH=/usr/local/lib/gcc13\nexport SSL_CERT_FILE=/usr/local/share/certs/ca-root-nss.crt\ncd /build/ponyc\ncmake --preset debug -DCMAKE_C_COMPILER=/usr/local/bin/gcc13 -DCMAKE_CXX_COMPILER=/usr/local/bin/g++13\ncmake --build --preset debug\nctest --preset debug -L ci-core\nEOF\n```\n\nTo iterate on a fix: edit on the host, re-run Step 9's `rsync` (it's incremental — only\nchanged files are sent), then re-run the build block above (long rebuilds should also be\ndetached + polled). For exact CI parity, the tier-3 `dragonflybsd` job\n(`.github/workflows/ponyc-tier3.yml`) runs more than this — the reject-unsupported-builds\ncheck, a `--static` link smoke, and the coverage smoke — so consult it when validating a\nCI-matching issue.\n\n## Lifecycle\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\npgrep -af \"qemu-system-x86_64 -name dragonfly\"        # status\nssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 root@localhost   # interactive shell\n# stop: DragonFly's default install has no ACPI shutdown handler, so system_powerdown\n# usually does nothing — kill the process instead. Disks persist for next time.\nkill \"$(cat dfly.pid)\" 2>/dev/null || pkill -f \"qemu-system-x86_64 -name dragonfly\"\n```\n\nTo restart later, re-run Steps 4–7 (the disks and installed deps persist; the key and ISO\ncreation are idempotent, and Steps 5–7 re-establish sshd and the `/build` mount, which\nare not persisted across a guest reboot). For a long-lived VM, make `sshd_enable`\npermanent in `/etc/rc.conf` and add the `/build` mount to `/etc/fstab`.\n\n## How this relates to CI (don't re-add the CI-only steps)\n\nThis mirrors `.ci-scripts/bsd/dragonfly-provision.bash` (the source of truth — keep this\nskill in sync if it changes) and **reuses `.ci-scripts/bsd/dfly_configure_vm.py`\nunchanged**. The local setup deliberately differs from CI, and each difference is safe:\n\n- Skips CI host-bootstrap (free disk space, install qemu, make `/dev/kvm` writable) —\n  your host already has qemu and a usable hardware accelerator (Step 0 verified it).\n- `bsdtar` instead of `sudo mount -o loop` for the ISO headers — no privilege needed.\n- Persistent VM instead of built-fresh-and-killed per run.\n- ssh adds `-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR` to quiet the host-key\n  warning: a persistent VM reused on `localhost:2222` collides with the stale\n  `known_hosts` entry from a prior VM. CI's ephemeral runners never reuse the port, so\n  `dragonfly-provision.bash` doesn't bother.\n- `-smp 8` for build speed (CI uses 4).\n- Both use the seed ISO + sendkey approach with screendump-based boot detection; locally\n  you can also use the manual screendump helper for ad-hoc debugging.\n- No GHCR libs cache (that's token-gated CI plumbing) — you just run `cmake -P lib/build-libs.cmake` once.\n\nThe FreeBSD and OpenBSD CI VMs follow the same shape\n(`.ci-scripts/bsd/{freebsd,openbsd}-provision.bash`) and each has its own skill:\n`create-freebsd-dev-env` (takes a `FREEBSD_VERSION`) and `create-openbsd-dev-env` (its\nstatic-link checks expect static-PIE, and it rejects the unsupported `use=` options). This\nskill is DragonFly-only; load a sibling if you need one of those.\n","frontmatter":{"name":"create-dragonfly-dev-env","description":"Load when you need to reproduce or validate a gcc-specific or DragonFly-specific ponyc issue on a local VM, or to stand up a local DragonFly BSD development VM that matches ponyc tier-3 CI. Covers the prerequisite check, the sudo-free QEMU/KVM setup, the reused console automation, the in-VM gcc13 build env, and the gotchas (csh, screendump paths, boot timing, detached builds, per-block shells) that make hand-rolling one error-prone.","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":5811,"sizeBytes":19394},{"name":"SKILL.md","path":".claude/skills/create-freebsd-dev-env/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/.claude/skills/create-freebsd-dev-env/SKILL.md","title":"create-freebsd-dev-env","category":"anthropic-skill","format":"markdown","content":"---\nname: create-freebsd-dev-env\ndescription: Load when you need to reproduce or validate a FreeBSD-specific ponyc issue on a local VM, or to stand up a local FreeBSD development VM that matches ponyc tier-3 CI. Covers the cloud-init seed, the sudo-free QEMU/KVM boot, the one-time expect/su root bootstrap, the clang build env, and the gotchas (freebsd-user login, no bash in base, growfs auto-resize, detached builds, per-block shells) that make hand-rolling one error-prone.\ndisable-model-invocation: false\n---\n\n# Create a FreeBSD dev/test VM for ponyc\n\nStand up a local FreeBSD VM that matches the ponyc tier-3 CI job, so you can build\nand test ponyc on FreeBSD without GitHub Actions.\n\nFreeBSD's tier-3 job exercises paths the clang-on-Linux jobs never do: the embedded-LLD\n`--static` link recipe, the native sanitizer link path, and FreeBSD's base\nlibc++/libunwind. So this VM is the authoritative local\nenvironment for **FreeBSD-platform** ponyc issues. (It is clang-based — FreeBSD's base\ncompiler is clang, not gcc. For **gcc-specific** issues use the DragonFly skill instead.)\n\nRun the repo-relative commands (`rsync` of the source) from your ponyc checkout root.\nThe VM itself lives in a separate directory (`$VMDIR`).\n\n**Each fenced `sh` block below is self-contained.** It re-sets `VMDIR`/`FREEBSD_VERSION`\n(and, where needed, the `SSH` command) at its top, because an agent runs each block as a\nfresh shell — environment variables and the working directory do NOT carry from one\nblock to the next. Do not \"optimize\" by setting them once and dropping them from later\nblocks: an empty `$SSH` would make a `$SSH /bin/sh <<'EOF' ... EOF` heredoc run **on your\nhost** (silently) instead of in the VM. Keep every block whole. Pick your `VMDIR`/\n`FREEBSD_VERSION` once and use the same values in every block.\n\n## Gotchas that will bite you (read first)\n\nFreeBSD is much simpler to bring up than DragonFly (cloud-init injects the ssh key, so\nthere is no VGA-console automation), but these still matter:\n\n- **Per-block fresh shells.** See above: re-establish `VMDIR`/`FREEBSD_VERSION`/`SSH` in\n  every block; never let `$SSH` be empty in front of a `/bin/sh` heredoc.\n- **You log in as `freebsd`, not `root`.** The cloud-init seed installs your key for the\n  `freebsd` user and sets root's password to `ciroot`. Build and test run **as\n  `freebsd`** (no root needed). Root is only needed twice during setup — the one-time\n  `expect`/`su` bootstrap below installs the packages and configures passwordless `doas`,\n  after which the rare root operation goes through `doas`.\n- **Cloud-init means no console driving.** Unlike the DragonFly skill there is no QEMU\n  `sendkey`/screendump/monitor dance — just boot and wait for ssh. But the **first** boot\n  runs `nuageinit` (key install) and `growfs` (root-fs auto-resize), so ssh is not\n  instant; poll for it (Step 4).\n- **The root fs auto-grows; there is no growfs command to run.** `qemu-img resize ... 60G`\n  enlarges the disk and the image's first boot grows the root fs to fill it. You do not\n  run `growfs` yourself — the wait-for-ssh loop is what accounts for it.\n- **FreeBSD's base system has no `bash`.** In-VM scripts must be POSIX `sh` (the two CI\n  smoke scripts already are). Don't reach for bashisms in a `$SSH /bin/sh` heredoc.\n- **Detach long in-VM builds.** The libs build (LLVM) takes hours; run it\n  `nohup … > /…/x.log 2>&1 &` and poll the log, so an ssh drop doesn't kill it.\n- **The one-time root bootstrap needs `expect`.** FreeBSD's `nuageinit` supports only\n  `ssh_authorized_keys` + `chpasswd` (not cloud-init's `write_files`/`runcmd`), so the\n  package install and `doas.conf` setup are driven through `su -m root` with `expect`,\n  exactly as CI does it. After that, `doas` is on disk and persists across reboots.\n\n## Step 0 — verify prerequisites (do NOT assume they're installed)\n\nThis skill needs, on the host:\n\n- an existing ponyc checkout (you run the `rsync` from it)\n- hardware-accelerated virtualization for QEMU (KVM on Linux, HVF on macOS)\n- enough free disk for the VM's sparse qcow2 disk (provisioned at 60 GB nominal; it grows\n  only as used — the libs build plus a debug build take the qcow2 to ~12 GB, and a release\n  build on top adds more), ~1 GB for the compressed image, and network access to\n  download.freebsd.org\n- `qemu-system-x86_64` and `qemu-img`\n- `cloud-localds` (from cloud-image-utils) or `genisoimage` to build the cloud-init\n  seed image\n- `expect` (drives the one-time `su` root bootstrap)\n- `xz`, `rsync`, `git`, `curl`, `python3`\n- an OpenSSH client (`ssh`, `scp`, `ssh-keygen`)\n\nNote this list is shorter than the DragonFly skill's: FreeBSD needs no `bsdtar`/`bunzip2`\n(no ISO header mining) and no PPM-to-PNG converter (no console screendumps).\n\nCheck that every required tool is present. Do not assume the environment has them, and do\nnot install anything yourself — installing them is the user's call (it needs privilege,\nand the package names vary by OS). If anything is missing, **stop, tell the user which\ntools/capabilities are missing, and ask them to install them with their platform's\npackage manager**, then re-run the check before proceeding.\n\n```sh\nmissing=\"\"\nfor t in qemu-system-x86_64 qemu-img expect xz rsync git ssh scp ssh-keygen curl python3; do\n  command -v \"$t\" >/dev/null 2>&1 || missing=\"$missing $t\"\ndone\ncommand -v cloud-localds >/dev/null 2>&1 || command -v genisoimage >/dev/null 2>&1 \\\n  || missing=\"$missing seed-builder(cloud-localds|genisoimage)\"\n# Hardware acceleration is host-specific — check the right accelerator for this OS.\ncase \"$(uname -s)\" in\n  Linux)  [ -w /dev/kvm ] || missing=\"$missing kvm(/dev/kvm-not-writable)\" ;;\n  Darwin) qemu-system-x86_64 -accel help 2>&1 | grep -q hvf || missing=\"$missing hvf-accelerator\" ;;\n  *)      echo \"NOTE: confirm this host has a QEMU hardware accelerator and adjust the boot accel= below\" ;;\nesac\n[ -n \"$missing\" ] && echo \"MISSING:$missing\" || echo \"all prerequisites present\"\n```\n\nIf the output is anything but `all prerequisites present`, name the missing\ntools/capabilities to the user and ask them to install them however their OS does — do\nNOT guess a distro package name. (`cloud-localds` ships in `cloud-image-utils` on\nDebian/Ubuntu; if it is unavailable, the cidata seed can also be built with\n`genisoimage -output seed.img -volid cidata -joliet -rock user-data meta-data`, which is\nwhat the OpenBSD CI path uses.) A missing hardware accelerator — no writable `/dev/kvm`\non Linux, no HVF on macOS — is a host/BIOS/virtualization fix only the user can make; the\nVM is unusably slow without it, so don't fall back to TCG emulation.\n\nThis flow is verified on Linux+KVM through the libs build and a debug build. The guest side\nis OS-agnostic, so macOS goes through the same steps with the HVF accelerator\n(`accel=kvm:hvf` in Step 3 selects it).\n\n## Setup\n\nPick a persistent VM directory and a FreeBSD version that **matches ponyc CI** (the tier-3\n`freebsd` job in `.github/workflows/ponyc-tier3.yml` tests a single version — currently\n15.1; the image URL is in `.ci-scripts/bsd/freebsd-provision.bash`). Use the same value\nin every block below.\n\n### 1. Download the image (one time; keep the `.xz` to avoid re-downloading)\n\nThe BASIC-CLOUDINIT image boots straight to a cloud-init-configured system — no installer,\nand it ships the full base system including `/usr/include`.\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1\nmkdir -p \"$VMDIR\" && cd \"$VMDIR\"\nbase=\"https://download.freebsd.org/releases/VM-IMAGES/${FREEBSD_VERSION}-RELEASE/amd64/Latest\"\ncurl -fL --retry 3 -o freebsd.qcow2.xz \\\n  \"$base/FreeBSD-${FREEBSD_VERSION}-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz\"\nxz -dk freebsd.qcow2.xz                 # -k keeps the .xz original\nqemu-img resize freebsd.qcow2 60G       # first boot grows the root fs to fill this\n```\n\n### 2. Key + cloud-init seed\n\n`nuageinit` reads a cidata seed image at boot: it installs your ssh public key for the\n`freebsd` user and sets root's password (so the Step 5 `su` bootstrap can reach root).\n\n```sh\nVMDIR=~/vms/freebsd-15.1; cd \"$VMDIR\"\ntest -f vm_key || ssh-keygen -t ed25519 -f vm_key -N \"\" -q\ncat > user-data <<USERDATA\n#cloud-config\nssh_authorized_keys:\n  - $(cat vm_key.pub)\nchpasswd:\n  expire: false\n  list:\n    - root:ciroot\nUSERDATA\ncat > meta-data <<'METADATA'\ninstance-id: freebsd-dev\nlocal-hostname: freebsd-dev\nMETADATA\ncloud-localds seed.img user-data meta-data\n```\n\n### 3. Boot (daemonized, persistent)\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1; cd \"$VMDIR\"\nqemu-system-x86_64 \\\n  -name freebsd-${FREEBSD_VERSION} \\\n  -machine pc,accel=kvm:hvf -cpu host -smp 8 -m 12G \\\n  -drive file=freebsd.qcow2,format=qcow2,if=virtio \\\n  -drive file=seed.img,format=raw,if=virtio \\\n  -netdev user,id=net0,hostfwd=tcp::2222-:22 \\\n  -device virtio-net-pci,netdev=net0 \\\n  -display none -pidfile freebsd.pid -daemonize\n```\n\n(`accel=kvm:hvf` picks KVM on Linux or HVF on macOS and errors if neither is available —\nit never silently falls back to slow TCG. `-smp`/`-m` are speed knobs; CI uses 4 CPUs /\n12G. `hostfwd 2222->22` is the ssh port — to run two FreeBSD VMs at once (e.g. CI's 15.1\nplus another version you want to compare against), give each its own `$VMDIR` and a\ndistinct hostfwd port, and pass that port to every ssh/scp/rsync `-p`. The seed drive stays attached — `nuageinit` skips it on\nlater boots once the instance-id is seen. This boot command is adapted from\n`.ci-scripts/bsd/freebsd-provision.bash`: it adds `-pidfile`/`-smp 8` and drops the CI\nhost-bootstrap; CI has no rng or monitor device for FreeBSD and neither does this.)\n\n### 4. Wait for ssh (first boot runs nuageinit + growfs)\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1; cd \"$VMDIR\"\nup=\"\"\nfor i in $(seq 1 150); do   # ~5 min, matching CI's timeout\n  if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=2 -i vm_key -p 2222 \\\n       freebsd@localhost true 2>/dev/null; then up=1; echo \"SSH up\"; break; fi\n  sleep 2\ndone\n[ -n \"$up\" ] || echo \"SSH NEVER CAME UP — see recovery below.\"\n```\n\nIf ssh never comes up, this VM has no VGA console to look at (it booted headless), so\ndiagnose in this order:\n\n- **Is the process alive?** `pgrep -af \"qemu-system-x86_64 -name freebsd-${FREEBSD_VERSION}\"`.\n  If it's gone, the boot died early (usually a bad/incomplete image from Step 1) — re-check\n  the Step 1 download/resize and re-boot.\n- **Watch the boot.** The image logs to the serial console, so re-boot with a serial log\n  added — `kill \"$(cat freebsd.pid)\"`, then re-run the Step 3 boot command with\n  `-serial file:$VMDIR/console.log` appended — and read `console.log` to see how far it got.\n- **ssh actively *refuses* the key (vs. just timing out)?** That means `nuageinit` never\n  installed it — the seed didn't apply. Confirm `seed.img` is attached as a drive in the\n  boot command (Step 3) and was built in Step 2, then re-boot.\n\n### 5. One-time root bootstrap (install deps + configure doas) via expect\n\nFreeBSD's `nuageinit` can't run `write_files`/`runcmd`, so — exactly as CI does — drive\n`su -m root` with `expect` to install the build dependencies and grant the `freebsd` user\npasswordless `doas`. This runs once; `doas.conf` persists on disk for the VM's life. (The\nexpect block is adapted from the one embedded in `freebsd-provision.bash`.)\n\n```sh\nVMDIR=~/vms/freebsd-15.1; cd \"$VMDIR\"\nexpect <<'EXPECT'\nset timeout 600\nspawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 -t freebsd@localhost su -m root\nexpect {\n  \"Password:\" { send \"ciroot\\r\" }\n  timeout { puts \"Timeout waiting for password prompt\"; exit 1 }\n}\nexpect {\n  \"su: Sorry\" { puts \"su failed - wrong password or nuageinit didn't set it\"; exit 1 }\n  \"#\" {}\n  timeout { puts \"Timeout waiting for root shell\"; exit 1 }\n}\nsend \"pkg install -y cmake gmake libunwind git python3 rsync doas\\r\"\nexpect {\n  \"#\" {}\n  timeout { puts \"Timeout during pkg install\"; exit 1 }\n}\nsend \"echo 'permit nopass freebsd' > /usr/local/etc/doas.conf\\r\"\nexpect {\n  \"#\" {}\n  timeout { puts \"Timeout writing doas.conf\"; exit 1 }\n}\nsend \"exit\\r\"\nexpect eof\nEXPECT\n```\n\nIf `expect` prints one of its `Timeout …` messages, it's safe to re-run this block — the\n`pkg install -y` and the clobbering `>` write to `doas.conf` are both idempotent. The one\nexception is `su failed`: that means the seed never set root's password, so root was never\nreachable. Re-running won't fix it — that's a boot/seed problem, so work through the Step 4\nrecovery (is the VM up? did `seed.img` apply?) first.\n\n### 6. Rsync the ponyc checkout in (exclude the host `build/`)\n\nRun from your ponyc checkout. Exclude the top-level `build/` (host artifacts; the VM\nbuilds its own). Keep `.git` (CMake runs `git rev-parse`). The vendored LLVM submodule\nunder `lib/llvm/src` IS transferred — the VM needs it for the libs build.\n\n```sh\nVMDIR=~/vms/freebsd-15.1\nrsync -az --exclude='/build' \\\n  -e \"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222\" \\\n  \"$(git rev-parse --show-toplevel)/\" freebsd@localhost:/home/freebsd/ponyc/\n```\n\n## Using the VM\n\nThe build uses FreeBSD's default clang — no `CC`/`CXX`/`LD_LIBRARY_PATH` exports (that is\nthe DragonFly-only gcc13 dance). `cmake -P lib/build-libs.cmake` builds the vendored LLVM and is the multi-hour\nlong pole — run it detached, then poll the log until it ends with `libs DONE rc=0` (a\nnonzero rc means it failed — read the log above the marker):\n\n```sh\nVMDIR=~/vms/freebsd-15.1\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 freebsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncat > /home/freebsd/run-libs.sh <<'S'\n#!/bin/sh\ncd /home/freebsd/ponyc && cmake -DJOBS=4 -P lib/build-libs.cmake\necho \"libs DONE rc=$?\"\nS\nchmod +x /home/freebsd/run-libs.sh\nnohup /home/freebsd/run-libs.sh > /home/freebsd/libs-build.log 2>&1 &\necho \"libs building pid $!\"\nEOF\n# poll until done: $SSH /bin/sh -c 'tail -3 /home/freebsd/libs-build.log'  -> wait for \"libs DONE rc=0\"\n```\n\nOnce `libs` is built (it persists in the VM), build and test with the same shell. Do not\nrun this block until the poll shows `libs DONE rc=0` — `cmake --build` against a half-built\n`build/libs` fails:\n\n```sh\nVMDIR=~/vms/freebsd-15.1\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 freebsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncd /home/freebsd/ponyc\ncmake --preset debug\ncmake --build --preset debug\nctest --preset debug -L ci-core\nEOF\n```\n\nTo iterate on a fix: edit on the host, re-run Step 6's `rsync` (it's incremental — only\nchanged files are sent), then re-run the build block above (long rebuilds should also be\ndetached + polled). For exact CI parity, the tier-3 `freebsd` job\n(`.github/workflows/ponyc-tier3.yml`) runs more than this — a `--static` embedded-LLD link\nsmoke, the self-hosted tool tests (`pony-doc-tests`/`pony-lint-tests`/`pony-lsp-tests`), a\nrelease build + the `ci-core` suite, and two extra smokes:\n`.ci-scripts/freebsd-sanitizer-smoke.sh`. Consult that job when validating a\nCI-matching issue.\n\n## Lifecycle\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1; cd \"$VMDIR\"\npgrep -af \"qemu-system-x86_64 -name freebsd-${FREEBSD_VERSION}\"        # status\nssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 freebsd@localhost   # interactive shell\n# stop: CI stops this VM by killing the process; do the same. Disks persist for next time.\n# The pgrep/pkill are scoped to this version so a second FreeBSD VM (e.g. another\n# version you're comparing against) isn't matched; the pidfile path is per-$VMDIR anyway.\nkill \"$(cat freebsd.pid)\" 2>/dev/null || pkill -f \"qemu-system-x86_64 -name freebsd-${FREEBSD_VERSION}\"\n```\n\nTo restart later, re-run the step-3 boot command (the disk and installed deps persist; you\ndo not redo setup). On a reboot, ssh-as-`freebsd` works immediately (the key was written\nto disk on first boot) and `doas` still works (`doas.conf` persisted) — there is no\nre-bootstrap, unlike the DragonFly VM.\n\n## How this relates to CI (don't re-add the CI-only steps)\n\nThis mirrors `.ci-scripts/bsd/freebsd-provision.bash` (the source of truth — keep this\nskill in sync if it changes). The local setup deliberately differs from CI, and each\ndifference is safe:\n\n- Skips CI host-bootstrap (free disk space, `apt-get` qemu, make `/dev/kvm` writable) —\n  your host already has qemu and a usable hardware accelerator (Step 0 verified it).\n- Persistent VM instead of built-fresh-and-killed per run.\n- ssh adds `-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR` to quiet the host-key\n  warning: a persistent VM reused on `localhost:2222` collides with the stale\n  `known_hosts` entry from a prior VM. CI's ephemeral runners never reuse the port, so\n  `freebsd-provision.bash` doesn't bother.\n- `-smp 8` for build speed (CI uses 4), and a `-pidfile` for the lifecycle commands.\n- No GHCR libs cache (that's token-gated CI plumbing) — you just run `cmake -P lib/build-libs.cmake` once.\n\nThe DragonFly and OpenBSD CI VMs follow the same shape\n(`.ci-scripts/bsd/{dragonfly,openbsd}-provision.bash`) and each has its own skill:\n`create-dragonfly-dev-env` (gcc-only, and it drives a VGA console) and\n`create-openbsd-dev-env` (also cloud-init, but with static-PIE static-link checks, a raised\n`datasize` limit, and the source under a `/build` disk). This skill is FreeBSD-only; load a\nsibling if you need one of those.\n","frontmatter":{"name":"create-freebsd-dev-env","description":"Load when you need to reproduce or validate a FreeBSD-specific ponyc issue on a local VM, or to stand up a local FreeBSD development VM that matches ponyc tier-3 CI. Covers the cloud-init seed, the sudo-free QEMU/KVM boot, the one-time expect/su root bootstrap, the clang build env, and the gotchas (freebsd-user login, no bash in base, growfs auto-resize, detached builds, per-block shells) that make hand-rolling one error-prone.","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":5105,"sizeBytes":17634},{"name":"SKILL.md","path":".claude/skills/create-openbsd-dev-env/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/.claude/skills/create-openbsd-dev-env/SKILL.md","title":"create-openbsd-dev-env","category":"anthropic-skill","format":"markdown","content":"---\nname: create-openbsd-dev-env\ndescription: Load when you need to reproduce or validate an OpenBSD-specific ponyc issue on a local VM, or to stand up a local OpenBSD development VM that matches ponyc tier-3 CI. Covers the cloud-init seed (cdrom), the sudo-free QEMU/KVM boot, the doas-via-cloud-init setup (no expect bootstrap), the dedicated /build data disk, the datasize limit, the clang build env, and the gotchas (openbsd-user login, disklabel build disk, raised datasize, static-PIE static linking, pkg_add naming, detached builds, per-block shells) that make hand-rolling one error-prone.\ndisable-model-invocation: false\n---\n\n# Create an OpenBSD dev/test VM for ponyc\n\nStand up a local OpenBSD VM that matches the ponyc tier-3 CI job, so you can build\nand test ponyc on OpenBSD without GitHub Actions.\n\nOpenBSD's tier-3 job exercises paths the Linux jobs never do: the static-PIE `--static`\nembedded-LLD link recipe, OpenBSD's base clang/libc++, and the per-process `datasize`\nlimit that the LLVM build pushes against. It also asserts the `use=` build options that\ncan't work there are rejected. So this VM is the authoritative local environment for\n**OpenBSD-platform** ponyc issues. (It is clang-based — OpenBSD's base compiler is clang,\nnot gcc. For **gcc-specific** issues use the DragonFly skill instead.)\n\nRun the repo-relative commands (`rsync` of the source) from your ponyc checkout root. The\nVM itself lives in a separate directory (`$VMDIR`).\n\n**Each fenced `sh` block below is self-contained.** It re-sets `VMDIR`/`OPENBSD_VERSION`\n(and, where needed, the `SSH` command) at its top, because an agent runs each block as a\nfresh shell — environment variables and the working directory do NOT carry from one block\nto the next. Do not \"optimize\" by setting them once and dropping them from later blocks:\nan empty `$SSH` would make a `$SSH /bin/sh <<'EOF' ... EOF` heredoc run **on your host**\n(silently) instead of in the VM — and Step 5 contains `newfs` and Step 6 edits\n`/etc/login.conf`. Keep every block whole. Pick your `VMDIR`/`OPENBSD_VERSION` once and use\nthe same values in every block.\n\n## Gotchas that will bite you (read first)\n\nOpenBSD is a hybrid of the other two BSD skills: like FreeBSD it boots from a cloud-init\nseed and is driven entirely over ssh (no VGA-console automation), but like DragonFly the\nbuild lives on a dedicated data disk. These matter:\n\n- **Per-block fresh shells.** See above: re-establish `VMDIR`/`OPENBSD_VERSION`/`SSH` in\n  every block; never let `$SSH` be empty in front of a `/bin/sh` heredoc.\n- **You log in as `openbsd`, not `root`.** The cloud-init seed creates the `openbsd` user\n  in the `wheel` group and writes `/etc/doas.conf` (`permit nopass keepenv :wheel`). Build\n  and test run **as `openbsd`**; the few root operations (disk setup, package install,\n  editing `login.conf`) go through `doas`, which is configured before first login and\n  persists on disk. There is **no** `expect`/`su` bootstrap — OpenBSD's cloud-init runs\n  `write_files`, so `doas` is in place from the first boot (simpler than FreeBSD).\n- **`doas` is in the base system.** Nothing to install; cloud-init just drops\n  `/etc/doas.conf`. Keep its `0600 root:wheel` permissions exactly — `doas` refuses to run\n  with a group- or world-writable config.\n- **Cloud-init means no console driving.** Unlike the DragonFly skill there is no QEMU\n  `sendkey`/screendump/monitor dance — just boot and wait for ssh. But the **first** boot\n  runs cloud-init (user + key + `doas.conf`), so ssh is not instant; poll for it (Step 4).\n- **Build on the `/build` data disk, not `/home`.** OpenBSD's default disklabel confines\n  writable space to `/home` (~10.8G), which the LLVM 22 build overflows. A second qcow2 is\n  partitioned, `newfs`'d, and mounted at `/build`, and the source plus the build live\n  there — mirroring the DragonFly job. The disklabel setup is **one-time and not\n  idempotent** (`newfs` reformats), so run Step 5 exactly once.\n- **The `/build` mount is not persisted across reboot.** `disklabel`/`newfs`/`mount` are\n  manual one-time steps with no `/etc/fstab` entry; after a guest reboot, remount\n  `/dev/sd1a` at `/build`. `/etc/login.conf` and `/etc/doas.conf` are on the root disk and\n  **do** persist.\n- **Raise the `datasize` limit.** OpenBSD caps a process's data segment in\n  `/etc/login.conf` (1536M on the stock 7.9 image); the LLVM build needs more, so Step 6\n  raises `datasize-max`/`-cur` to at least 8192M. Without it the libs build dies with\n  allocation failures partway through. The edit is on the root disk and persists.\n- **OpenBSD's base system has no `bash`.** `/bin/sh` is a POSIX `ksh`; in-VM scripts must\n  be POSIX `sh`. Don't reach for bashisms in a `$SSH /bin/sh` heredoc.\n- **Two `pkg_add` names look like typos but aren't:** `python%3` (the `%3` selects the 3.x\n  branch) and `rsync--` (the trailing `--` picks the no-flavor build). Copy them verbatim.\n- **Detach long in-VM builds.** The libs build (LLVM) takes hours; run it\n  `nohup … > /build/x.log 2>&1 &` and poll the log, so an ssh drop doesn't kill it.\n\n## Step 0 — verify prerequisites (do NOT assume they're installed)\n\nThis skill needs, on the host:\n\n- an existing ponyc checkout (you run the `rsync` from it)\n- hardware-accelerated virtualization for QEMU (KVM on Linux, HVF on macOS)\n- enough free disk for the VM's two sparse qcow2 disks (the shipped root image plus a 50 GB\n  nominal data disk; they grow only as used — the libs build plus a debug build take the data\n  disk to several GB), network access to github.com (the image is a GitHub release asset)\n- `qemu-system-x86_64` and `qemu-img`\n- `genisoimage` (builds the cidata seed CD-ROM; `cloud-localds` can also produce an\n  equivalent cidata image)\n- `rsync`, `git`, `curl`\n- an OpenSSH client (`ssh`, `scp`, `ssh-keygen`)\n\nNote this is the shortest prerequisite list of the three BSD skills: OpenBSD needs no\n`expect` (cloud-init configures `doas` directly), no `xz` (the image ships as a plain\nqcow2 — no decompress, no root-fs resize), and no `bsdtar`/PPM-converter (it is headless\nwith no ISO header mining and no console screendumps).\n\nCheck that every required tool is present. Do not assume the environment has them, and do\nnot install anything yourself — installing them is the user's call (it needs privilege, and\nthe package names vary by OS). If anything is missing, **stop, tell the user which\ntools/capabilities are missing, and ask them to install them with their platform's package\nmanager**, then re-run the check before proceeding.\n\n```sh\nmissing=\"\"\nfor t in qemu-system-x86_64 qemu-img rsync git ssh scp ssh-keygen curl; do\n  command -v \"$t\" >/dev/null 2>&1 || missing=\"$missing $t\"\ndone\ncommand -v genisoimage >/dev/null 2>&1 || command -v cloud-localds >/dev/null 2>&1 \\\n  || missing=\"$missing seed-builder(genisoimage|cloud-localds)\"\n# Hardware acceleration is host-specific — check the right accelerator for this OS.\ncase \"$(uname -s)\" in\n  Linux)  [ -w /dev/kvm ] || missing=\"$missing kvm(/dev/kvm-not-writable)\" ;;\n  Darwin) qemu-system-x86_64 -accel help 2>&1 | grep -q hvf || missing=\"$missing hvf-accelerator\" ;;\n  *)      echo \"NOTE: confirm this host has a QEMU hardware accelerator and adjust the boot accel= below\" ;;\nesac\n[ -n \"$missing\" ] && echo \"MISSING:$missing\" || echo \"all prerequisites present\"\n```\n\nIf the output is anything but `all prerequisites present`, name the missing\ntools/capabilities to the user and ask them to install them however their OS does — do NOT\nguess a distro package name. (`genisoimage` ships in `genisoimage` or `cdrkit` on\nDebian/Ubuntu; CI uses `genisoimage`. If only `cloud-localds` is available it can build the\nsame cidata image — attach it as the cdrom in Step 3.) A missing hardware accelerator — no\nwritable `/dev/kvm` on Linux, no HVF on macOS — is a host/BIOS/virtualization fix only the\nuser can make; the VM is unusably slow without it, so don't fall back to TCG emulation.\n\nThis flow is verified on Linux+KVM through VM bring-up, the `/build` disk plus\n`doas`/`datasize` setup, dependency install, the source rsync, and the unsupported-build\nrejection smoke (`.ci-scripts/openbsd-reject-unsupported-builds.sh`, which runs `cmake …\n-DPONY_USES=…` and so needs no LLVM). The LLVM build (`cmake -P lib/build-libs.cmake`), the subsequent\n`cmake --preset debug`/build — which require the built LLVM (CMake's `find_package(LLVM)`\nfails without it) — and the test suites were **not** run locally; those steps mirror\n`.ci-scripts/bsd/openbsd-provision.bash` and the tier-3 `openbsd` job, which CI exercises\nend-to-end and keeps green. The guest side is OS-agnostic, so macOS goes through the same\nsteps with the HVF accelerator (`accel=kvm:hvf` in Step 3 selects it).\n\n## Setup\n\nPick a persistent VM directory and an OpenBSD version that **matches ponyc CI** (the tier-3\n`openbsd` job in `.github/workflows/ponyc-tier3.yml` runs OpenBSD 7.9; the exact image URL,\nincluding the dated build tag, is in `.ci-scripts/bsd/openbsd-provision.bash`). Use the same\ntwo values in every block below.\n\n### 1. Download the image + create the build disk (one time)\n\nOpenBSD ships no official cloud image, so CI uses the community\n`hcartiaux/openbsd-cloud-image` GitHub release — a plain qcow2 that boots straight to a\ncloud-init-configured system. Pin the **full dated release tag** that\n`openbsd-provision.bash` references (a bare `v7.9` is not a valid asset path). The root\nimage is left at its shipped size; the build does not live on it.\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9\nmkdir -p \"$VMDIR\" && cd \"$VMDIR\"\ncurl -fL --retry 3 -o openbsd.qcow2 \\\n  \"https://github.com/hcartiaux/openbsd-cloud-image/releases/download/v7.9_2026-06-03-20-46/openbsd-generic.qcow2\"\n# Build-workspace disk. OpenBSD's default disklabel confines builds to /home\n# (~10.8G), which the LLVM 22 build overflows, so build on a dedicated disk.\nqemu-img create -f qcow2 openbsd-data.qcow2 50G\n```\n\n### 2. Key + cloud-init seed\n\nOpenBSD's cloud-init reads a cidata seed at boot: it creates the `openbsd` user (in\n`wheel`) with your ssh public key, and writes `/etc/doas.conf` so the `openbsd` user gets\npasswordless `doas`. Build the seed as an ISO and attach it as a CD-ROM (Step 3).\n\n```sh\nVMDIR=~/vms/openbsd-7.9; cd \"$VMDIR\"\ntest -f vm_key || ssh-keygen -t ed25519 -f vm_key -N \"\" -q\ncat > user-data <<USERDATA\n#cloud-config\nusers:\n- name: openbsd\n  groups: wheel\n  ssh_authorized_keys:\n  - $(cat vm_key.pub)\nwrite_files:\n- path: /etc/doas.conf\n  content: |\n    permit nopass keepenv :wheel\n  owner: root:wheel\n  permissions: '0600'\nUSERDATA\ncat > meta-data <<'METADATA'\ninstance-id: openbsd-dev\nlocal-hostname: openbsd-dev\nMETADATA\ngenisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data\n```\n\n### 3. Boot (daemonized, persistent)\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9; cd \"$VMDIR\"\nqemu-system-x86_64 \\\n  -name openbsd-${OPENBSD_VERSION} \\\n  -machine pc,accel=kvm:hvf -cpu host -smp 8 -m 12G \\\n  -drive file=openbsd.qcow2,format=qcow2,if=virtio \\\n  -drive file=openbsd-data.qcow2,format=qcow2,if=virtio \\\n  -drive file=seed.iso,media=cdrom \\\n  -netdev user,id=net0,hostfwd=tcp::2222-:22 \\\n  -device virtio-net-pci,netdev=net0 \\\n  -display none -pidfile openbsd.pid -daemonize\n```\n\n(`accel=kvm:hvf` picks KVM on Linux or HVF on macOS and errors if neither is available — it\nnever silently falls back to slow TCG; CI uses bare `accel=kvm` because it always runs on\nLinux. `-smp`/`-m` are speed knobs; CI uses 4 CPUs / 12G. The build is capped by the 8192M\n`datasize` limit per process, not by total RAM. `hostfwd 2222->22` is the ssh port — to run\ntwo OpenBSD VMs at once, give each its own `$VMDIR` and a distinct hostfwd port, and pass\nthat port to every ssh/scp/rsync `-p`. The seed is attached as a **cdrom** — that is how\nOpenBSD's cloud-init finds the cidata volume; do not copy FreeBSD's raw-virtio-disk seed\nform here. This boot command is adapted from `.ci-scripts/bsd/openbsd-provision.bash`: it\nadds `-pidfile`/`-smp 8`, drops the CI host-bootstrap, and like CI uses no rng or monitor\ndevice.)\n\n### 4. Wait for ssh (first boot runs cloud-init)\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9; cd \"$VMDIR\"\nup=\"\"\nfor i in $(seq 1 150); do   # ~5 min, matching CI's timeout\n  if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=2 -i vm_key -p 2222 \\\n       openbsd@localhost true 2>/dev/null; then up=1; echo \"SSH up\"; break; fi\n  sleep 2\ndone\n[ -n \"$up\" ] || echo \"SSH NEVER CAME UP — see recovery below.\"\n```\n\nIf ssh never comes up, this VM has no VGA console to look at (it booted headless), so\ndiagnose in this order:\n\n- **Is the process alive?** `pgrep -af \"qemu-system-x86_64 -name openbsd-${OPENBSD_VERSION}\"`.\n  If it's gone, the boot died early (usually a bad/incomplete image from Step 1) — re-check\n  the Step 1 download and re-boot.\n- **Watch the boot.** The image logs to the serial console, so re-boot with a serial log\n  added — `kill \"$(cat openbsd.pid)\"`, then re-run the Step 3 boot command with\n  `-serial file:$VMDIR/console.log` appended — and read `console.log` to see how far it got.\n- **ssh actively *refuses* the key (vs. just timing out)?** That means cloud-init never\n  applied the seed. Confirm `seed.iso` is attached as a **cdrom** drive in the boot command\n  (Step 3) and was built in Step 2, then re-boot.\n\n### 5. One-time build-disk setup (disklabel, newfs, mount)\n\nOpenBSD sees the second virtio disk as `sd1`. Give it a single 4.2BSD partition spanning\nthe disk, `newfs` it, mount it at `/build`, and `chown` it to `openbsd` so the later rsync\nand build run without root. The `printf` drives the interactive `disklabel -E` editor (`a a`\nadd partition, then default offset/size/type, `w` write, `q` quit) — it is fiddly and copied\nverbatim from CI. **Run this once:** `newfs` reformats, so re-running destroys whatever is\non `/build`.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\nprintf 'a a\\n\\n\\n\\nw\\nq\\n' | doas disklabel -E sd1\ndoas newfs /dev/rsd1a\ndoas mkdir -p /build\ndoas mount /dev/sd1a /build\ndoas chown openbsd:wheel /build\ndf -h /build\nEOF\n```\n\nIf the `doas` commands here fail (permission denied, or `doas` prompts for a password),\ncloud-init's `write_files` never created `/etc/doas.conf` — the seed didn't apply. Don't\nretype the disk commands; nothing here works until `doas` does, so work through the Step 4\nrecovery (is the VM up? is `seed.iso` attached as a **cdrom**?) first.\n\n### 6. Install deps + raise the datasize limit\n\n`doas pkg_add` installs the build tools (the `python%3`/`rsync--` spellings are correct —\nsee Gotchas). Then raise the per-process `datasize` ceiling for the `openbsd` user — and\ntreat this as intent, not a fixed substitution. OpenBSD's `/etc/login.conf` caps each\nprocess's data segment in the `default` login class (the class the `openbsd` user falls\nunder), and the LLVM build needs roughly 4 GB, so both `datasize-cur` and `datasize-max`\nmust end up at least `8192M` (what CI sets). On the stock OpenBSD 7.9 image both default to\n`1536M`, so the two `sed`s below do it. But the goal is the `8192M` ceiling, not the literal\n`1536M`: if a future image ships a different default the `sed`s match nothing, so read the\ncurrent values (the trailing `grep` prints them) and, if either is below `8192M`, raise it\nto `8192M` directly — adjust the `sed` pattern or edit `/etc/login.conf`. Confirm both read\n`8192M` before continuing; too low and the libs build dies with allocation failures partway\nthrough, far from this step. The edit persists on the root disk and re-running is a no-op.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ndoas pkg_add -u && doas pkg_add cmake gmake git python%3 rsync--\n# Raise the openbsd user's datasize cap to 8192M. The stock 7.9 image\n# defaults both to 1536M; if a future image differs, the sed matches\n# nothing — read the grep below and set both to 8192M however fits.\ndoas sed -i 's/datasize-max=1536M/datasize-max=8192M/' /etc/login.conf\ndoas sed -i 's/datasize-cur=1536M/datasize-cur=8192M/' /etc/login.conf\ngrep -E 'datasize-(cur|max)=' /etc/login.conf   # confirm both now read 8192M\nEOF\n```\n\n### 7. Rsync the ponyc checkout in (exclude the host `build/`)\n\nRun from your ponyc checkout. Exclude the top-level `build/` (host artifacts; the VM builds\nits own — this `--exclude` is a deliberate divergence from CI, whose fresh checkout has none\nto skip). Keep `.git` (CMake runs `git rev-parse`). The vendored LLVM submodule under\n`lib/llvm/src` IS transferred — the VM needs it for the libs build.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nrsync -az --exclude='/build' \\\n  -e \"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222\" \\\n  \"$(git rev-parse --show-toplevel)/\" openbsd@localhost:/build/ponyc/\n```\n\n## Using the VM\n\nThe build uses OpenBSD's base clang — no `CC`/`CXX`/`LD_LIBRARY_PATH` exports (that is the\nDragonFly-only gcc13 dance). `cmake -P lib/build-libs.cmake` builds the vendored LLVM and is the multi-hour long\npole — run it detached, then poll the log until it ends with `libs DONE rc=0` (a nonzero rc\nmeans it failed — read the log above the marker). Keep `-DJOBS=4` (what CI uses):\neach parallel compile is bounded by the 8192M `datasize` cap, so higher parallelism is the\nfirst thing to suspect on a libs out-of-memory.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncat > /build/run-libs.sh <<'S'\n#!/bin/sh\ncd /build/ponyc && cmake -DJOBS=4 -P lib/build-libs.cmake\necho \"libs DONE rc=$?\"\nS\nchmod +x /build/run-libs.sh\nnohup /build/run-libs.sh > /build/libs-build.log 2>&1 &\necho \"libs building pid $!\"\nEOF\n# poll until done: $SSH /bin/sh -c 'tail -3 /build/libs-build.log'  -> wait for \"libs DONE rc=0\"\n```\n\nOnce `libs` is built (it persists in the VM), build and test with the same shell. Do not run\nthis block until the poll shows `libs DONE rc=0` — even `cmake --preset debug` needs\nthe built LLVM (CMake's `find_package(LLVM)` fails without it), so it cannot run before\nthe libs build:\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncd /build/ponyc\ncmake --preset debug\ncmake --build --preset debug\nctest --preset debug -L ci-core\nEOF\n```\n\nTo iterate on a fix: edit on the host, re-run Step 7's `rsync` (it's incremental — only\nchanged files are sent), then re-run the build block above (long rebuilds should also be\ndetached + polled). For exact CI parity, the tier-3 `openbsd` job\n(`.github/workflows/ponyc-tier3.yml`) runs more than this. In order: an\nunsupported-build-rejection smoke that runs **before** the libs build (it needs no LLVM —\n`.ci-scripts/openbsd-reject-unsupported-builds.sh` asserts `use=address_sanitizer`,\n`thread_sanitizer`, `undefined_behavior_sanitizer`, `coverage`, and `valgrind` are\neach rejected with \"not supported on OpenBSD\"); then debug configure/build; a `--static`\nembedded-LLD link smoke (OpenBSD static is static-PIE, so the binary's `file` output reads\n\"shared object\" — the smoke instead asserts `readelf -d` shows **no** `(NEEDED)` entries);\nthe debug `ci-core` suite; the self-hosted tool tests (`pony-doc-tests`/`pony-lint-tests`/\n`pony-lsp-tests`); and a release build + the `ci-core` suite. Consult that job when validating a\nCI-matching issue.\n\n## Lifecycle\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9; cd \"$VMDIR\"\npgrep -af \"qemu-system-x86_64 -name openbsd-${OPENBSD_VERSION}\"        # status\nssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 openbsd@localhost   # interactive shell\n# stop: kill the process. Disks persist for next time. The pgrep/pkill are scoped to this\n# version so a second OpenBSD VM isn't matched; the pidfile path is per-$VMDIR anyway.\nkill \"$(cat openbsd.pid)\" 2>/dev/null || pkill -f \"qemu-system-x86_64 -name openbsd-${OPENBSD_VERSION}\"\n```\n\nTo restart later, re-run the step-3 boot command (the disks and installed deps persist; you\ndo not redo setup). On a reboot, ssh-as-`openbsd` works immediately and `doas` still works\n(`/etc/doas.conf` persisted), and the raised `datasize` limit is still in `/etc/login.conf`\n— but the `/build` data disk is **not** auto-mounted (there is no `/etc/fstab` entry), so\nremount it before building: `doas mount /dev/sd1a /build`. The source under `/build/ponyc`\nis preserved on the data disk across the remount. For a long-lived VM, add the mount to\n`/etc/fstab` so it survives reboots.\n\n## How this relates to CI (don't re-add the CI-only steps)\n\nThis mirrors `.ci-scripts/bsd/openbsd-provision.bash` (the source of truth — keep this skill\nin sync if it changes). The local setup deliberately differs from CI, and each difference is\nsafe:\n\n- Skips CI host-bootstrap (free disk space, `apt-get` qemu, make `/dev/kvm` writable) — your\n  host already has qemu and a usable hardware accelerator (Step 0 verified it).\n- Persistent VM instead of built-fresh-and-killed per run.\n- ssh adds `-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR` to quiet the host-key\n  warning: a persistent VM reused on `localhost:2222` collides with the stale `known_hosts`\n  entry from a prior VM. CI's ephemeral runners never reuse the port, so\n  `openbsd-provision.bash` doesn't bother.\n- `-smp 8` / `-m 12G` for build speed (CI uses 4 / 12G), and a `-pidfile` for the lifecycle\n  commands.\n- `rsync --exclude='/build'` to skip the host's build artifacts (CI's fresh checkout has\n  none).\n- No GHCR libs cache (that's token-gated CI plumbing) — you just run `cmake -P lib/build-libs.cmake` once.\n\nThe FreeBSD and DragonFly CI VMs follow the same shape\n(`.ci-scripts/bsd/{freebsd,dragonfly}-provision.bash`) and each has its own skill:\n`create-freebsd-dev-env` (also cloud-init, but base libc++, an `expect`/`su` root\nbootstrap, and the source under `/home/freebsd` rather than a `/build` disk) and\n`create-dragonfly-dev-env` (gcc-only, and it drives a VGA console). This skill is\nOpenBSD-only; load a sibling if you need one of those.\n","frontmatter":{"name":"create-openbsd-dev-env","description":"Load when you need to reproduce or validate an OpenBSD-specific ponyc issue on a local VM, or to stand up a local OpenBSD development VM that matches ponyc tier-3 CI. Covers the cloud-init seed (cdrom), the sudo-free QEMU/KVM boot, the doas-via-cloud-init setup (no expect bootstrap), the dedicated /build data disk, the datasize limit, the clang build env, and the gotchas (openbsd-user login, disklabel build disk, raised datasize, static-PIE static linking, pkg_add naming, detached builds, per-block shells) that make hand-rolling one error-prone.","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":6589,"sizeBytes":22573},{"name":"SKILL.md","path":".claude/skills/update-ssl-builders/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/.claude/skills/update-ssl-builders/SKILL.md","title":"update-ssl-builders","category":"anthropic-skill","format":"markdown","content":"---\nname: update-ssl-builders\ndescription: Twice-yearly workflow for bumping ponyc CI SSL builder images (OpenSSL, LibreSSL) to their latest series releases. Invoked via /update-ssl-builders.\ndisable-model-invocation: false\n---\n\n# Update SSL Builders\n\nponyc CI tests `packages/net/` and `packages/crypto/` against multiple SSL implementations. Each has its own Docker builder image under `.ci-dockerfiles/ubuntu26.04-builder-with-<library>-<version>/`. This skill walks through bumping those builders to the latest upstream releases.\n\n## Background\n\n**One version per series**: These are internal CI builders. Each series keeps exactly one version — an update replaces the old version with the new one in a single PR.\n\n**OpenSSL 1.1.1 is EOL**: The `openssl-1.1.1w` builder exists for backward-compatibility testing. This skill does not update or drop it.\n\n**Builders are amd64-only**: Each builder directory contains two files: `Dockerfile` and `build-and-push.bash`. No multiplatform build, no `combine-images.bash`, no `README.md`.\n\n**SSL flag mapping**: The `ssl-backends-test` matrix in `pr.yml` maps each SSL library to a Pony compiler flag:\n\n| Library series | Pony flag |\n|---|---|\n| `libressl-*` | `-Dlibressl` |\n| `openssl-3.*` | `-Dopenssl_3.0.x` |\n| `openssl-4.*` | `-Dopenssl_4.0.x` |\n\nIf a new major version line appears upstream (e.g., OpenSSL 5.x) that has no existing Pony compiler flag, do not add a builder. Open a task issue on this repo to add support for testing with the new version, noting the upstream release.\n\n**Three workflow files reference SSL builders**:\n\n- `rebuild-ssl-builder-images.yml` — `matrix.ssl` list; rebuilds all builders when the base image changes.\n- `build-builder-image.yml` — `inputs.builder-name` dropdown and per-builder job blocks; builds a single chosen builder on demand.\n- `pr.yml` — `ssl-backends-test.strategy.matrix.include`; runs `net` and `crypto` tests against each builder image.\n\n## Phase 1: Inventory\n\nList all `ubuntu26.04-builder-with-*` directories under `.ci-dockerfiles/` and group them by library + series. Ignore `openssl-1.1.1w`.\n\n## Phase 2: Fetch upstream releases\n\n- LibreSSL: https://www.libressl.org/releases.html\n- OpenSSL: https://openssl-library.org/source/\n\nFor each library, find the highest version number available across all currently-supported upstream branches.\n\n## Phase 3: Compute updates and confirm\n\n**Series definition (deliberate project policy)**: One major version line = one series. LibreSSL 3.x, LibreSSL 4.x, OpenSSL 3.x, OpenSSL 4.x are each a single series. This collapses OpenSSL's concurrently-supported LTS branches (e.g., 3.0/3.4/3.5/3.6) into one series — a new release in any 3.x branch counts as \"the latest of OpenSSL 3.x.\"\n\nFor each series (excluding OpenSSL 1.x):\n- If the upstream latest is newer than what we have, plan to **replace** the current version with the new one.\n- If upstream is not newer, no changes for that series.\n\nPresent the plan to the user and wait for explicit confirmation before making any changes.\n\n## Phase 4: Per-update workflow (one PR per series)\n\nEach series update gets its own PR. The PR replaces the old builder with the new one.\n\n1. Create a new branch.\n2. Create a new directory `.ci-dockerfiles/ubuntu26.04-builder-with-<library>-<new-version>/`.\n3. Copy the two files from the current version in the same series: `Dockerfile` and `build-and-push.bash`.\n4. Bump version references in each file:\n   - **Dockerfile**: The version ARG (`OPENSSL_VERSION` or `LIBRESSL_VERSION`). The download URL pattern is stable within a major version line and should not need changing.\n   - **build-and-push.bash**: The `NAME` variable (full GHCR image path including the version) and the `BUILDER` variable (strip dots and dashes from the version, e.g., `ssl-openssl372`, `ssl-libressl430`).\n5. Delete the old directory `.ci-dockerfiles/ubuntu26.04-builder-with-<library>-<old-version>/`.\n6. Edit `.github/workflows/rebuild-ssl-builder-images.yml`: replace the old `<library>-<old-version>` entry in `matrix.ssl` with `<library>-<new-version>`.\n7. Edit `.github/workflows/build-builder-image.yml`:\n   - Replace the old builder name with the new one in the `options` list.\n   - Replace the old job block with a new one. The job key uses underscores for dots (e.g., `ubuntu26_04-builder-with-openssl-3_7_0`). Copy the structure from the old job, updating the `if:` condition, `name:` field, and `run:` step to reference the new directory.\n8. Commit, push, open PR. Do **not** update `pr.yml` yet — there is no image to reference.\n9. Dispatch the build workflow on the branch to build and push the new image to GHCR:\n   ```\n   gh workflow run build-builder-image.yml --ref <branch> -f ref=<branch> -f builder-name=ubuntu26.04-builder-with-<library>-<new-version>\n   ```\n   `--ref <branch>` is required — the new builder name only exists in the branch's `choice` input list. The image is tagged with today's date (`YYYYMMDD` from `build-and-push.bash`).\n10. Wait for the workflow run to complete successfully. Verify the image exists on GHCR:\n    ```\n    gh api orgs/ponylang/packages/container/ponyc-ci-ubuntu26.04-builder-with-<library>-<new-version>/versions --jq '.[0].metadata.container.tags'\n    ```\n11. Edit `.github/workflows/pr.yml`: in `ssl-backends-test.strategy.matrix.include`, replace the old entry with the new one:\n    ```yaml\n    - ssl-name: <library>-<new-version>\n      ssl-flag: '<flag from mapping table>'\n      image: ghcr.io/ponylang/ponyc-ci-ubuntu26.04-builder-with-<library>-<new-version>:<YYYYMMDD>\n    ```\n12. Commit and push.\n13. Wait for PR CI to pass. The `ssl-backends-test` suite should now include the new builder and its `net`/`crypto` tests should pass.\n14. Squash-merge the PR.\n\nExisting tagged images for the old version on GHCR are left intact — they simply stop receiving updates because they are no longer in the rebuild workflow's matrix.\n","frontmatter":{"name":"update-ssl-builders","description":"Twice-yearly workflow for bumping ponyc CI SSL builder images (OpenSSL, LibreSSL) to their latest series releases. Invoked via /update-ssl-builders.","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":1490,"sizeBytes":5921},{"name":"SKILL.md","path":".claude/skills/upgrade-llvm/SKILL.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/.claude/skills/upgrade-llvm/SKILL.md","title":"upgrade-llvm","category":"anthropic-skill","format":"markdown","content":"---\nname: upgrade-llvm\ndescription: Load when upgrading the vendored LLVM submodule in ponyc. Covers the per-version commit strategy, submodule mechanics, patch handling, hash updates, and common API migration patterns.\ndisable-model-invocation: false\n---\n\n# Upgrading the vendored LLVM in ponyc\n\nponyc vendors LLVM as a submodule at `lib/llvm/src`, pinned by hash in\n`lib/CMakeLists.txt` and patched from `lib/llvm/patches/`. Upgrading it means\nmoving the pin, re-checking the patches, fixing whatever LLVM removed or\nchanged under `src/libponyc/codegen/`, and rebuilding LLVM from source.\n\nRun every command from the repo root.\n\n## Gotchas that will bite you (read first)\n\n- **The submodule is a shallow clone.** `lib/CMakeLists.txt` clones it with\n  `--depth 1` and fetches no tags. A plain `git fetch origin tag llvmorg-XX.Y.Z`\n  deepens it to the whole of llvm-project's history up to that tag. Pass\n  `--depth 1`.\n- **A patch that fails the forward check has not necessarily been upstreamed.**\n  The forward check also fails when the patch is already applied to the tree,\n  and when `git apply` cannot read the tree at all. Work through step 4 before\n  you delete anything.\n- **Don't `rm -rf build`.** That directory holds every configured build tree,\n  not just LLVM's. The vendored LLVM builds in `build/build_libs` and installs\n  to `build/libs`; remove only those two.\n- **Stage the submodule pointer before you build.** `cmake -P\n  lib/build-libs.cmake` runs `git submodule update --init`, which resets\n  `lib/llvm/src` to the recorded pointer. A pointer change that exists only in\n  the working tree is lost.\n- **ponyc still builds when an intrinsic's signature changes.** The failure\n  comes later, when ponyc compiles a Pony program, so run the tests and not\n  just the build.\n\n## Strategy\n\nUpgrade one major version at a time, and give each version its own commit. One\nPR can carry more than one version. Separate commits show which LLVM version\neach source change came from, and let you bisect the branch when something\nbreaks. ponylang repos squash-merge, so those commits become one commit on\n`main` — you can only bisect them while the branch is open.\n\n## Steps\n\nSteps 1, 8, and 9 run once for the PR. Steps 2 through 7 repeat for each major\nversion you move through.\n\n### 1. Branch\n\nBranch off `main`.\n\n### 2. Update the submodule\n\n```bash\n# Discard any patches applied to the tree\ngit -C lib/llvm/src checkout -- .\n# Fetch and check out the new tag, keeping the clone shallow\ngit -C lib/llvm/src fetch --depth 1 origin tag llvmorg-XX.Y.Z\ngit -C lib/llvm/src checkout llvmorg-XX.Y.Z\n```\n\n`git checkout -- .` throws away every modification to a tracked file in the\nsubmodule, including a patch you rebased by hand. Save that work outside the\ntree first.\n\n### 3. Update `LLVM_DESIRED_HASH`\n\nGet the new commit hash:\n\n```bash\ngit -C lib/llvm/src rev-parse HEAD\n```\n\nSet `LLVM_DESIRED_HASH` in `lib/CMakeLists.txt` to this value.\n\n### 4. Check the patches\n\nThe build applies every `.diff` in `lib/llvm/patches/`, so check each one.\nRun the same check the build runs, with the same flags:\n\n```bash\ngit -C lib/llvm/src apply --check -p 1 --ignore-whitespace --whitespace=nowarn \\\n  ../patches/<patch>.diff\n```\n\nIf that fails, run the reverse check before concluding anything:\n\n```bash\ngit -C lib/llvm/src apply --reverse --check -p 1 --ignore-whitespace \\\n  --whitespace=nowarn ../patches/<patch>.diff\n```\n\n- Forward check passes: the patch still applies. Keep it.\n- Forward fails, reverse passes: the change is already in the tree. On a tree\n  you reset in step 2, LLVM upstreamed it, so delete the patch file. On a tree\n  you did not reset, an earlier build applied it — reset and check again.\n- Both fail: either LLVM moved the code the patch touches, or `git apply` could\n  not read the tree. In the second case `git apply` reports `fatal: not a git\n  repository`, which comes from a dangling submodule gitlink — a `.git` file\n  pointing at a `.git/modules/...` target that isn't there, left behind when\n  the tree was copied or rsynced without the parent's `.git/modules`.\n  Otherwise, rebase the patch onto the new source rather than deleting it.\n\n### 5. Commit the submodule pointer\n\n```bash\ngit add lib/llvm/src lib/CMakeLists.txt\ngit commit -m \"Upgrade LLVM AA.B.C -> XX.Y.Z\"\n```\n\n`cmake -P lib/build-libs.cmake` resets the submodule to the pointer recorded in\nthe index, so stage this before step 6 or the new pointer is lost. Amend this\ncommit as the next two steps produce changes.\n\n### 6. Build, and fix what breaks\n\n```bash\nrm -rf build/libs build/build_libs         # Drop the old LLVM; the next command rebuilds it\ncmake -DJOBS=<n> -P lib/build-libs.cmake   # Build LLVM; see BUILD.md for picking <n>\ncmake --preset release                     # Configure the ponyc build\ncmake --build --preset release             # Build ponyc\n```\n\n`JOBS` defaults to 4. BUILD.md says to pass your own core count and keep it\nmodest, because LLVM is memory-hungry. On Windows use the Windows presets\n(BUILD.md).\n\nTwo kinds of failure come out of this step.\n\n**A patch-hash mismatch**, if you added, removed, or edited a patch file in\nstep 4. The lib configure step prints both hashes and stops:\n\n```\nPatch hash actual '<actual>' does not match desired '<desired>'\n```\n\nCopy `<actual>` into `PATCHES_DESIRED_HASH` in `lib/CMakeLists.txt`. The hash\nis a SHA256 over the seed string `needed_if_no_patches` and the SHA256 of each\npatch file's text with newlines squashed to spaces, which is awkward to\nreproduce in a shell — let CMake compute it. If no patches remain at all, the\nvalue is `SHA256(\"needed_if_no_patches\")` =\n`3e16c097794cb669a8f6a0bd7600b440205ac5c29a6135750c2e83263eb16a95`.\n\n**Compile errors**, from LLVM APIs that were removed or changed. See \"Common\nmigration patterns\" below. Fix them, amend the commit, and build again.\n\n### 7. Run the tests\n\n```bash\nctest --preset release -L ci-core\n```\n\nThe `ci-core` label covers `libponyc.tests`, where the codegen assertions live,\nalong with the full-program, stdlib, and examples tests.\n\nThis is narrower than what the PR will run: CI runs `ci-core` in both debug and\nrelease on Linux, macOS, and Windows, and separately builds and runs\n`pony-compiler-tests`. A failure that only shows up in one config, or on one\nplatform, will not appear here.\n\nTo reproduce a tier-3 BSD failure, the `create-dragonfly-dev-env`,\n`create-freebsd-dev-env`, and `create-openbsd-dev-env` skills stand up a local\nVM matching that CI job.\n\n### 8. Add a release note\n\nAn LLVM upgrade is user-facing. Add `.release-notes/update-llvm-to-XX.md`:\n\n```markdown\n## Update to LLVM XX.Y.Z\n\nWe've updated the LLVM version used to build Pony from AA.B.C to XX.Y.Z.\n```\n\nDo not edit `next-release.md`; CI aggregates the individual files.\n\n### 9. Push and open the PR\n\nTitle the PR `Update to LLVM XX.Y.Z`, matching the release note. Before merging,\na Pony team member adds a `changelog - changed` label, which puts a line built\nfrom the PR title into that CHANGELOG section. That is a separate mechanism from\nthe release note file, which reaches `next-release.md` on its own.\n\n## Common migration patterns\n\n### `LLVMConst*` to `LLVMBuild*` (constant expression removal)\n\nLLVM removed a number of constant expression functions. The `LLVMBuild*`\nequivalents constant-fold when given constant operands, so they compute the\nsame thing; they take a builder and a name as well:\n\n```c\n// Before:\nreturn LLVMConstShl(l_value, r_value);\n// After:\nreturn LLVMBuildShl(c->builder, l_value, r_value, \"\");\n```\n\n`LLVMConstICmp`, `LLVMConstFCmp`, `LLVMConstShl`, and `LLVMConstMul` are gone\nfrom LLVM 22.1.6's `Core.h`. `LLVMConstAdd` and `LLVMConstSub` are still there\nand carry no deprecation, but ponyc calls none of the six.\n\n### Deprecated to newer API variants\n\nOn the clang and gcc builds ponyc's C files compile with `-Werror`, so a\ndeprecated LLVM C function fails the build if its declaration carries\n`LLVM_ATTRIBUTE_C_DEPRECATED`, which produces a compiler warning. The MSVC build\npasses `/wd4996` alongside `/WX`, so the same call compiles clean on Windows.\n\nA function marked deprecated only in a doc comment produces no warning\nanywhere. The headers use two spellings for that, `@deprecated` and a plain\n`Deprecated: use X instead`, so grep for both when you want to know what ponyc\ncalls that upstream has marked. The list below is a starting point, not a\ncomplete one.\n\n`LLVMGetMDKindID` carried the attribute rather than a doc comment, so it did\nfail the build. That migration is done; `src/` now calls\n`LLVMGetMDKindIDInContext`. `LLVMBuildNUWNeg` also carries the attribute — its\nreplacement is `LLVMBuildNeg` followed by `LLVMSetNUW`, which ponyc wraps as\n`LLVMSetNoUnsignedWrap` in `host.cc`. `LLVMBuildNSWNeg` is not deprecated.\n\n### A new link-time dependency\n\nAn LLVM upgrade can make ponyc need a system library it did not need before,\nand the need can be Windows-only, so a local Linux build will not show it. The\nLLVM 21 upgrade (`22e84c4a1`) added `ntdll.lib` to the Windows link line in\n`src/libponyc/platform/vcvars.c` and to three `CMakeLists.txt` files.\n\n### Intrinsic signature changes\n\nLLVM can change an intrinsic's signature in a major version. These do not show\nup as compile errors; ponyc builds clean. They surface as a module verification\nfailure when ponyc compiles a Pony program (`Incorrect number of arguments\npassed to called function!`), which is why step 7 runs the tests.\n\n- **LLVM 22**: `llvm.lifetime.start` and `llvm.lifetime.end` dropped their\n  leading `i64 size` argument and now take a single pointer. ponyc emits these\n  in `gencall_lifetime_start` and `gencall_lifetime_end` (`gencall.c`). Drop\n  the size operand, drop the `type` parameter the size was computed from, and\n  call with one argument:\n\n```c\n// Before (2 args: size, ptr):\nLLVMValueRef args[2];\nargs[0] = LLVMConstInt(c->i64, size, false);\nargs[1] = ptr;\nLLVMBuildCall2(c->builder, func_type, func, args, 2, \"\");\n// After (1 arg: ptr):\nLLVMBuildCall2(c->builder, func_type, func, &ptr, 1, \"\");\n```\n\n`CodegenTest.LifetimeIntrinsicsHaveSinglePointerArgument` in\n`test/libponyc/codegen.cc` asserts the emitted argument count. When a later\nLLVM changes another intrinsic's signature, assert its argument count the same\nway.\n\n### C++ API changes\n\nponyc also embeds LLD (`genexe.cc`) and clang's frontend (`gencshim.cc`). Those\nC++ APIs carry no stability guarantee and break more often than LLVM's C API.\n\n- **LLVM 19**: `DIBuilder::insertDeclare` returns `DbgInstPtr` (PointerUnion)\n  instead of `Instruction*`. If callers don't use the return value, change the\n  wrapper's return type to `void`.\n- **LLVM 20**: `Intrinsic::getDeclaration` to\n  `Intrinsic::getOrInsertDeclaration`.\n- **LLVM 20**: Optimizer extension point callbacks\n  (`registerOptimizerEarlyEPCallback`, `registerOptimizerLastEPCallback`) now\n  take a `ThinOrFullLTOPhase` parameter in the lambda.\n- **LLVM 21**: `Attribute::NoCapture` to\n  `Attribute::getWithCaptureInfo(ctx, CaptureInfo::none())`.\n- **LLVM 21**: `LintPass()` requires a `bool AbortOnError` parameter:\n  `LintPass(false)`.\n- **LLVM 22**: `createTargetMachine` takes a `const Triple&` instead of a\n  `StringRef`; wrap with `Triple(opt->triple)`. ponyc made this change under\n  LLVM 21, in `3eae0a119`, while the `StringRef` overload was still there and\n  deprecated.\n\n## APIs to check on every upgrade\n\nAPIs ponyc calls that a future LLVM may break:\n\n| API | Location | Likely replacement |\n|-----|----------|-------------------|\n| `LLVMArrayType` | codegen.c, gendesc.c, genident.c | `LLVMArrayType2` (`uint64_t` count) |\n| `LLVMConstArray` | gendesc.c, genident.c | `LLVMConstArray2` (`uint64_t` count) |\n| `LLVMConstStringInContext` | codegen.c | `LLVMConstStringInContext2` (`size_t` length) |\n| `LLVMConstNeg` | genoperator.c | `LLVMBuildNeg` |\n| `LLVMConstNot` | genoperator.c | `LLVMBuildNot` |\n| `LLVMConstXor` | genoperator.c | `LLVMBuildXor` |\n| `LLVMConstTrunc` | genreference.c | `LLVMBuildTrunc` |\n\nThe first three carry an `@deprecated` doc comment; the other four are not\ndeprecated. The build fails once one of them gains the deprecation attribute or\nis removed. Re-check this table against the new headers as part of the upgrade.\n\n## Files typically modified\n\n- `lib/CMakeLists.txt` — `LLVM_DESIRED_HASH`, `PATCHES_DESIRED_HASH`\n- `lib/llvm/patches/` — patch files, added or removed\n- `.release-notes/` — the release note\n- `src/libponyc/codegen/genoperator.c` — constant expression replacements\n- `src/libponyc/codegen/genreference.c` — constant expression replacements\n- `src/libponyc/codegen/gendebug.cc`, `gendebug.h` — debug info API changes\n- `src/libponyc/codegen/host.cc` — target machine, intrinsics\n- `src/libponyc/codegen/genopt.cc` — optimizer pass pipeline\n- `src/libponyc/codegen/gendesc.c` — array and descriptor construction\n- `src/libponyc/codegen/genident.c` — array construction\n- `src/libponyc/codegen/codegen.c` — core codegen types and helpers\n- `src/libponyc/codegen/gencontrol.c` — metadata APIs\n- `src/libponyc/codegen/gencall.c`, `gencall.h` — intrinsic emission\n  (lifetime markers, memcpy and memmove)\n- `src/libponyc/codegen/genexe.cc` — embedded LLD\n- `src/libponyc/codegen/gencshim.cc` — embedded clang frontend\n- `src/libponyc/platform/vcvars.c`, `src/ponyc/CMakeLists.txt`,\n  `test/libponyc/CMakeLists.txt`, `benchmark/libponyc/CMakeLists.txt` — link\n  dependencies\n- `test/libponyc/codegen.cc` — codegen assertions\n\nA bigger LLVM can also push a CI job past its disk limit. During the LLVM 22\nupgrade we moved the OpenBSD tier-3 job onto a dedicated 50 GB data disk; the\nscript that creates it is in `.ci-scripts/bsd/`.\n","frontmatter":{"name":"upgrade-llvm","description":"Load when upgrading the vendored LLVM submodule in ponyc. Covers the per-version commit strategy, submodule mechanics, patch handling, hash updates, and common API migration patterns.","disable-model-invocation":false,"disableModelInvocation":false},"isInternal":false,"tokens":3692,"sizeBytes":13690},{"name":"AGENTS.md","path":"packages/net/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/packages/net/AGENTS.md","title":"Net Skill","category":"anthropic-skill","format":"markdown","content":"# Net package\n\nThe `net` package provides Pony's networking. It reworks the original stdlib `net` package around a different split: the connection logic lives in a plain `class` (`TCPConnection`, `TCPListener`, `UDPSocket`) that the user's `actor` holds and delegates to, rather than being baked into a single actor.\n\n<!-- contributor-only -->\n## Contributing with an AI assistant\n\nThis is a Pony project. The ponylang org maintains a set of LLM coding skills. Get set up with them before contributing:\n\n- **Not set up yet?** Install them once:\n\n  ```bash\n  git clone https://github.com/ponylang/llm-skills.git\n  cd llm-skills\n  python install.py\n  ```\n\n- **Already set up?** Make sure you're on the latest. If you installed with the script above, `git pull` in the directory where you cloned `llm-skills` and the symlinked skills update automatically — if you set them up another way, refresh them however that setup expects.\n\nSee the [llm-skills README](https://github.com/ponylang/llm-skills) for details and other harnesses.\n\nWhen you start working on this project, load the `pony-skills` skill — it tells your assistant which Pony skill to use for each task.\n\nRead [CONTRIBUTING.md](../../CONTRIBUTING.md).\n<!-- /contributor-only -->\n\n## Architecture\n\nA client connect subscribes one socket per resolved address at once (Happy Eyeballs). Whichever connects first becomes the connection's own event; the rest are stragglers, and each has to be unsubscribed and its fd closed when its event arrives. That is why every state handles `own_event` and `foreign_event` separately, and what `_UnconnectedClosing` drains.\n\n### Connection lifecycle\n\n`TCPConnection` tracks its lifecycle with explicit state objects — the `_ConnectionState` trait and its implementers in `_connection_state.pony` — rather than boolean flags.\n\n```\n_ConnectionNone     → _ClientConnecting\n                    → _Open              (server, plaintext)\n                    → _SSLHandshaking    (server, SSL)\n                    → _Closed            (SSL session creation failed)\n_ClientConnecting   → _Open              (connected, plaintext)\n                    → _SSLHandshaking    (connected, SSL)\n                    → _UnconnectedClosing (close, drain stragglers)\n                    → _Closed            (hard_close / all attempts failed)\n_SSLHandshaking     → _Open              (ssl_handshake_complete)\n                    → _Closed            (hard_close / SSL error)\n_Open               → _Closing           (close)\n                    → _TLSUpgrading      (start_tls)\n                    → _Closed            (hard_close)\n_TLSUpgrading       → _Open              (ssl_handshake_complete)\n                    → _Closed            (hard_close / TLS error)\n_Closing            → _Closed            (drained, or hard_close)\n_UnconnectedClosing → _Closed            (drained, or hard_close)\n```\n\n### UDP lifecycle\n\n`UDPSocket` uses the same state-object pattern with three states.\n\n```\n_UDPNone    → _UDPOpen   (bind succeeded)\n            → _UDPClosed (bind failed, or dispose/init race)\n_UDPOpen    → _UDPClosed (close or error)\n```\n\nThe dispose/init race: `_finish_initialization` is a self→self behavior. If `dispose()` arrives first, `_UDPNone.close()` transitions to `_UDPClosed`, and `_finish_initialization` checks for that and skips the bind.\n\n### Connected UDP lifecycle\n\n`ConnectedUDPSocket` uses the same state-object pattern. Initialization has three outcomes: bind failure, connect failure, or success.\n\n```\n_ConnectedUDPNone    → _ConnectedUDPOpen   (bind and connect succeeded)\n                     → _ConnectedUDPClosed (bind failed, connect failed, or dispose/init race)\n_ConnectedUDPOpen    → _ConnectedUDPClosed (close or error)\n```\n\nThe dispose/init race works the same way as `UDPSocket`. The peer is fixed at creation — there is no disconnect or reconnect.\n\n## Traps\n\n- **One read loop; keep `_on_received` out of `_ssl_poll()`.** An earlier design had `_ssl_poll()` deliver application data from a second loop of its own, so mute and the liveness check had to be written twice. Both shipped as bugs before the second copy existed.\n\n- **`_ssl` is a `_TLSState`, not `(SSL | None)`.** `(SSL | None)` was the earlier design. It records whether a session exists but not whether one may be used, and that ambiguity was a segfault.\n\n- **Mint the send token before the flush.** The flush can close the connection, so a token minted after it loses the terminal callback for a send whose bytes already went out. It is also why `send()` returns `SendAccepted` rather than the token: `_on_sent` is a direct call, so the application has to be holding the token before the flush runs. Keep every error return upstream of the mint, so an errored send burns no token id.\n\n- **`_on_send_failed` is deferred; `_on_sent` is not.** `_on_sent` was deferred too, for the same-looking reason, and it was wrong: a send reaching the OS is its own event, and queueing it let callbacks for later events overtake it.\n\n- **The hard-close reason is the `_HardCloseCause` argument, not a field.** An earlier design set one of three fields (`_connect_timed_out` and the like) right before an argumentless `hard_close()` and dug it back out after. Do not put it back in a field.\n\n- **`close_notify` is deferred to `_Closing.drained()`, not sent in `_Open.close()`.** `ssl.close()` calls `SSL_shutdown`, which makes `SSL_read` return `SSL_ERROR_ZERO_RETURN` — buffered TLS records the read loop has not yet delivered are lost.\n\n- **`_set_unwriteable()` before `_asio.resubscribe_write()`.** The ponyc epoll backend's `pony_asio_event_resubscribe()` only includes `EPOLLOUT` when `!ev->writeable`. After `_dispatch_io_event()` sets writeable, the flag stays true, so a later resubscribe is a no-op for the write side. Clear the flag first.\n\n- **A stale foreign event is dropped once, in `_event_notify`, not in each state.** The check used to sit in each `foreign_event` instead. Three of those copies were removed because the suite still passed on Linux, where the second message does not arrive, and that shipped as a bug reachable only where kqueue is the backend. Do not push the check back down into the states.\n\n## Platform differences\n\nPOSIX and Windows share one readiness-based I/O path: one-shot readiness events (epoll/kqueue; `ProcessSocketNotifications` on Windows), resubscribe, then a synchronous `RuntimeBackend.receive`/`sendv`. Windows uses this path because ponyc removed IOCP; the floor is Windows 11 / Windows Server 2022.\n\nHow many messages one subscription delivers is platform-specific too. kqueue arms read and write as separate one-shot filters and sends a message from each (ponyc's `kqueue.c`), so a single subscribed socket can deliver two readiness messages; epoll and Windows combine both directions into one (`epoll.c`, `sock_notify.c`). Code written on the assumption of one message per subscription is wrong wherever kqueue is the backend — macOS is the one CI covers, but the BSDs use it too.\n\n## Conventions\n\n- `_Unreachable()` in a branch the compiler cannot prove impossible, rather than an empty `else`.\n- The runtime will not exit while an actor holds a live I/O resource, so a test has to dispose any actor that might still hold one when it ends, or CI hangs. That is the client a listener creates in `_on_listening` and every server actor returned from `_on_accept`. Happy Eyeballs can deliver more than one connection per client connect (one per resolved address), so store accepted servers in `let _servers: Array[Type]`, push in `_on_accept`, and iterate in `dispose`/`_on_closed`.\n- Tests bind listeners and sockets to port `\"0\"` (OS-assigned ephemeral port) and retrieve the actual port from `local_address()` in the ready callback (`_on_listening` for TCP, `_on_bound` for UDP, `_on_connected` for connected UDP). This avoids conflicts when multiple test runs execute concurrently on the same machine.\n- `\\nodoc\\` on test classes.\n- A new test goes in the `_test_*.pony` file for its functional area, registered in `Main.tests()` in `_test.pony`, which holds only the test runner.\n- Each example has a file-level docstring saying what it demonstrates, uses the Listener/Server/Client actor structure, and uses a unique port. Adding one means placing it under the appropriate `examples/networking/` subdirectory and adding it to `examples/README.md` under the Networking section.\n","isInternal":false,"tokens":1934,"sizeBytes":8457},{"name":"CLAUDE.md","path":"packages/net/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/packages/net/CLAUDE.md","title":"Net Skill","category":"anthropic-skill","format":"markdown","content":"@AGENTS.md\n","isInternal":false,"tokens":5,"sizeBytes":11},{"name":"README.md","path":"tools/lib/README.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/tools/lib/README.md","title":"lib Documentation","category":"plugin-manifest","format":"markdown","content":"# tools/lib\n\nVendored library dependencies for the tools in the `tools/` directory.\n","isInternal":false,"tokens":20,"sizeBytes":84},{"name":"README.md","path":"tools/pony-agent-server/README.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/tools/pony-agent-server/README.md","title":"pony-agent-server Documentation","category":"plugin-manifest","format":"markdown","content":"# pony-agent-server\n\nA query server for AI coding agents that need Pony type information while editing code. It compiles a package and answers questions about types, definitions, capabilities, and structure.\n\nThe query interface is experimental and subject to change.\n\n## Usage\n\n```text\npony-agent-server <package-directory> [-D define]...\n```\n\nThe server compiles the package on startup, then reads one JSON query per line from stdin and writes one JSON response per line to stdout. Diagnostic messages go to stderr.\n\nPass `-D` flags the same way you would pass them to `ponyc`. For example, `-D openssl_3.0.x` for OpenSSL 3.x.\n\nRun `--queries` to print a summary of all queries and their input format.\n\n## Protocol\n\nEvery query is a JSON object with a `\"query\"` field. An optional `\"id\"` field (string or integer) is echoed back in the response.\n\nOn success, the response contains the query-specific fields described below. On error, the response contains an `\"error\"` field with a message.\n\n```json\n{\"query\":\"status\", \"id\":1}\n{\"id\":1, \"status\":\"ready\"}\n```\n\n## Queries\n\n### inspect\n\nType and definition of the AST node at a source position.\n\n```json\n{\"query\":\"inspect\", \"file\":\"/path/to/file.pony\", \"line\":10, \"column\":5}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `type` | string | The node's type as the compiler resolved it |\n| `node_kind` | string | AST node kind (e.g. `\"letref\"`, `\"call\"`) |\n| `line`, `column` | integer | Position of the node |\n| `definition` | object | Where the symbol is defined: `file`, `line`, `column`, `kind`, `declared_type` |\n| `enclosing` | object | Enclosing context: `method`, `receiver_cap`, `type` |\n| `in_recover` | boolean | Whether the position is inside a `recover` block |\n| `sendable` | boolean | Whether the type is sendable (iso, val, or tag) |\n\n### scope\n\nEvery name visible at a source position, with recover-block accessibility.\n\n```json\n{\"query\":\"scope\", \"file\":\"/path/to/file.pony\", \"line\":10, \"column\":5}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `in_recover` | boolean | Whether the position is inside a `recover` block |\n| `entries` | array | Each entry has `name`, `type`, `kind`, `accessible`, and optionally `reason` |\n\nWhen `accessible` is false, `reason` explains why (typically because a non-sendable reference is blocked by a recover expression).\n\n### exports\n\nPublic types exported by a package.\n\n```json\n{\"query\":\"exports\", \"package\":\"collections\"}\n```\n\nThe package can be a qualified name like `\"collections\"` or an absolute path.\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `package_path` | string | Resolved path to the package |\n| `entries` | array | Each entry has `name`, `kind` (class, actor, primitive, etc.), and optionally `docstring` |\n\n### type_api\n\nMethods callable on a type at a given capability. There are two ways to query: by type name with an explicit capability, or by source position (the type and capability are read from the compiler's AST).\n\nBy name:\n\n```json\n{\"query\":\"type_api\", \"type\":\"Array[String]\", \"cap\":\"ref\"}\n```\n\nBy position:\n\n```json\n{\"query\":\"type_api\", \"file\":\"/path/to/file.pony\", \"line\":10, \"column\":5}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `type` | string | The type name or expression |\n| `kind` | string | Entity kind: `\"class\"`, `\"actor\"`, `\"primitive\"`, `\"trait\"`, `\"interface\"`, `\"struct\"`, or `\"union\"` |\n| `callable` | array | Methods callable at this capability |\n| `not_callable` | array | Methods not callable, each with a `method` and `reason` |\n| `constructors` | array | Available constructors |\n| `traits` | array | Traits and interfaces the type provides |\n\nEach method object has: `name`, `receiver` (capability), `params` (array of `{name, type, has_default}`), `return`, `partial` (boolean), `kind` (`\"fun\"`, `\"be\"`, or `\"new\"`), and `source_type` (the type that defined the method).\n\n### check\n\nSubtype check between two types using the compiler's subtype checker.\n\n```json\n{\"query\":\"check\", \"source\":\"Collector ref\", \"target\":\"Describable ref\"}\n```\n\nTypes are a name optionally followed by a capability. Without a capability, the type's default is used (ref for classes, val for primitives, tag for actors).\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `compatible` | boolean | Whether the source is a subtype of the target |\n| `source_type` | string | Source type with resolved capability |\n| `target_type` | string | Target type with resolved capability |\n| `reason` | string | Explanation of the result |\n| `suggestions` | array | When incompatible, suggestions for making it work |\n\n### implementors\n\nTypes that implement a given trait or interface.\n\n```json\n{\"query\":\"implementors\", \"type\":\"Describable\"}\n```\n\nThe target must be a trait or interface.\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `type` | string | The queried trait or interface name |\n| `nominal` | array | Types that explicitly declare the trait/interface in their provides list |\n| `structural` | array | Types that match the interface's method signatures without declaring it (interfaces only; traits require nominal declaration) |\n\n### errors\n\nCompilation errors from the last compile or reload.\n\n```json\n{\"query\":\"errors\"}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `errors` | array | Each error has `message`, and optionally `file`, `line`, `column` |\n\n### reload\n\nRecompile the package. The server recompiles from the same directory it was started with and replaces the in-memory program on success. Queries issued after a failed reload get the previous successful compilation.\n\n```json\n{\"query\":\"reload\"}\n```\n\nThe response is the same as the initial compile: `{\"status\":\"ready\"}` on success, or `{\"status\":\"error\", \"errors\":[...]}` on failure.\n\n### status\n\nWhether the server has a compiled program ready for queries.\n\n```json\n{\"query\":\"status\"}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `status` | string | `\"ready\"` or `\"not_compiled\"` |\n","isInternal":false,"tokens":1495,"sizeBytes":6188},{"name":"README.md","path":"tools/pony-lsp/README.md","rawUrl":"https://raw.githubusercontent.com/ponylang/ponyc/HEAD/tools/pony-lsp/README.md","title":"pony-lsp Documentation","category":"plugin-manifest","format":"markdown","content":"# pony-lsp - the Pony Language Server\n\n`pony-lsp` implements the [Language Server Protocol] for Pony. It communicates with editors via stdout/stdin, and is built and distributed alongside `ponyc` via `ponyup`.\n\nFor user documentation — installation and editor configuration — see the [pony-lsp documentation] on the Ponylang website.\n\n[Language Server Protocol]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/\n[pony-lsp documentation]: https://www.ponylang.io/use/pony-lsp/\n\n## Feature Support\n\n| **Feature** | **Description** |\n| ------- | ----------- |\n| **[Diagnostics][spec-diagnostics]** (push, pull, refresh and change notifications) | Ponyc errors and related information is reported as LSP diagnostics. |\n| **[Hover][spec-hover]** | Additional information is shown for: entities, methods, fields, local variables and references. |\n| **[Signature Help][spec-signature-help]** | Parameter hints are shown when the cursor is inside a call expression, with the active parameter highlighted. Includes the method's docstring when present. Requires the file to be saved — signature help is not available while the file has unsaved edits. |\n| **[Go To Definition][spec-definition]** | For most language constructs, you can go from a reference to its definition. |\n| **[Go To Declaration][spec-declaration]** | Navigate from a reference to the declaration site of a symbol. |\n| **[Go To Type Definition][spec-type-definition]** | Navigate to the type definition of the symbol under the cursor. |\n| **[Document Symbols][spec-document-symbols]** | pony-lsp provides a list of available symbols for each opened document. |\n| **[Workspace Symbols][spec-workspace-symbols]** | Fuzzy search over all symbols across the entire workspace. |\n| **[Document Highlight][spec-document-highlight]** | All occurrences of the symbol under the cursor are highlighted simultaneously across the document. |\n| **[Inlay Hints][spec-inlay-hints]** | Three kinds of hints are shown inline. For `let`, `var`, and field declarations with no type annotation, the full inferred type appears after the variable name. For annotated types where the capability is omitted — in variable and field declarations, function parameter types, generic type arguments, and union and tuple members — the missing capability keyword appears after the type name. For `fun` declarations, the implicit receiver capability appears before the function name and the implicit return type appears after the closing parenthesis. |\n| **[Find References][spec-references]** | All references to the symbol under the cursor are returned, with optional inclusion of the declaration site. |\n| **[Rename][spec-rename]** | Rename a symbol and all its references across the workspace. |\n| **[Folding Range][spec-folding-range]** | Code folding ranges are provided for blocks, methods, classes, and other structured constructs. |\n| **[Selection Range][spec-selection-range]** | Smart expand/shrink selection based on the AST structure of the document. |\n| **[Type Hierarchy][spec-type-hierarchy]** | Navigate the type hierarchy of Pony entities. `prepareTypeHierarchy` identifies the entity under the cursor; `supertypes` returns the types in its `is` clause; `subtypes` returns all types across the compiled module that directly provide it. |\n| **[Call Hierarchy][spec-call-hierarchy]** | Navigate the call hierarchy of Pony methods. `prepareCallHierarchy` identifies the method under the cursor; `incomingCalls` returns all callers across the workspace with their call sites; `outgoingCalls` returns all callees with their call sites within the method. |\n\n[spec-diagnostics]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#pull-diagnostics\n[spec-hover]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#hover-request-leftwards_arrow_with_hook\n[spec-signature-help]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_signatureHelp\n[spec-definition]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#goto-definition-request-leftwards_arrow_with_hook\n[spec-declaration]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#goto-declaration-request-leftwards_arrow_with_hook\n[spec-type-definition]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#goto-type-definition-request-leftwards_arrow_with_hook\n[spec-document-symbols]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#document-symbols-request-leftwards_arrow_with_hook\n[spec-workspace-symbols]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace-symbols-request-leftwards_arrow_with_hook\n[spec-document-highlight]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#document-highlights-request-leftwards_arrow_with_hook\n[spec-inlay-hints]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#inlay-hint-request-leftwards_arrow_with_hook\n[spec-references]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#find-references-request-leftwards_arrow_with_hook\n[spec-rename]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#rename-request-leftwards_arrow_with_hook\n[spec-folding-range]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#folding-range-request-leftwards_arrow_with_hook\n[spec-selection-range]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#selection-range-request-leftwards_arrow_with_hook\n[spec-type-hierarchy]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareTypeHierarchy\n[spec-call-hierarchy]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareCallHierarchy\n\nNew features are actively being added. Contributions are welcome — we are happy to provide help and guidance.\n\n## Settings\n\n`pony-lsp` supports settings via the [`workspace/configuration`] request and [`workspace/didChangeConfiguration`] notification.\n\n[`workspace/configuration`]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_configuration\n[`workspace/didChangeConfiguration`]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeConfiguration\n\n| **Setting** | **Type** | **Example** | **Description** |\n| ------- | ---- | ------- | ----------- |\n| `defines` | `Array[String]` | `[\"FOO\", \"BAR\"]` | Defines active during compilation, equivalent to the `-D` flag in `ponyc` |\n| `ponypath` | `Array[String]` | `[\"/path/to/pony/module\"]` | Additional module search paths |\n\nExample settings in JSON:\n\n```json\n{\n  \"defines\": [\"FOO\", \"BAR\"],\n  \"ponypath\": [\"/path/to/pony/module\", \"/another/path\"]\n}\n```\n\n## Building from source\n\nFirst, follow the [ponyc build documentation] and make sure you have everything to build `ponyc`. Then building `pony-lsp` is as easy as:\n\n```bash\nmake pony-lsp\n```\n\n[ponyc build documentation]: ../../BUILD.md\n\n## Reporting Issues\n\nIf `pony-lsp` is not working as expected, please [open an issue]. Include a minimal Pony program that reproduces the problem, the `pony-lsp` version, and the LSP client/editor you are using.\n\n[open an issue]: https://github.com/ponylang/ponyc/issues/new\n","isInternal":false,"tokens":1715,"sizeBytes":7695}],"systemPromptSnippet":"<yakaai_skills repo=\"ponylang/ponyc\">\n<!-- File: .ci-scripts/AGENTS.md (Tokens: ~1166 | Category: root-instruction) -->\n# .ci-scripts\n\nHelper scripts that CI workflows call (Python and shell). Not part of the ponyc\nbuild, the runtime, or the stdlib — these only run in GitHub Actions (and locally\nwhen you want to exercise them).\n\n## Python conventions\n\n- **Stdlib only.** CI runners have no `pip install` step, so scripts must not\n  import third-party packages. Use `urllib`, `json`, `subprocess`, `tarfile`,\n  etc. — never `requests`, `pyyaml`, `pytest`, …\n- **ruff lints everything here** (`.github/workflows/lint-python.yml`,\n  which now only lints — the tests live in `test-ci-scripts.yml` and\n  `test-rt-stress.yml`). There is no ruff config file, so the defaults apply. A\n  `lint` job runs `ruff check .ci-scripts`; a separate `lint-rt-stress` job runs\n  `ruff check test/rt-stress` — these conventions also cover `test/rt-stress/`,\n  whose generative stress harness carries Python (an orchestrator) under the same\n  rules even though it lives with a test rather than in `.ci-scripts/`.\n- **Tests are `*_test.py` files, self-contained, no pytest.** Each is runnable as\n  `python3 <path>/<name>_test.py`, exits 0 on pass / 1 on fail, and prints a\n  one-line summary (see `pr_changed_suites_test.py` for the shape).\n  **`test-ci-scripts.yml` discovers every `*_test.py` under `.ci-scripts/` (via\n  `find … -name '*_test.py'`) and runs each one; `test-rt-stress.yml` does the\n  same for `test/rt-stress/`, on Linux and Windows** — so a new `*_test.py` is\n  picked up automatically — you do not register it anywhere. When you add or\n  change a script, add or update its `*_test.py`; it will run in CI on the next\n  PR.\n- After writing a test, break each assertion once to confirm it actually fires\n  (counterfactual check) before trusting it.\n\n## Subdirectory and import conventions\n\n- Related scripts are grouped in a subdirectory (e.g. `libs-cache/`). A script\n  imports its **siblings by module name** (`import cache_arch`); this resolves\n  because the scripts are invoked by full path, so `sys.path[0]` is the script's\n  own directory. **Move a script together with its siblings and its importers**,\n  or the imports break.\n- A subdirectory mixes **entry scripts** (runnable by full path, each with a\n  `main` and a CLI) with **support-library modules** (import-only, no `main`). In\n  `libs-cache/` the library modules are `common.py` (die/info/`require_env`/the\n  orchestrator helpers, plus the shared entry-script path constants\n  `MAIN_CACHE`/`BRANCH_CACHE`/`PROMOTE`), `registry.py` (the GHCR registry-v2\n  push/pull client, including `copy` — the cross-namespace promote the warmer uses\n  to reuse a branch build), `ghpackages.py` (the GitHub-packages REST client), and\n  `cache_arch.py` (arch normalization); the entry scripts (`*_libs_cache.py`,\n  `resolve_libs_cache.py`) are thin layers over them — `promote_libs_cache.py`\n  resolves the branch (source) and main (destination) names and calls\n  `registry.copy`. Shared logic belongs in a library module, not copied across\n  entry scripts.\n- A module that derives the **repo root** from `__file__` (e.g.\n  `libs-cache/registry.py`'s `repo_root()`) hardcodes the directory depth with a\n  fixed number of `os.path.dirname` calls. If you move it to a different depth,\n  update that count — `libs-cache/repo_root_test.py` pins it, so a wrong depth\n  fails the test job rather than only surfacing at runtime in CI.\n\n## Pointers\n\n- The libs cache (the `libs-cache/` scripts: warmer, consumers, branch cache,\n  retention, clear) is documented in `libs-cache/README.md`.\n- The `bsd/` scripts provision the BSD CI VMs and run the in-VM libs handling,\n  both shared by `update-lib-cache.yml` and `ponyc-tier3.yml`, one script per\n  platform: `{freebsd,openbsd,dragonfly}-provision.bash` (+ `dfly_configure_vm.py`)\n  boot and set up the VM, and `{freebsd,openbsd,dragonfly}-libs-cache.bash\n  <operation>` restores or builds-and-pushes the libs cache over ssh. See\n  `libs-cache/README.md`.\n- `windows-install-deps.ps1` installs the pinned msys2 lldb and the libraries it\n  loads against. It holds the only copy of that pin, and every Windows job\n  needing lldb calls it. Don't inline the `pacman` block into a workflow: the\n  PR job that checks lldb's behavior has to install the same lldb the\n  scheduled lanes do, or it guards a version nothing runs. The script's header\n  carries the pin history.\n\n<!-- File: AGENTS.md (Tokens: ~1927 | Category: root-instruction) -->\n# ponyc\n\n<!-- contributor-only -->\n## Contributing with an AI assistant\n\nThis is a Pony project. The ponylang org maintains a set of LLM coding skills. Get set up with them before contributing:\n\n- **Not set up yet?** Install them once:\n\n  ```bash\n  git clone https://github.com/ponylang/llm-skills.git\n  cd llm-skills\n  python install.py\n  ```\n\n- **Already set up?** Make sure you're on the latest. If you installed with the script above, `git pull` in the directory where you cloned `llm-skills` and the symlinked skills update automatically — if you set them up another way, refresh them however that setup expects.\n\nSee the [llm-skills README](https://github.com/ponylang/llm-skills) for details and other harnesses.\n\nWhen you start working on this project, load the `pony-skills` skill — it tells your assistant which Pony skill to use for each task.\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md).\n<!-- /contributor-only -->\n\n## Building\n\nThe build uses CMake presets. See [BUILD.md](BUILD.md) for platform-specific instructions and build options.\n\nBuild ponyc in debug mode:\n\n```bash\ncmake --build --preset debug\n```\n\nThe output goes in `build/debug`. Use `--preset release` for a release build. The vendored LLVM libraries must be built first with `cmake -P lib/build-libs.cmake` — this only needs to run once (or when the LLVM submodule changes).\n\n## Testing\n\nTests are registered with ctest and grouped by label. Two labels matter:\n\n- **`ci-core`** — built by a normal `cmake --build --preset debug`. The C/C++ compiler tests (`libponyc.tests`), the runtime tests (`libponyrt.tests`), the stdlib suite, full-program integration tests, and grammar validation.\n- **`tools`** — **not** built by a normal `cmake --build --preset debug`. The self-hosted tool test suites: pony-compiler, pony-lsp, pony-lint, pony-doc, pony-dep, and pony-agent-server.\n\n### Core tests (ci-core)\n\nRun the full core suite:\n\n```bash\nctest --preset debug -L ci-core\n```\n\nTwo environment variables control Pony compilation features at test time:\n\n- `PONY_DEBUG=1` — compile Pony sources with `-d` (debug codegen)\n- `PONY_THIN_LTO=1` — compile Pony sources with `--thin-lto`\n\nThese apply to stdlib tests, full-program tests, examples, and tool builds. Set them as command-line prefixes (`PONY_DEBUG=1 ctest ...`) or `export` them. CI runs tests in both codegen modes — the first `ctest` pass sets `PONY_DEBUG=1`, the second runs only the Pony-compilation tests (`stdlib`, `full-programs`) without it. PR builds run only debug codegen; release-mode-checks, weekly-checks, tier-3, and rolling-releases run both.\n\nRun individual tests by name:\n\n- `ctest --preset debug -R libponyc.tests` — compiler C/C++ unit tests (GTest)\n- `ctest --preset debug -R libponyrt.tests` — runtime C/C++ unit tests (GTest)\n- `ctest --preset debug -R stdlib` — stdlib test suite\n- `ctest --preset debug -R full-programs` — compile-and-run integration tests\n- `ctest --preset debug -R validate-grammar` — checks `pony.g` against the compiler\n- `ctest --preset debug -R examples` — compiles all examples (not part of `ci-core`; runs locally and in the weekly `build-examples.yml` workflow)\n\n#### Per-module stdlib tests\n\nA single module's tests can be compiled and run without rebuilding the full stdlib suite:\n\n```bash\ncd build/debug && ./ponyc -b stdlib --checktree -Dopenssl_3.0.x --pic ../../packages/collections\n./stdlib --sequential\n```\n\nTo compile with debug codegen, add `-d`: `./ponyc -d -b stdlib ...`. To compile with thin LTO, add `--thin-lto`.\n\nThe SSL flag must match the installed SSL library: `-Dopenssl_3.0.x` for OpenSSL 3.x, `-Dopenssl_1.1.x` for OpenSSL 1.1.x, `-Dlibressl` for LibreSSL. CMake detects this automatically for `ctest` runs; the manual command needs it explicitly.\n\n### Tool tests\n\nTool test binaries must be built explicitly before running. Build one target, then run through ctest:\n\n- `cmake --build --preset debug --target pony-compiler-tests && ctest --preset debug -R pony-compiler-tests`\n- `cmake --build --preset debug --target pony-lsp-tests && ctest --preset debug -R pony-lsp-tests`\n- `cmake --build --preset debug --target pony-lint-tests && ctest --preset debug -R pony-lint-tests`\n- `cmake --build --preset debug --target pony-doc-tests && ctest --preset debug -R pony-doc-tests`\n- `cmake --build --preset debug --target pony-dep-tests && ctest --preset debug -R pony-dep-tests`\n- `cmake --build --preset debug --target pony-agent-server-tests && ctest --preset debug -R pony-agent-server-tests`\n\nBuild all tool test binaries at once with `cmake --build --preset debug --target tool-tests`, then run them with `ctest --preset debug -L tools`.\n\nThe first build of a tool test binary compiles from Pony source (~60s); the binary is not recompiled when nothing under its source tree changed. On Windows, use the `windows-x86-64-debug` preset the same way.\n\n### Linting tool source\n\nRun the `pony-lint` binary (built by a normal `cmake --build --preset debug`) against a tool's directory:\n\n```bash\ncd build/debug && PONYPATH=../../tools/lib/ponylang/pony_compiler ./pony-lint ../../tools/pony-lint/\n```\n\nThe same works for `../../tools/pony-lsp/`, `../../tools/pony-doc/`, `../../tools/pony-dep/`, and `../../tools/pony-agent-server/`.\n\n## Opening PRs\n\nBefore opening a PR, verify:\n\n- **Debug build succeeds** (if compiled code was changed): `cmake --build --preset debug` completes without errors.\n- **Relevant tests pass locally**: Run the tests that cover your change — see the Testing section above for the right commands. Don't skip a test suite because \"CI will catch it.\"\n- **pony-lint passes** (if Pony source was changed): Lint any Pony code you touched. See \"Linting tool source\" above for the command.\n- **Grammar validation passes** (if the parser or `pony.g` was changed): `ctest --preset debug -R validate-grammar`.\n- **Release notes updated** (if the change is user-facing): Load the `pony-release-notes` skill for what needs updating and how.\n\n## Adding threads or locks\n\nNever add a mutex or a new thread unless the idea came from the human operator or a committer expressly approved it. Pony's runtime is built on a deliberate concurrency model, and adding either is an architectural decision, not an implementation detail. If a change looks like it needs one, stop and raise it rather than writing it.\n\n## Declaring runtime FFI functions on Windows\n\nOn the Windows MSVC build, libponyrt's `.c` files compile as C++ (`src/libponyrt/CMakeLists.txt` sets `LANGUAGE CXX` on them). A `PONY_API` function whose definition is not inside a `PONY_EXTERN_C_BEGIN`/`PONY_EXTERN_C_END` region gets a C++-mangled symbol, which the stdlib FFI — referencing the plain C name — can't resolve at link (`lld-link: undefined symbol`). Linux, macOS, and BSD compile `.c` as C, so the failure is Windows-only and won't show up in a local build there. Every runtime `.c` already wraps its definitions in that region (see `lang/stat.c`, `lang/socket.c`); put any new `PONY_API` function inside it. To check a symbol's linkage, run `dumpbin /SYMBOLS <lib> | findstr <name>` from a vcvars64 shell: a C-linkage function shows the plain name, a mangled one shows the C++ form.\n\n## Dispatching workflows on a branch\n\n`gh workflow run <workflow> --ref <branch> -f ref=<branch>` — both flags are needed. `--ref` selects which branch the workflow YAML is read from; `-f ref=` sets the `inputs.ref` that the checkout step uses. Without `-f ref=`, the job checks out main regardless of `--ref`.\n\n<!-- File: benchmark/AGENTS.md (Tokens: ~56 | Category: root-instruction) -->\n# benchmark/\n\n## Conventions\n\n- Do not add coupling comments in `src/libponyrt/` pointing back to\n  `benchmark/`. Benchmarks depend on runtime constants — the coupling\n  risk is theirs, and the note goes on the benchmark side alone.\n\n<!-- File: .ci-scripts/CLAUDE.md (Tokens: ~5 | Category: claude-rule) -->\n@AGENTS.md\n\n<!-- File: CLAUDE.md (Tokens: ~5 | Category: claude-rule) -->\n@AGENTS.md\n\n<!-- File: .claude/skills/32-bit-support-validation/SKILL.md (Tokens: ~6218 | Category: anthropic-skill) -->\n---\nname: 32-bit-support-validation\ndescription: Load when validating ponyc 32-bit support. Covers syncing the test machine, building LLVM and ponyc, and running the full test suite on a 32-bit ARM RPi4.\ndisable-model-invocation: false\n---\n\n# Validating 32-bit support\n\nRun the test suite on a 32-bit ARM machine to verify nothing is broken.\nThe default test machine is `pony-rpi4-32` (user `pi`, checkout at\n`/home/pi/code/ponylang/ponyc`). It has a persistent checkout and a built LLVM\nthat only needs rebuilding when the vendored LLVM changes.\n\nThis is slow — LLVM builds take many hours, ponyc builds take roughly an hour each,\nand the test suites add more on top. Run long steps detached and poll their logs.\n\n## Known ILP32 failures\n\nThe 32-bit address space (~3 GB user) constrains what can be compiled and\nlinked as a single binary. These are current failures, not accepted\nlimitations — they need to be fixed.\n\n- **The monolithic stdlib test** (`ctest -R stdlib`) compiles every stdlib\n  package into one binary. LLVM runs out of memory during code generation\n  (the \"Function prototypes\" phase), regardless of LTO mode, jemalloc, or\n  debug/release codegen. Stdlib tests are run per-package instead\n  (step 4c / 5c) as a workaround.\n\n## Gotchas (read first)\n\n- **Check the login shell.** If the machine's login shell is not bash (e.g. fish),\n  every SSH command must go through bash explicitly — either\n  `ssh pi@pony-rpi4-32 bash << 'ENDSSH'` for heredocs or\n  `ssh pi@pony-rpi4-32 bash -c '...'` for one-liners.\n- **Check the compiler.** The cmake presets default to `clang`/`clang++`. If the\n  machine only has GCC, override on every cmake configure:\n  `-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++`.\n- **`-mtune=generic` is invalid on ARM GCC.** The `native` preset sets\n  `-march=native -mtune=generic`, but GCC on ARM rejects `generic`. Override:\n  `\"-DCMAKE_C_FLAGS=-march=native\" \"-DCMAKE_CXX_FLAGS=-march=native\"`.\n  The LLVM build (`lib/build-libs.cmake`) does not set these flags, so it is not\n  affected.\n- **Don't run concurrent builds to the same build directory.** If an SSH session\n  times out mid-build, kill the orphaned cmake process before starting another —\n  two builds writing to the same directory corrupt it.\n- **Detach long builds.** Write a bash script to the machine, run it with `nohup`,\n  and poll the log file. Don't use `tee` over SSH — it keeps the connection alive\n  and the SSH session will time out. Redirect to a file instead.\n- **OpenSSL version matters for per-package tests.** cmake auto-detects the SSL\n  library for ctest, but per-package compilation (steps 4c/5c) needs the flag\n  passed explicitly. The scripts detect it via `openssl version`.\n- **LLVM build parallelism.** LLVM is memory-hungry. On machines with limited RAM,\n  use `-DJOBS=2` (or lower) to avoid OOM during the LLVM build.\n\n\n## Step 1 — verify the machine is reachable\n\n```bash\nssh -o ConnectTimeout=5 pi@pony-rpi4-32 bash -c '\"uname -m && cat /etc/os-release | head -2\"'\n```\n\nIf the machine is not reachable, stop and ask where to run the 32-bit validation.\nYou need: an SSH-accessible 32-bit ARM Linux machine with a ponyc checkout, GCC,\ncmake ≥ 3.25, and Python 3.\n\n## Step 2 — update the repo\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\ngit fetch origin\ngit checkout main\ngit pull origin main\ngit submodule update --init lib/llvm/src\ngit log --oneline -3\ngit -C lib/llvm/src rev-parse HEAD\nENDSSH\n```\n\nConfirm the log shows the expected HEAD of main, and the submodule hash matches\n`LLVM_DESIRED_HASH` in `lib/CMakeLists.txt`.\n\n## Step 3 — ensure LLVM is built and current\n\nLLVM is \"current\" when all three of these are true:\n\n1. The submodule at `lib/llvm/src` is at the commit specified by `LLVM_DESIRED_HASH`\n   in `lib/CMakeLists.txt`.\n2. The patches in `lib/llvm/patches/` have the hash specified by\n   `PATCHES_DESIRED_HASH` in `lib/CMakeLists.txt`.\n3. `build/libs/` exists and contains the LLVM built from that source with those\n   patches applied (subdirectories `lib/`, `bin/`, `include/`).\n\n### Check the submodule hash\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\necho \"desired: $(grep 'set(LLVM_DESIRED_HASH' lib/CMakeLists.txt | grep -oP '(?<=\")[^\"]+(?=\")')\"\necho \"actual:  $(git -C lib/llvm/src rev-parse HEAD)\"\nENDSSH\n```\n\nThese must match. If they don't, `git submodule update --init` in step 2 didn't work\n— re-run it, or investigate why the submodule is at the wrong commit.\n\n### Check whether build artifacts exist\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"ls /home/pi/code/ponylang/ponyc/build/libs/lib/ 2>/dev/null | head -3 || echo NO_LIBS\"'\n```\n\nIf `NO_LIBS`, a full rebuild is needed (8+ hours on the RPi — see \"Full rebuild\"\nbelow).\n\n### Validate the built LLVM against current source and patches\n\nEven when `build/libs/` exists and the submodule hash matches, the built LLVM might\nbe stale — the patches could have changed, or the build could be left over from a\ndifferent branch. Run `lib/build-libs.cmake` to validate everything. It checks both\n`LLVM_DESIRED_HASH` and `PATCHES_DESIRED_HASH` internally, resets the submodule\nsource tree, applies the current patches, and does an incremental build. If the source\nand patches haven't changed, the build step is a no-op (minutes, not hours).\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\ncmake -DJOBS=2 -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ -P lib/build-libs.cmake 2>&1\nENDSSH\n```\n\nThree outcomes:\n\n- **Completes with no errors** → LLVM is current, proceed to step 4.\n- **Fails with a hash mismatch** (`Patch hash actual '...' does not match\n  desired '...'`) → the patches changed since the last build. Do a full rebuild\n  (see below).\n- **Fails with a submodule hash error** → the submodule is wrong. Re-run\n  `git submodule update --init lib/llvm/src` in step 2 and try again.\n\nIf this step takes more than a few minutes, it is doing a real rebuild — detach it\n(see \"Full rebuild\" below) and poll.\n\n### Full rebuild\n\nWhen a clean LLVM build is needed (no `build/libs/`, or a hash mismatch), remove\nthe old build artifacts and rebuild from scratch. This takes 8+ hours on the RPi.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-libs-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\nrm -rf build/libs build/build_libs\ncmake -DJOBS=2 -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++ -P lib/build-libs.cmake > /home/pi/libs-build.log 2>&1\necho \"LIBS_BUILD_EXIT_CODE=$?\" >> /home/pi/libs-build.log\nSCRIPT\nchmod +x /home/pi/run-libs-build.sh\nnohup /home/pi/run-libs-build.sh > /dev/null 2>&1 &\necho \"libs build started pid $!\"\nENDSSH\n```\n\nPoll until done:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -3 /home/pi/libs-build.log\"'\n```\n\nWait for `LIBS_BUILD_EXIT_CODE=0`. A nonzero exit code means the build failed —\nread the log above the exit code line for the error.\n\n## Test matrix\n\nTwo runtime builds (debug and release), each running the tests twice: once with\n`PONY_DEBUG=1` (debug codegen) and once without (release codegen). The env var\ncontrols whether ponyc compiles Pony sources with `-d`.\n\nEach runtime build runs these tests in both codegen modes:\n\n**ci-core minus stdlib** (run via ctest, excludes the monolithic stdlib test):\n\n- **check-version** — ponyc `--version` exits successfully\n- **libponyc.tests** — compiler C/C++ unit tests (GTest)\n- **libponyrt.tests** — runtime C/C++ unit tests (GTest)\n- **full-programs** — compile-and-run integration tests\n- **full-program-runner-rejects-broken-config** — verifies the test runner rejects bad config\n- **validate-grammar** — `pony.g` validated against the compiler\n\n**stdlib per-package** (run via ponyc directly, one package at a time):\n\nEach stdlib package is compiled and tested individually to stay within the\nILP32 address space limit. The full list of testable packages is derived from\n`packages/stdlib/_test.pony`.\n\n**tools** (all 5 tool test suites):\n\n- **pony-compiler-tests** — self-hosted compiler tool tests\n- **pony-dep-tests** — dependency tool tests\n- **pony-doc-tests** — documentation tool tests\n- **pony-lint-tests** — linter tool tests\n- **pony-lsp-tests** — language server tool tests\n\n## Step 4 — build and test with debug runtime\n\n### 4a. Configure\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\nrm -rf build/build_debug\ncmake --preset debug \\\n  -DCMAKE_C_COMPILER=gcc \\\n  -DCMAKE_CXX_COMPILER=g++ \\\n  \"-DCMAKE_C_FLAGS=-march=native\" \\\n  \"-DCMAKE_CXX_FLAGS=-march=native\"\nENDSSH\n```\n\nVerify \"Configuring done\" and \"Build files have been written\" in the output.\n\n### 4b. Build ponyc (detached)\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset debug > /home/pi/debug-build.log 2>&1\necho \"DEBUG_BUILD_EXIT_CODE=$?\" >> /home/pi/debug-build.log\nSCRIPT\nchmod +x /home/pi/run-debug-build.sh\nnohup /home/pi/run-debug-build.sh > /dev/null 2>&1 &\necho \"debug build started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -3 /home/pi/debug-build.log\"'\n```\n\nWait for `DEBUG_BUILD_EXIT_CODE=0`. Then verify all binaries built:\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc/build/debug\nfor bin in ponyc pony-compiler pony-dep pony-doc pony-lint pony-lsp; do\n  test -x \"$bin\" && echo \"$bin: OK\" || echo \"$bin: MISSING\"\ndone\nENDSSH\n```\n\nAll six must show OK.\n\n### 4c. Run stdlib tests per-package (debug runtime)\n\nDetect the SSL flag, then compile and run each testable package individually\nin both codegen modes. Run detached — this iterates over ~30 packages.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-stdlib-perpkg.sh << 'SCRIPT'\n#!/bin/bash\nset -u\ncd /home/pi/code/ponylang/ponyc\n\nPONYC=./build/debug/ponyc\nOUTDIR=./build/debug\nPKGDIR=./packages\n\n# Detect OpenSSL version for the SSL flag\nssl_ver=$(openssl version 2>/dev/null | head -1)\ncase \"$ssl_ver\" in\n  OpenSSL\\ 3.*) SSL_FLAG=-Dopenssl_3.0.x ;;\n  OpenSSL\\ 1.1.*) SSL_FLAG=-Dopenssl_1.1.x ;;\n  LibreSSL*) SSL_FLAG=-Dlibressl ;;\n  *) echo \"ERROR: cannot detect OpenSSL version: $ssl_ver\" >&2; exit 1 ;;\nesac\necho \"Detected SSL: $ssl_ver -> $SSL_FLAG\"\n\n# Packages with tests, from packages/stdlib/_test.pony\nPACKAGES=(\n  actor_pinning\n  encode/base64\n  buffered\n  builtin_test\n  bureaucracy\n  cli\n  collections\n  collections/persistent\n  constrained_types\n  crypto\n  files\n  format\n  http_client\n  ini\n  iregex\n  itertools\n  json\n  math\n  net\n  pony_test\n  process\n  promises\n  random\n  runtime_info\n  signals\n  strings\n  term\n  time\n  uri\n)\n\nLOGFILE=/home/pi/debug-stdlib-perpkg.log\n> \"$LOGFILE\"\noverall_rc=0\n\nfor codegen in debug release; do\n  echo \"=== $codegen codegen ===\" >> \"$LOGFILE\"\n  for pkg in \"${PACKAGES[@]}\"; do\n    name=$(echo \"$pkg\" | tr '/' '_')\n    debug_flag=\"\"\n    if [ \"$codegen\" = \"debug\" ]; then\n      debug_flag=\"-d\"\n    fi\n\n    echo -n \"  $pkg ($codegen): \" >> \"$LOGFILE\"\n\n    # Compile\n    $PONYC $debug_flag -b stdlib_test --checktree $SSL_FLAG --pic \\\n      \"$PKGDIR/$pkg\" -o \"$OUTDIR\" >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"COMPILE FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    # Run\n    \"$OUTDIR/stdlib_test\" --sequential >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"TEST FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    echo \"PASS\" >> \"$LOGFILE\"\n    rm -f \"$OUTDIR/stdlib_test\"\n  done\ndone\n\necho \"DEBUG_STDLIB_PERPKG_EXIT_CODE=$overall_rc\" >> \"$LOGFILE\"\nSCRIPT\nchmod +x /home/pi/run-debug-stdlib-perpkg.sh\nnohup /home/pi/run-debug-stdlib-perpkg.sh > /dev/null 2>&1 &\necho \"debug per-package stdlib tests started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -5 /home/pi/debug-stdlib-perpkg.log\"'\n```\n\nWait for `DEBUG_STDLIB_PERPKG_EXIT_CODE=0`.\n\n### 4d. Run ci-core tests minus stdlib (debug runtime)\n\nRun the ci-core tests excluding the monolithic stdlib, in both codegen modes.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-ci-core.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\necho \"=== debug codegen ===\" > /home/pi/debug-ci-core.log\nPONY_DEBUG=1 ctest --preset debug -L ci-core -E \"^stdlib$\" >> /home/pi/debug-ci-core.log 2>&1\nrc1=$?\necho \"=== release codegen ===\" >> /home/pi/debug-ci-core.log\nctest --preset debug -R \"^full-programs$\" >> /home/pi/debug-ci-core.log 2>&1\nrc2=$?\n\nif [ $rc1 -eq 0 ] && [ $rc2 -eq 0 ]; then\n  echo \"DEBUG_CI_CORE_EXIT_CODE=0\" >> /home/pi/debug-ci-core.log\nelse\n  echo \"DEBUG_CI_CORE_EXIT_CODE=1 (debug_codegen=$rc1 release_codegen=$rc2)\" >> /home/pi/debug-ci-core.log\nfi\nSCRIPT\nchmod +x /home/pi/run-debug-ci-core.sh\nnohup /home/pi/run-debug-ci-core.sh > /dev/null 2>&1 &\necho \"ci-core tests started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -5 /home/pi/debug-ci-core.log\"'\n```\n\nWait for `DEBUG_CI_CORE_EXIT_CODE=0`.\n\n### 4e. Build tool test binaries (after ponyc build completes)\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-tool-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset debug --target tool-tests > /home/pi/debug-tool-build.log 2>&1\necho \"TOOL_BUILD_EXIT_CODE=$?\" >> /home/pi/debug-tool-build.log\nSCRIPT\nchmod +x /home/pi/run-debug-tool-build.sh\nnohup /home/pi/run-debug-tool-build.sh > /dev/null 2>&1 &\necho \"tool build started pid $!\"\nENDSSH\n```\n\nPoll and wait for `TOOL_BUILD_EXIT_CODE=0`.\n\n### 4f. Run tool tests (debug runtime)\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-debug-tools.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\nrc=0\nctest --preset debug -L tools >> /home/pi/debug-tools.log 2>&1\nrc=$?\necho \"DEBUG_TOOLS_EXIT_CODE=$rc\" >> /home/pi/debug-tools.log\nSCRIPT\nchmod +x /home/pi/run-debug-tools.sh\nnohup /home/pi/run-debug-tools.sh > /dev/null 2>&1 &\necho \"tool tests started pid $!\"\nENDSSH\n```\n\nPoll:\n\n```bash\nssh pi@pony-rpi4-32 bash -c '\"tail -5 /home/pi/debug-tools.log\"'\n```\n\nWait for `DEBUG_TOOLS_EXIT_CODE=0`.\n\n## Step 5 — build and test with release runtime\n\nRepeat the full test matrix with a release-built runtime and compiler.\n\n### 5a. Configure\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncd /home/pi/code/ponylang/ponyc\nrm -rf build/build_release\ncmake --preset release \\\n  -DCMAKE_C_COMPILER=gcc \\\n  -DCMAKE_CXX_COMPILER=g++ \\\n  \"-DCMAKE_C_FLAGS=-march=native\" \\\n  \"-DCMAKE_CXX_FLAGS=-march=native\"\nENDSSH\n```\n\n### 5b. Build ponyc (detached)\n\nSame as step 4b but using the release preset.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset release > /home/pi/release-build.log 2>&1\necho \"RELEASE_BUILD_EXIT_CODE=$?\" >> /home/pi/release-build.log\nSCRIPT\nchmod +x /home/pi/run-release-build.sh\nnohup /home/pi/run-release-build.sh > /dev/null 2>&1 &\necho \"release build started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_BUILD_EXIT_CODE=0`. Verify binaries as in 4b\n(using `build/release` instead of `build/debug`).\n\n### 5c. Run stdlib tests per-package (release runtime)\n\nSame as step 4c but using the release ponyc.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-stdlib-perpkg.sh << 'SCRIPT'\n#!/bin/bash\nset -u\ncd /home/pi/code/ponylang/ponyc\n\nPONYC=./build/release/ponyc\nOUTDIR=./build/release\nPKGDIR=./packages\n\n# Detect OpenSSL version for the SSL flag\nssl_ver=$(openssl version 2>/dev/null | head -1)\ncase \"$ssl_ver\" in\n  OpenSSL\\ 3.*) SSL_FLAG=-Dopenssl_3.0.x ;;\n  OpenSSL\\ 1.1.*) SSL_FLAG=-Dopenssl_1.1.x ;;\n  LibreSSL*) SSL_FLAG=-Dlibressl ;;\n  *) echo \"ERROR: cannot detect OpenSSL version: $ssl_ver\" >&2; exit 1 ;;\nesac\necho \"Detected SSL: $ssl_ver -> $SSL_FLAG\"\n\nPACKAGES=(\n  actor_pinning\n  encode/base64\n  buffered\n  builtin_test\n  bureaucracy\n  cli\n  collections\n  collections/persistent\n  constrained_types\n  crypto\n  files\n  format\n  http_client\n  ini\n  iregex\n  itertools\n  json\n  math\n  net\n  pony_test\n  process\n  promises\n  random\n  runtime_info\n  signals\n  strings\n  term\n  time\n  uri\n)\n\nLOGFILE=/home/pi/release-stdlib-perpkg.log\n> \"$LOGFILE\"\noverall_rc=0\n\nfor codegen in debug release; do\n  echo \"=== $codegen codegen ===\" >> \"$LOGFILE\"\n  for pkg in \"${PACKAGES[@]}\"; do\n    name=$(echo \"$pkg\" | tr '/' '_')\n    debug_flag=\"\"\n    if [ \"$codegen\" = \"debug\" ]; then\n      debug_flag=\"-d\"\n    fi\n\n    echo -n \"  $pkg ($codegen): \" >> \"$LOGFILE\"\n\n    $PONYC $debug_flag -b stdlib_test --checktree $SSL_FLAG --pic \\\n      \"$PKGDIR/$pkg\" -o \"$OUTDIR\" >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"COMPILE FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    \"$OUTDIR/stdlib_test\" --sequential >> \"$LOGFILE\" 2>&1\n    if [ $? -ne 0 ]; then\n      echo \"TEST FAILED\" >> \"$LOGFILE\"\n      overall_rc=1\n      continue\n    fi\n\n    echo \"PASS\" >> \"$LOGFILE\"\n    rm -f \"$OUTDIR/stdlib_test\"\n  done\ndone\n\necho \"RELEASE_STDLIB_PERPKG_EXIT_CODE=$overall_rc\" >> \"$LOGFILE\"\nSCRIPT\nchmod +x /home/pi/run-release-stdlib-perpkg.sh\nnohup /home/pi/run-release-stdlib-perpkg.sh > /dev/null 2>&1 &\necho \"release per-package stdlib tests started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_STDLIB_PERPKG_EXIT_CODE=0`.\n\n### 5d. Run ci-core tests minus stdlib (release runtime)\n\nSame as step 4d but using the release preset.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-ci-core.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\necho \"=== debug codegen ===\" > /home/pi/release-ci-core.log\nPONY_DEBUG=1 ctest --preset release -L ci-core -E \"^stdlib$\" >> /home/pi/release-ci-core.log 2>&1\nrc1=$?\necho \"=== release codegen ===\" >> /home/pi/release-ci-core.log\nctest --preset release -R \"^full-programs$\" >> /home/pi/release-ci-core.log 2>&1\nrc2=$?\n\nif [ $rc1 -eq 0 ] && [ $rc2 -eq 0 ]; then\n  echo \"RELEASE_CI_CORE_EXIT_CODE=0\" >> /home/pi/release-ci-core.log\nelse\n  echo \"RELEASE_CI_CORE_EXIT_CODE=1 (debug_codegen=$rc1 release_codegen=$rc2)\" >> /home/pi/release-ci-core.log\nfi\nSCRIPT\nchmod +x /home/pi/run-release-ci-core.sh\nnohup /home/pi/run-release-ci-core.sh > /dev/null 2>&1 &\necho \"ci-core tests started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_CI_CORE_EXIT_CODE=0`.\n\n### 5e. Build and run tool tests (release runtime)\n\nSame as steps 4e/4f but using the release preset.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-tool-build.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\ncmake --build --preset release --target tool-tests > /home/pi/release-tool-build.log 2>&1\necho \"TOOL_BUILD_EXIT_CODE=$?\" >> /home/pi/release-tool-build.log\nSCRIPT\nchmod +x /home/pi/run-release-tool-build.sh\nnohup /home/pi/run-release-tool-build.sh > /dev/null 2>&1 &\necho \"tool build started pid $!\"\nENDSSH\n```\n\nPoll and wait for `TOOL_BUILD_EXIT_CODE=0`.\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\ncat > /home/pi/run-release-tools.sh << 'SCRIPT'\n#!/bin/bash\ncd /home/pi/code/ponylang/ponyc\nctest --preset release -L tools > /home/pi/release-tools.log 2>&1\nrc=$?\necho \"RELEASE_TOOLS_EXIT_CODE=$rc\" >> /home/pi/release-tools.log\nSCRIPT\nchmod +x /home/pi/run-release-tools.sh\nnohup /home/pi/run-release-tools.sh > /dev/null 2>&1 &\necho \"tool tests started pid $!\"\nENDSSH\n```\n\nPoll and wait for `RELEASE_TOOLS_EXIT_CODE=0`.\n\n## Step 6 — report results\n\nSummarize which steps passed and which failed. Report each result individually:\n\n- LLVM version and whether it needed rebuilding\n- Debug runtime build: pass/fail (ponyc, pony-compiler, pony-dep, pony-doc,\n  pony-lint, pony-lsp built)\n- Debug runtime — ci-core minus stdlib (debug codegen): pass/fail\n- Debug runtime — ci-core minus stdlib (release codegen): pass/fail\n- Debug runtime — stdlib per-package (debug codegen): pass/fail per package\n- Debug runtime — stdlib per-package (release codegen): pass/fail per package\n- Debug runtime — tool tests: pass/fail\n- Release runtime build: pass/fail\n- Release runtime — ci-core minus stdlib (debug codegen): pass/fail\n- Release runtime — ci-core minus stdlib (release codegen): pass/fail\n- Release runtime — stdlib per-package (debug codegen): pass/fail per package\n- Release runtime — stdlib per-package (release codegen): pass/fail per package\n- Release runtime — tool tests: pass/fail\n\n## Cleanup\n\nKill any orphaned build processes and remove log files when done:\n\n```bash\nssh pi@pony-rpi4-32 bash << 'ENDSSH'\npkill -f \"cmake --build\" 2>/dev/null\npkill -f \"ctest --preset\" 2>/dev/null\npkill -f \"run-.*\\.sh\" 2>/dev/null\nrm -f /home/pi/run-*.sh /home/pi/*-build.log /home/pi/*-tool-build.log /home/pi/*-ci-core.log /home/pi/*-tools.log /home/pi/*-stdlib-perpkg.log\nENDSSH\n```\n\nDo not remove the `build/` directory — it holds the built LLVM and ponyc, which\npersist for the next validation run.\n\n<!-- File: .claude/skills/add-linux-release-target/SKILL.md (Tokens: ~5126 | Category: anthropic-skill) -->\n---\nname: add-linux-release-target\ndescription: Load when adding a new Linux distro/version (e.g. Ubuntu 26.04, Alpine 3.24) as a ponyc release target. Covers builder Dockerfile creation, image build dispatch and tag discovery, workflow updates, docs, release notes, and the post-merge Last Week in Pony announcement.\ndisable-model-invocation: false\n---\n\n# Adding a New Linux Distro/Version as a ponyc Release Target\n\n## Scope\n\nUse this skill when adding a new distro/version (e.g. Ubuntu 26.04) so ponyc produces release and nightly artifacts users can install via ponyup.\n\nThe procedure encodes the **multi-platform builder pattern** — a single `.ci-dockerfiles/<distro><version>-builder/` directory containing one Dockerfile and one `build-and-push.bash` that builds and publishes a multi-arch image (linux/amd64 + linux/arm64) under `ghcr.io/ponylang/ponyc-ci-<distro><version>-builder`. This is the pattern used by Alpine 3.23+. New release-target adds — including new Ubuntu versions — should use it. Older builders still use a per-arch pattern (separate `x86-64-unknown-linux-...-builder/` and `arm64-unknown-linux-...-builder/` directories); leave those alone, don't migrate them as part of an add.\n\n**Out of scope:**\n- CI test-only workflows: `pr.yml`, `ponyc-tier2.yml`, `ponyc-weekly-checks.yml`, `stress-test-*.yml`. These reference Linux images for testing ponyc itself, not for producing release artifacts. Don't touch them.\n- `pr-docker-image-validation.yml` validates the user-facing `.dockerfiles/{nightly,release}/Dockerfile` images (Alpine-based). Not affected by adding a release target.\n- Dropping older releases. We support only the two newest releases of a distro, so adding a newer one eventually means dropping the now-oldest — but that drop is a separate, by-hand procedure, not covered here.\n- Migrating an existing per-arch builder to the multi-platform pattern. Different procedure, not covered here.\n\n## Guiding facts\n\n- **Adds only; the policy is newest-two.** This skill only adds a target. Our policy is to support the two newest releases of a distro, so adding a newer release usually pairs with dropping the now-oldest — but that drop is a separate, by-hand procedure (not covered here). Don't drop anything as part of this work.\n- **Both architectures by default.** Multi-platform images build linux/amd64 and linux/arm64 from one Dockerfile. Don't restrict arches without a concrete reason.\n- **GHCR builder packages are repo-scoped.** No manual \"make public\" step is needed for new packages — same-repo CI pulls them automatically.\n- **Mental model.** One multi-arch builder image gets pushed to GHCR with a date-stamp tag. Workflow files (`release.yml`, `nightlies.yml`, `update-lib-cache.yml`) reference that one image by exact tag from both their x86_64-linux and arm64-linux matrices — Docker pulls the matching arch on each runner. Adding a new release target = create the Dockerfile, build/push the image, then thread the resulting tag into every workflow that consumes it, plus update the user-facing docs.\n\n## File inventory\n\nNew files (a single builder directory with two files):\n- `.ci-dockerfiles/<distro><version>-builder/Dockerfile`\n- `.ci-dockerfiles/<distro><version>-builder/build-and-push.bash`\n\nExisting files to edit:\n- `.github/workflows/build-builder-image.yml` — one dropdown option + one job block\n- `.github/workflows/release.yml` — `x86_64-linux` and `arm64-linux` matrices\n- `.github/workflows/nightlies.yml` — same matrices\n- `.github/workflows/update-lib-cache.yml` — same matrices (simpler shape: just `image:`)\n- `INSTALL.md` — supported-distro list + PLATFORM string table\n- `RELEASE_PROCESS.md` — package names list\n\nNew release notes file:\n- `.release-notes/add-<distro>-<version>.md`\n\n`BUILD.md` and `README.md` need no changes — neither contains version-specific content for this kind of add.\n\n## Before you begin\n\n- **Branch base:** start from a fresh `main`. `git checkout main && git pull`.\n- **Tools:** Docker daemon running (`docker info` succeeds), `gh` authenticated, `git` configured to push to origin.\n- **`gh` token scopes needed:** `workflow` (for dispatching builds) and `read:packages` (for querying GHCR tags). Check with `gh auth status`. If missing: `gh auth refresh -s workflow,read:packages`.\n- **Placeholders in this document:** every `<distro>`, `<version>`, `<tag>`, etc. is a placeholder for substitution. The angle brackets are not part of any command. Example for Ubuntu 26.04: `<distro>` → `ubuntu`, `<version>` → `26.04`.\n\n## Procedure\n\nThe **structural exemplar** — directory layout, `build-and-push.bash`, `build-builder-image.yml` job block, workflow matrix entries — is the most recent Alpine multi-platform builder. As of writing, that's `alpine3.24-builder/`. Before starting, glance at `.ci-dockerfiles/` and pick whichever Alpine multi-platform builder dir is newest (`alpineX.Y-builder/`, no arch prefix); use it in place of `alpine3.24-builder` throughout this document.\n\nThe **Dockerfile-content exemplar** is the most recent same-distro builder, regardless of pattern (per-arch is fine — only the package list and base image are reused). For Ubuntu adds at the time this skill was written, that's `x86-64-unknown-linux-ubuntu24.04-builder/Dockerfile`.\n\n### 1. Branch\n\n```bash\ngit checkout main && git pull\ngit checkout -b add-<distro>-<version>-release-target\n```\n\n### 2. Create the builder Dockerfile and build-and-push script\n\n```bash\nmkdir -p .ci-dockerfiles/<distro><version>-builder\n```\n\n**Dockerfile.** Copy the body from the most recent same-distro builder's Dockerfile (for Ubuntu, the per-arch x86-64 and arm64 Ubuntu 24.04 Dockerfiles are byte-identical, so either is fine), change the `FROM` line to the new version. The base image (e.g. `ubuntu:26.04`) must be a multi-arch manifest — it already is for the official `ubuntu` and `alpine` images. If adding a different distro, verify with:\n\n```bash\ndocker manifest inspect <distro>:<version> | jq '.manifests[].platform'\n```\n\nBoth `linux/amd64` and `linux/arm64` must appear.\n\n**build-and-push.bash.** Copy from the structural-exemplar multi-platform builder (e.g. `alpine3.24-builder/build-and-push.bash`) and change only the `NAME` and the `BUILDER` prefix. The shape:\n\n```bash\n#!/bin/bash\n\nset -o errexit\nset -o nounset\n\n#\n# *** You should already be logged in to GHCR when you run this ***\n#\n\nNAME=\"ghcr.io/ponylang/ponyc-ci-<distro><version>-builder\"\nTODAY=$(date +%Y%m%d)\nDOCKERFILE_DIR=\"$(dirname \"$0\")\"\nBUILDER=\"<distro><version>-builder-$(date +%s)\"\n\ndocker buildx create --use --name \"${BUILDER}\"\ndocker buildx build --provenance false --sbom false --platform linux/arm64,linux/amd64 --pull --push -t \"${NAME}:${TODAY}\" \"${DOCKERFILE_DIR}\"\ndocker buildx rm \"${BUILDER}\"\n```\n\nDon't modify the package list, the `LABEL`, the user setup, or anything else without a concrete reason. If a package name has changed in the new distro version, the local smoke test in step 3 will surface it — don't pre-emptively edit.\n\n### 3. Local smoke test\n\nBefore pushing or dispatching anything, build the Dockerfile locally for amd64 explicitly:\n\n```bash\ndocker build --pull --platform linux/amd64 .ci-dockerfiles/<distro><version>-builder/\n```\n\n`--platform linux/amd64` makes the smoke test deterministic regardless of host architecture (matters on Apple Silicon, where host-default would be `linux/arm64` and use a different package set). `--pull` forces Docker to fetch the latest base image rather than use a stale local cache. This is a smoke test only — don't run `build-and-push.bash` locally (that would push to GHCR with today's date stamp).\n\nIf the build fails because Docker isn't running or the package manager can't reach the network, that's an environment problem — fix it and retry, don't escalate. If the build fails inside the image (package gone, repo URL invalid for the new distro, base image not yet published, etc.): **STOP**. Surface the error to the human. Don't retry. Don't \"fix\" by tweaking the package list without confirmation — that's a design discussion.\n\nThe remote multi-platform build in step 6 cross-builds arm64 from x86-64 via QEMU. Local x86-64 success is a strong signal but not a guarantee for arm64; the GHA dispatch is the real arm64 verification.\n\n### 4. Update `build-builder-image.yml`\n\nTwo edits, both mechanical:\n\n**a. Add one dropdown option** (alphabetical across the whole list):\n\n```yaml\noptions:\n  ...\n  - <distro><version>-builder\n  ...\n```\n\n**b. Add one job block** by copying the structural-exemplar job (e.g. `alpine3_24-builder`) and substituting the version. Place the new job alphabetically among the sibling jobs.\n\nThe job map key replaces `.` with `_` (YAML map keys can't contain `.`): a version of `26.04` becomes `26_04` in the job key only — the `if:` filter value, the `name:` field, and the `bash` script path all keep the dot form. Reference shape:\n\n```yaml\n<distro><version_underscore>-builder:\n  if: ${{ github.event.inputs.builder-name == '<distro><version>-builder' }}\n  runs-on: ubuntu-latest\n\n  name: <distro><version>-builder\n  steps:\n    - name: Checkout\n      uses: actions/checkout@v6.0.2\n    - name: Set up Docker Buildx\n      uses: docker/setup-buildx-action@v4\n      with:\n        version: v0.23.0\n    - name: Login to GitHub Container Registry\n      uses: docker/login-action@v4\n      with:\n        registry: ghcr.io\n        username: ${{ github.repository_owner }}\n        password: ${{ secrets.GITHUB_TOKEN }}\n    - name: Build and push\n      run: bash .ci-dockerfiles/<distro><version>-builder/build-and-push.bash\n```\n\n`runs-on: ubuntu-latest` is correct — the multi-platform build cross-compiles arm64 via QEMU on an x86-64 runner. Don't change it.\n\n### 5. Push branch (WIP commit is fine)\n\n```bash\ngit add .ci-dockerfiles .github/workflows/build-builder-image.yml\ngit commit -m \"WIP: add <distro> <version> builder\"\ngit push -u origin add-<distro>-<version>-release-target\n```\n\n### 6. Dispatch the builder workflow from the branch\n\nGHA `workflow_dispatch` runs the workflow file from the chosen ref, so the new dropdown option becomes available when targeting the branch.\n\n```bash\ngh workflow run build-builder-image.yml \\\n  --ref add-<distro>-<version>-release-target \\\n  -f builder-name=<distro><version>-builder\n```\n\nIf dispatch returns 403, the `gh` token lacks `workflow` scope — see \"Before you begin.\"\n\n### 7. Wait for the build to finish\n\nThe multi-platform build takes ~20–40 minutes (arm64 via QEMU is the slow leg). Capture the run id, then watch it:\n\n```bash\nRUN_ID=$(gh run list --workflow=build-builder-image.yml \\\n  --branch=add-<distro>-<version>-release-target \\\n  --limit=1 --json databaseId --jq '.[].databaseId')\n\ngh run watch \"$RUN_ID\" --exit-status\n```\n\nIf the build fails: **STOP**. Read the failed logs with `gh run view \"$RUN_ID\" --log-failed` and surface the failure to the human. A common cause is a package or repo change for the new distro — that's a discussion, not something to silently work around.\n\n### 8. Discover the published tag\n\nThe successful build pushes `ghcr.io/ponylang/ponyc-ci-<distro><version>-builder:YYYYMMDD`. Query GHCR:\n\n```bash\ngh api -X GET /orgs/ponylang/packages/container/ponyc-ci-<distro><version>-builder/versions \\\n  --jq '[.[].metadata.container.tags[]] | map(select(test(\"^[0-9]{8}$\"))) | max'\n```\n\nThe `select(test(\"^[0-9]{8}$\"))` filter restricts to date-form tags (rejects `latest` or other future tags); `max` picks the most recent.\n\nIf the API returns 403, the `gh` token lacks `read:packages` — see \"Before you begin.\"\n\n### 9. Apply the tag to workflow files\n\nAdd new matrix entries alongside the existing entries. The same image string goes into both the `x86_64-linux` and `arm64-linux` matrices — Docker pulls the matching arch on each runner.\n\n**Use the structural-exemplar entry (Alpine 3.24) from the same file/matrix as your template.** Existing same-distro entries are inconsistent (per-arch builders use different image-name shapes; some files have `triple-vendor: unknown` on arm64, others omit it). Avoid that ambiguity: copy the alpine3.24 entry from the file/matrix you're editing, then substitute `image:`, `name:`, and `triple-os:` with the new distro/version values. Keep all other fields (including `triple-vendor`'s presence or absence) exactly as alpine3.24 has them in that location.\n\nFiles and matrices to edit:\n- `.github/workflows/release.yml` — `x86_64-linux` and `arm64-linux` matrices.\n- `.github/workflows/nightlies.yml` — `x86_64-linux` and `arm64-linux` matrices.\n- `.github/workflows/update-lib-cache.yml` — `x86_64-linux` and `arm64-linux` matrices (simpler shape: just `image:`).\n\nWorked example for Ubuntu 26.04, x86_64-linux matrices in `release.yml` and `nightlies.yml`:\n\n```yaml\n- image: ghcr.io/ponylang/ponyc-ci-ubuntu26.04-builder:<tag>\n  name: x86-64-unknown-linux-ubuntu26.04\n  triple-os: linux-ubuntu26.04\n  triple-vendor: unknown\n```\n\nThe arm64-linux matrix entry in those files uses the **same** `image:` string but `name: arm64-unknown-linux-ubuntu26.04`. Because the structural-exemplar (Alpine 3.24) arm64-linux entries omit `triple-vendor`, this entry omits it too.\n\n(In `release.yml` the arm64-linux job uses `docker run` directly rather than a `container:` job; ignore the surrounding job structure — the matrix entry shape is what matters.)\n\n### 10. Update docs\n\n`INSTALL.md`:\n- \"Supported Glibc distributions\" list (only relevant for distros listed there; Alpine adds use a different section): extend the existing comma-separated Ubuntu line in place. Add the new version to that line, don't add a new bullet.\n- If a corresponding Pop!_OS or Linux Mint release based on this Ubuntu version exists at this time, add it to its line and to the PLATFORM string table too. If it doesn't exist yet, skip — those rows can be added in a later PR when they ship.\n- PLATFORM string table: add two rows (amd64 and arm64). The existing rows for arm64 Ubuntu use `arm-linux-` (not `arm64-linux-`) — match that historical convention; don't \"fix\" it. PLATFORM strings (in INSTALL.md) and package names (in RELEASE_PROCESS.md) intentionally use different arch prefixes — match existing rows in each file rather than imposing consistency between them.\n\n`RELEASE_PROCESS.md`:\n- \"Package names will be:\" list: add two lines (alphabetical). Artifact naming always uses the arch-prefixed form, regardless of how the builder image is named:\n  - `* ponyc-arm64-unknown-linux-<distro><version>.tar.gz`\n  - `* ponyc-x86-64-unknown-linux-<distro><version>.tar.gz`\n\n### 11. Add release note\n\nCreate `.release-notes/add-<distro>-<version>.md`:\n\n```markdown\n## <Distro> <Version> added as a supported platform\n\nWe've added arm64 and amd64 builds for <Distro> <Version>. We build ponyc releases for the two newest <Distro> releases, so we'll keep building for <Version> until two newer <Distro> releases have shipped, at which point we'll stop.\n```\n\nThis combined-arch form matches the precedent for fresh adds (e.g. `.release-notes/0.60.4.md`). The per-arch form (\"X on arm64 added as a supported platform\") is for the historical case of adding an arch to an already-supported version — not what this skill covers.\n\nAfter this step, sanity-check coverage:\n\n```bash\ngit diff --stat main\ngrep -rn \"<distro><version>\" .github/workflows/ INSTALL.md RELEASE_PROCESS.md .ci-dockerfiles/ .release-notes/\n```\n\n`git diff --stat` should show changes only in the files listed in the \"File inventory\" section above (plus the two new Dockerfile/script files and the new release-notes file) — investigate any unexpected location. The `grep` confirms the placeholder was substituted; note that `build-builder-image.yml` job-key `<distro><version_underscore>` (e.g. `ubuntu26_04`) won't match the dotted form, so check that file separately.\n\n### 12. Squash, push, open PR\n\nThe PR title must exactly match the release-note title from step 11 (`<Distro> <Version> added as a supported platform`) — both end up as public-facing CHANGELOG content, so they must agree. The commit message matches too (it becomes the squash-merge commit subject).\n\nThe PR also needs the `changelog - added` label so release-notes aggregation picks up the entry under the right CHANGELOG section.\n\n```bash\ngit fetch origin main\ngit reset --soft origin/main\ngit add -A\ngit commit -m \"<Distro> <Version> added as a supported platform\"\ngit push --force-with-lease\n\nPR_URL=$(gh pr create \\\n  --title \"<Distro> <Version> added as a supported platform\" \\\n  --label \"changelog - added\" \\\n  --body \"$(cat <<'EOF'\nAdds <Distro> <Version> as a supported release target. Builder image, release/nightly/lib-cache workflow entries, INSTALL/RELEASE_PROCESS docs, and a release note are all included.\nEOF\n)\")\nPR_NUM=${PR_URL##*/}\n```\n\n### 13. Wait for CI, then squash-merge\n\nBlock until all PR checks complete, then merge if they pass:\n\n```bash\ngh pr checks \"$PR_NUM\" --watch --exit-status\ngh pr merge \"$PR_NUM\" --squash --delete-branch\ngit checkout main && git pull\n```\n\n`--watch --exit-status` blocks until checks finish and exits non-zero if any failed. If checks fail: **STOP**. Read the failure with `gh pr checks \"$PR_NUM\"` to see which check, drill into its logs, and surface to the human. Don't retry the merge.\n\n`--squash --delete-branch` matches the ponylang merge convention (repos under the ponylang and seantallen-org GitHub orgs only allow squash merges) and removes both the remote and local branch. The `git checkout main && git pull` afterward returns you to an up-to-date main.\n\n### 14. Post-merge: Last Week in Pony comment\n\nAfter the PR merges, find the open LWIP issue and post a comment.\n\n```bash\nLWIP=$(gh issue list -R ponylang/ponylang-website \\\n  --label last-week-in-pony --state open --limit 1 --json number --jq '.[0].number // empty')\n\nif [ -z \"$LWIP\" ]; then\n  echo \"No open LWIP issue. Wait until the next one is created (typically within a week) and post then. Don't open a new LWIP issue yourself.\"\nelse\n  gh issue comment \"$LWIP\" -R ponylang/ponylang-website --body \"$(cat <<'EOF'\n<Distro> <Version> has been added as a supported platform for ponyc. Prebuilt binaries are available via [ponyup](https://github.com/ponylang/ponyup). We build releases for the two newest <Distro> releases, so we'll keep building for <Version> until two newer <Distro> releases ship.\nEOF\n)\"\nfi\n```\n\nThe comment substance matches the release note: announcing the new platform and our policy of supporting the two newest releases. Format is short, single-paragraph prose matching existing LWIP comments.\n\nAfter this step, the add-a-release-target work is complete.\n\n## Failure modes — when to stop and ask\n\n- **Local Dockerfile build fails inside the image.** Package gone, repo URL invalid, base image not yet published. Don't tweak the package list or \"fix forward\" without confirmation.\n- **GHA builder dispatch fails.** Read run logs with `gh run view <id> --log-failed`. Most causes are the same as above.\n- **GHCR tag query returns no result.** Build may not have pushed (auth failure, network error). Check the workflow run logs.\n- **arm64 build fails but amd64 succeeds (or vice versa).** Multi-platform `docker buildx` produces a single manifest covering both arches; if either platform fails, the whole build fails and nothing gets pushed. Triage from the failed-platform logs.\n- **PR CI checks fail.** Don't retry the merge. Read the failed check's logs, surface to the human. Possible causes range from a flaky test (rare on a docs/config-only PR like this) to a real interaction with workflows you didn't expect to touch.\n\n## Anti-patterns\n\n- **Don't conflate this with CI test workflows.** PR validation, tier 2, weekly checks, and stress tests are separate concerns. They may also need updating for new distros, but not as part of \"add a release target.\"\n- **Don't migrate existing per-arch builders to multi-platform as a side effect.** That's a separate change.\n- **Don't skip the smoke test to save time.** A failed GHA multi-platform build is a 30-minute round-trip plus the human-attention cost of triaging it. A failed local build is 30 seconds.\n- **Don't run `build-and-push.bash` locally.** It pushes. The smoke test is `docker build` only.\n\n<!-- File: .claude/skills/create-dragonfly-dev-env/SKILL.md (Tokens: ~5811 | Category: anthropic-skill) -->\n---\nname: create-dragonfly-dev-env\ndescription: Load when you need to reproduce or validate a gcc-specific or DragonFly-specific ponyc issue on a local VM, or to stand up a local DragonFly BSD development VM that matches ponyc tier-3 CI. Covers the prerequisite check, the sudo-free QEMU/KVM setup, the reused console automation, the in-VM gcc13 build env, and the gotchas (csh, screendump paths, boot timing, detached builds, per-block shells) that make hand-rolling one error-prone.\ndisable-model-invocation: false\n---\n\n# Create a DragonFly BSD dev/test VM for ponyc\n\nStand up a local DragonFly BSD VM that matches the ponyc tier-3 CI job, so you can\nbuild and test ponyc on DragonFly without GitHub Actions.\n\nDragonFly only builds ponyc with gcc (the `gcc13` package), so it is the authoritative\nlocal environment for **gcc-specific** ponyc issues — the ones clang-based CI never\nsees. The setup is fiddly and timing-sensitive; the steps below are the known-good\nsequence. Follow them in order.\n\nRun the repo-relative commands (`.ci-scripts/bsd/...`, `rsync` of the source) from your\nponyc checkout root. The VM itself lives in a separate directory (`$VMDIR`).\n\n**Each fenced `sh` block below is self-contained.** It re-sets `VMDIR`/`DFLY_VER` (and,\nwhere needed, the `SSH` command) at its top, because an agent runs each block as a fresh\nshell — environment variables and the working directory do NOT carry from one block to\nthe next. Do not \"optimize\" by setting them once and dropping them from later blocks: an\nempty `$SSH` would make a `$SSH /bin/sh <<'EOF' ... EOF` heredoc run **on your host**\n(silently, exit 0) — and Step 7 contains `rm -rf /usr/local`. Keep every block whole.\nPick your `VMDIR`/`DFLY_VER` once and use the same values in every block.\n\n## Gotchas that will bite you (read first)\n\nEach of these cost real debugging. Internalize them before you start — they recur at\nseveral steps:\n\n- **Per-block fresh shells.** See above: re-establish `VMDIR`/`DFLY_VER`/`SSH` in every\n  block; never let `$SSH` be empty in front of a `/bin/sh` heredoc.\n- **Root's login shell is csh, not sh.** Any remote command with `$(...)`, `export`, or\n  a here-string MUST go through an explicit `/bin/sh`:\n  `ssh ... root@localhost /bin/sh <<'EOF' ... EOF`. A bare\n  `ssh ... root@localhost 'cmd with $(...)'` fails with `Illegal variable name.`\n- **Boot timing is handled by the script.** `dfly_configure_vm.py` takes periodic VGA\n  screendumps and waits for the screen to stabilize before attempting login. It then types\n  three short commands via sendkey (login, mount the seed ISO, run the setup script) and\n  verifies SSH becomes reachable. If something goes wrong, check the diagnostic screendump\n  the script saves, or use the manual screendump helper (Step 5) to see the VM state.\n- **A daemonized QEMU `chdir`s to `/`.** The monitor `screendump` (and any relative path\n  the daemon writes) needs an **absolute** path, or it fails `Permission denied`.\n- **No `sudo` needed (and often unavailable).** CI loop-mounts the ISO with `sudo` to get\n  `/usr/include`; locally, extract it with `bsdtar` instead — same result, no privilege.\n- **Detach long in-VM builds.** The libs build (LLVM) takes hours; run it\n  `nohup … > /build/x.log 2>&1 &` and poll the log, so an ssh drop doesn't kill it.\n- **Reuse the CI console script verbatim.** `.ci-scripts/bsd/dfly_configure_vm.py`\n  types login + mount + setup via QEMU sendkey, then verifies SSH is reachable. The setup\n  script lives on the seed ISO created in Step 4. Copy the Python script, don't reimplement\n  the bootstrap or setup.\n\n## Step 0 — verify prerequisites (do NOT assume they're installed)\n\nThis skill needs, on the host:\n\n- an existing ponyc checkout (you run the repo-relative commands from it)\n- hardware-accelerated virtualization for QEMU (KVM on Linux, HVF on macOS)\n- enough free disk for the VM's sparse qcow2 disks (provisioned at 60 GB + 50 GB nominal;\n  they grow only as used, and the libs build plus a build fill a large chunk of the data\n  disk), ~2 GB for the image/ISO files, and network access to mirror-master.dragonflybsd.org\n- `qemu-system-x86_64` and `qemu-img`\n- `bsdtar` (a libarchive tar that reads ISO9660; the default `tar` on macOS, FreeBSD, and DragonFly)\n- `genisoimage` (or `mkisofs`) for creating the seed ISO\n- `bunzip2`, `rsync`, `git`, `curl`, `python3`\n- an OpenSSH client (`ssh`, `scp`, `ssh-keygen`)\n- a PPM-to-PNG converter: any one of magick/convert, ffmpeg, or pnmtopng\n\nCheck that every required tool is present. Do not assume the environment has them, and do\nnot install anything yourself — installing them is the user's call (it needs privilege,\nand the package names vary by OS, so naming a specific package would be wrong on half the\nplatforms this runs on). If anything is missing, **stop, tell the user which\ntools/capabilities are missing, and ask them to install them with their platform's\npackage manager**, then re-run the check before proceeding.\n\n```sh\nmissing=\"\"\nfor t in qemu-system-x86_64 qemu-img bsdtar bunzip2 rsync git ssh scp ssh-keygen curl python3 genisoimage; do\n  command -v \"$t\" >/dev/null 2>&1 || missing=\"$missing $t\"\ndone\ncommand -v magick >/dev/null 2>&1 || command -v convert >/dev/null 2>&1 \\\n  || command -v ffmpeg >/dev/null 2>&1 || command -v pnmtopng >/dev/null 2>&1 \\\n  || missing=\"$missing ppm-converter(magick|ffmpeg|pnmtopng)\"\n# Hardware acceleration is host-specific — check the right accelerator for this OS.\ncase \"$(uname -s)\" in\n  Linux)  [ -w /dev/kvm ] || missing=\"$missing kvm(/dev/kvm-not-writable)\" ;;\n  Darwin) qemu-system-x86_64 -accel help 2>&1 | grep -q hvf || missing=\"$missing hvf-accelerator\" ;;\n  *)      echo \"NOTE: confirm this host has a QEMU hardware accelerator and adjust the boot accel= below\" ;;\nesac\n[ -n \"$missing\" ] && echo \"MISSING:$missing\" || echo \"all prerequisites present\"\n```\n\nIf the output is anything but `all prerequisites present`, name the missing\ntools/capabilities to the user and ask them to install them however their OS does — do\nNOT guess a distro package name. (`bsdtar` is the libarchive tar; on macOS/FreeBSD/\nDragonFly the system `tar` is already bsdtar, elsewhere it comes from libarchive.) A\nmissing hardware accelerator — no writable `/dev/kvm` on Linux, no HVF on macOS — is a\nhost/BIOS/virtualization fix only the user can make; the VM is unusably slow without it,\nso don't fall back to TCG emulation.\n\nThis flow is verified on Linux+KVM. The guest side is OS-agnostic, so macOS goes through\nthe same steps with the HVF accelerator (`accel=kvm:hvf` in Step 4 selects it).\n\n## Setup\n\nPick a persistent VM directory and a DragonFly version that **matches ponyc CI** (check\nthe image URL in `.ci-scripts/bsd/dragonfly-provision.bash`; currently 6.4.2). Use the\nsame two values in every block below.\n\n### 1. Download the image + ISO (one time; keep the `.bz2` to avoid re-downloading)\n\nThe raw `.img` boots; the `.iso` is only mined for system headers (the raw image ships\nwithout `/usr/include`).\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; DFLY_VER=6.4.2\nmkdir -p \"$VMDIR\" && cd \"$VMDIR\"\nbase=\"https://mirror-master.dragonflybsd.org/iso-images\"\ncurl -fL --retry 3 -o dfly.img.bz2 \"$base/dfly-x86_64-${DFLY_VER}_REL.img.bz2\"\ncurl -fL --retry 3 -o dfly.iso.bz2 \"$base/dfly-x86_64-${DFLY_VER}_REL.iso.bz2\"\n```\n\n### 2. Build the disks\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\nbunzip2 -k -f dfly.img.bz2 dfly.iso.bz2          # -k keeps the .bz2 originals\nqemu-img convert -f raw -O qcow2 dfly.img dfly.qcow2\nqemu-img resize dfly.qcow2 60G\nqemu-img create -f qcow2 dfly-data.qcow2 50G     # build workspace; root part is ~1.8G\nrm -f dfly.img                                    # keep dfly.qcow2 + the .bz2\n```\n\n### 3. Extract system headers from the ISO — no sudo (replaces CI's `mount -o loop`)\n\n`bsdtar` reads ISO9660 directly. The tar must have `include/` as its top entry so the\nin-VM extraction matches CI. (Both the extract and the repack use `bsdtar`, so no\nseparate `tar` is needed on the host.)\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\nrm -rf iso-stage && mkdir iso-stage\nbsdtar -xf dfly.iso -C iso-stage usr/include\nbsdtar -cf dfly-include.tar -C iso-stage/usr include\nrm -rf iso-stage\n```\n\n### 4. Key + seed ISO + boot (daemonized, persistent)\n\nCreate the SSH key, a seed ISO with the key and a setup script, then boot with the ISO\nattached as a CD-ROM. The setup script on the ISO configures networking, sshd, and SSH\nkeys — the sendkey bootstrap only needs to type three short commands (login, mount, run\nscript) instead of piping long commands through the fragile VGA console.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; DFLY_VER=6.4.2; cd \"$VMDIR\"\ntest -f vm_key || ssh-keygen -t ed25519 -f vm_key -N \"\" -q\ncat > setup.sh <<'SETUP'\n#!/bin/sh\nset -e\nif pgrep -x sshd >/dev/null 2>&1; then\n  exit 0\nfi\ndhclient vtnet0\necho \"PermitRootLogin yes\" >> /etc/ssh/sshd_config\necho \"PermitEmptyPasswords yes\" >> /etc/ssh/sshd_config\nmkdir -p /root/.ssh && chmod 700 /root/.ssh\ncp /mnt/authorized_keys /root/.ssh/authorized_keys\nchmod 600 /root/.ssh/authorized_keys\nssh-keygen -A\n/usr/sbin/sshd\nSETUP\ncp vm_key.pub authorized_keys\ngenisoimage -output seed.iso -volid CIDATA -joliet -rock setup.sh authorized_keys\nqemu-system-x86_64 \\\n  -name dragonfly-${DFLY_VER} \\\n  -machine pc,accel=kvm:hvf -cpu host -smp 8 -m 12G \\\n  -drive file=dfly.qcow2,format=qcow2,if=virtio \\\n  -drive file=dfly-data.qcow2,format=qcow2,if=virtio \\\n  -drive file=seed.iso,media=cdrom \\\n  -netdev user,id=net0,hostfwd=tcp::2222-:22 \\\n  -device virtio-net-pci,netdev=net0 \\\n  -object rng-random,id=rng0,filename=/dev/urandom \\\n  -device virtio-rng-pci,rng=rng0 \\\n  -monitor unix:dfly-monitor.sock,server,nowait \\\n  -serial unix:dfly-serial.sock,server,nowait \\\n  -display none -pidfile dfly.pid -daemonize\n```\n\n(`accel=kvm:hvf` picks KVM on Linux or HVF on macOS and errors if neither is available —\nit never silently falls back to slow TCG. `-smp`/`-m` are speed knobs; CI uses 4 CPUs /\n12G. `hostfwd 2222->22` is the ssh port.)\n\n### 5. Run the console setup script\n\nThe CI console script takes periodic VGA screendumps to detect when boot finishes, then\ntypes three short commands via sendkey (login as root, mount the seed ISO, run the setup\nscript). It verifies SSH becomes reachable after each attempt, retrying if needed.\n\nThe script lives in the repo at `.ci-scripts/bsd/dfly_configure_vm.py`. Copy it into\n`$VMDIR` (it connects to `dfly-monitor.sock` in its working directory) and run it there.\nSet `DFLY_ARTIFACTS_DIR` to the VM directory so the script can write screendumps for boot\ndetection and diagnostics:\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\ncp \"$(git rev-parse --show-toplevel)/.ci-scripts/bsd/dfly_configure_vm.py\" \"$VMDIR/\"\nexport DFLY_ARTIFACTS_DIR=\"$VMDIR\"\npython3 dfly_configure_vm.py\n```\n\nA `monitor_cmd.py` helper is still useful for ad-hoc screendumps when debugging boot\nproblems. Create one if needed:\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\ncat > monitor_cmd.py <<'PY'\nimport socket, sys, time\ns = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.connect('dfly-monitor.sock')\ntime.sleep(0.4); s.recv(65536)\ns.sendall((sys.argv[1] + '\\n').encode()); time.sleep(0.5); s.settimeout(1.0)\ntry:\n    while True:\n        d = s.recv(65536)\n        if not d: break\n        sys.stdout.write(d.decode(errors='replace'))\nexcept socket.timeout: pass\ns.close()\nPY\nto_png() {  # convert PPM->PNG with whichever converter is installed\n  if   command -v magick   >/dev/null 2>&1; then magick \"$1\" \"$2\"\n  elif command -v convert  >/dev/null 2>&1; then convert \"$1\" \"$2\"\n  elif command -v ffmpeg   >/dev/null 2>&1; then ffmpeg -y -loglevel error -i \"$1\" \"$2\"\n  else pnmtopng \"$1\" > \"$2\"; fi\n}\npython3 monitor_cmd.py \"screendump $VMDIR/console1.ppm\"   # absolute path is required\nto_png \"$VMDIR/console1.ppm\" \"$VMDIR/console1.png\"\n```\n\n### 6. Wait for ssh\n\nStep 5 already verifies SSH is reachable, so this is a belt-and-suspenders check. If it\nfails, re-screendump to check the console state, and re-run Step 5.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\nup=\"\"\nfor i in $(seq 1 150); do   # ~5 min, matching CI's timeout\n  if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=2 -i vm_key -p 2222 \\\n       root@localhost true 2>/dev/null; then up=1; echo \"SSH up\"; break; fi\n  sleep 2\ndone\n[ -n \"$up\" ] || echo \"SSH NEVER CAME UP — check the console screendump (Step 5) and rerun if needed\"\n```\n\nFrom here, **always** drive the VM through `/bin/sh` (root's shell is csh — see Gotchas),\nand define `$SSH` in the same block you use it (it does not carry between blocks):\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nuname -a\nEOF\n```\n\n### 7. Format and mount the build disk\n\nThe root partition is only ~1.8G — not enough for the build or packages. Format the\nsecond disk and mount it at `/build`.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\nnewfs /dev/vbd1\nmkdir -p /build\nmount /dev/vbd1 /build\nEOF\n```\n\n### 8. Move `/usr/local` to the build disk, install headers, install deps\n\n`gcc13` alone is ~418M, so package installs must land on `/build`.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncpdup /usr/local /build/usr_local\nrm -rf /usr/local\nln -s /build/usr_local /usr/local\nEOF\n\nscp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i \"$VMDIR/vm_key\" -P 2222 \"$VMDIR/dfly-include.tar\" root@localhost:/build/\n$SSH /bin/sh <<'EOF'\nset -e\ntar xf /build/dfly-include.tar -C /build\nln -s /build/include /usr/include\nrm /build/dfly-include.tar\nEOF\n\n$SSH /bin/sh <<'EOF'\nset -e\npkg install -y cmake gmake git python3 cxx_atomics rsync gcc13 ca_root_nss\npkg clean -ay\ngit config --global --add safe.directory /build/ponyc\nEOF\n```\n\n### 9. Rsync the ponyc checkout in (exclude the host `build/`)\n\nRun from your ponyc checkout. Exclude the top-level `build/` (host artifacts; the VM\nbuilds its own). Keep `.git` (CMake runs `git rev-parse`). The vendored LLVM submodule\nunder `lib/llvm/src` IS transferred — the VM needs it for the libs build.\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nrsync -az --exclude='/build' \\\n  -e \"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222\" \\\n  \"$(git rev-parse --show-toplevel)/\" root@localhost:/build/ponyc/\n```\n\n## Using the VM\n\nAlways set the gcc13 build env first (matches the tier-3 `dragonflybsd` job). `cmake -P lib/build-libs.cmake`\nbuilds the vendored LLVM and is the multi-hour long pole — run it detached, then poll the\nlog until it ends with `libs DONE rc=0` (a nonzero rc means it failed — read the log\nabove the marker):\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncat > /build/run-libs.sh <<'S'\n#!/bin/sh\nexport CC=/usr/local/bin/gcc13 CXX=/usr/local/bin/g++13\nexport LD_LIBRARY_PATH=/usr/local/lib/gcc13\nexport SSL_CERT_FILE=/usr/local/share/certs/ca-root-nss.crt\ncd /build/ponyc && cmake -DJOBS=4 -DCMAKE_C_COMPILER=/usr/local/bin/gcc13 -DCMAKE_CXX_COMPILER=/usr/local/bin/g++13 -P lib/build-libs.cmake\necho \"libs DONE rc=$?\"\nS\nchmod +x /build/run-libs.sh\nnohup /build/run-libs.sh > /build/libs-build.log 2>&1 &\necho \"libs building pid $!\"\nEOF\n# poll until done: $SSH /bin/sh -c 'tail -3 /build/libs-build.log'  -> wait for \"libs DONE rc=0\"\n```\n\nOnce `libs` is built (it persists in the VM), build and test with the same env:\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 root@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\nexport CC=/usr/local/bin/gcc13 CXX=/usr/local/bin/g++13\nexport LD_LIBRARY_PATH=/usr/local/lib/gcc13\nexport SSL_CERT_FILE=/usr/local/share/certs/ca-root-nss.crt\ncd /build/ponyc\ncmake --preset debug -DCMAKE_C_COMPILER=/usr/local/bin/gcc13 -DCMAKE_CXX_COMPILER=/usr/local/bin/g++13\ncmake --build --preset debug\nctest --preset debug -L ci-core\nEOF\n```\n\nTo iterate on a fix: edit on the host, re-run Step 9's `rsync` (it's incremental — only\nchanged files are sent), then re-run the build block above (long rebuilds should also be\ndetached + polled). For exact CI parity, the tier-3 `dragonflybsd` job\n(`.github/workflows/ponyc-tier3.yml`) runs more than this — the reject-unsupported-builds\ncheck, a `--static` link smoke, and the coverage smoke — so consult it when validating a\nCI-matching issue.\n\n## Lifecycle\n\n```sh\nVMDIR=~/vms/dragonfly-6.4.2; cd \"$VMDIR\"\npgrep -af \"qemu-system-x86_64 -name dragonfly\"        # status\nssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 root@localhost   # interactive shell\n# stop: DragonFly's default install has no ACPI shutdown handler, so system_powerdown\n# usually does nothing — kill the process instead. Disks persist for next time.\nkill \"$(cat dfly.pid)\" 2>/dev/null || pkill -f \"qemu-system-x86_64 -name dragonfly\"\n```\n\nTo restart later, re-run Steps 4–7 (the disks and installed deps persist; the key and ISO\ncreation are idempotent, and Steps 5–7 re-establish sshd and the `/build` mount, which\nare not persisted across a guest reboot). For a long-lived VM, make `sshd_enable`\npermanent in `/etc/rc.conf` and add the `/build` mount to `/etc/fstab`.\n\n## How this relates to CI (don't re-add the CI-only steps)\n\nThis mirrors `.ci-scripts/bsd/dragonfly-provision.bash` (the source of truth — keep this\nskill in sync if it changes) and **reuses `.ci-scripts/bsd/dfly_configure_vm.py`\nunchanged**. The local setup deliberately differs from CI, and each difference is safe:\n\n- Skips CI host-bootstrap (free disk space, install qemu, make `/dev/kvm` writable) —\n  your host already has qemu and a usable hardware accelerator (Step 0 verified it).\n- `bsdtar` instead of `sudo mount -o loop` for the ISO headers — no privilege needed.\n- Persistent VM instead of built-fresh-and-killed per run.\n- ssh adds `-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR` to quiet the host-key\n  warning: a persistent VM reused on `localhost:2222` collides with the stale\n  `known_hosts` entry from a prior VM. CI's ephemeral runners never reuse the port, so\n  `dragonfly-provision.bash` doesn't bother.\n- `-smp 8` for build speed (CI uses 4).\n- Both use the seed ISO + sendkey approach with screendump-based boot detection; locally\n  you can also use the manual screendump helper for ad-hoc debugging.\n- No GHCR libs cache (that's token-gated CI plumbing) — you just run `cmake -P lib/build-libs.cmake` once.\n\nThe FreeBSD and OpenBSD CI VMs follow the same shape\n(`.ci-scripts/bsd/{freebsd,openbsd}-provision.bash`) and each has its own skill:\n`create-freebsd-dev-env` (takes a `FREEBSD_VERSION`) and `create-openbsd-dev-env` (its\nstatic-link checks expect static-PIE, and it rejects the unsupported `use=` options). This\nskill is DragonFly-only; load a sibling if you need one of those.\n\n<!-- File: .claude/skills/create-freebsd-dev-env/SKILL.md (Tokens: ~5105 | Category: anthropic-skill) -->\n---\nname: create-freebsd-dev-env\ndescription: Load when you need to reproduce or validate a FreeBSD-specific ponyc issue on a local VM, or to stand up a local FreeBSD development VM that matches ponyc tier-3 CI. Covers the cloud-init seed, the sudo-free QEMU/KVM boot, the one-time expect/su root bootstrap, the clang build env, and the gotchas (freebsd-user login, no bash in base, growfs auto-resize, detached builds, per-block shells) that make hand-rolling one error-prone.\ndisable-model-invocation: false\n---\n\n# Create a FreeBSD dev/test VM for ponyc\n\nStand up a local FreeBSD VM that matches the ponyc tier-3 CI job, so you can build\nand test ponyc on FreeBSD without GitHub Actions.\n\nFreeBSD's tier-3 job exercises paths the clang-on-Linux jobs never do: the embedded-LLD\n`--static` link recipe, the native sanitizer link path, and FreeBSD's base\nlibc++/libunwind. So this VM is the authoritative local\nenvironment for **FreeBSD-platform** ponyc issues. (It is clang-based — FreeBSD's base\ncompiler is clang, not gcc. For **gcc-specific** issues use the DragonFly skill instead.)\n\nRun the repo-relative commands (`rsync` of the source) from your ponyc checkout root.\nThe VM itself lives in a separate directory (`$VMDIR`).\n\n**Each fenced `sh` block below is self-contained.** It re-sets `VMDIR`/`FREEBSD_VERSION`\n(and, where needed, the `SSH` command) at its top, because an agent runs each block as a\nfresh shell — environment variables and the working directory do NOT carry from one\nblock to the next. Do not \"optimize\" by setting them once and dropping them from later\nblocks: an empty `$SSH` would make a `$SSH /bin/sh <<'EOF' ... EOF` heredoc run **on your\nhost** (silently) instead of in the VM. Keep every block whole. Pick your `VMDIR`/\n`FREEBSD_VERSION` once and use the same values in every block.\n\n## Gotchas that will bite you (read first)\n\nFreeBSD is much simpler to bring up than DragonFly (cloud-init injects the ssh key, so\nthere is no VGA-console automation), but these still matter:\n\n- **Per-block fresh shells.** See above: re-establish `VMDIR`/`FREEBSD_VERSION`/`SSH` in\n  every block; never let `$SSH` be empty in front of a `/bin/sh` heredoc.\n- **You log in as `freebsd`, not `root`.** The cloud-init seed installs your key for the\n  `freebsd` user and sets root's password to `ciroot`. Build and test run **as\n  `freebsd`** (no root needed). Root is only needed twice during setup — the one-time\n  `expect`/`su` bootstrap below installs the packages and configures passwordless `doas`,\n  after which the rare root operation goes through `doas`.\n- **Cloud-init means no console driving.** Unlike the DragonFly skill there is no QEMU\n  `sendkey`/screendump/monitor dance — just boot and wait for ssh. But the **first** boot\n  runs `nuageinit` (key install) and `growfs` (root-fs auto-resize), so ssh is not\n  instant; poll for it (Step 4).\n- **The root fs auto-grows; there is no growfs command to run.** `qemu-img resize ... 60G`\n  enlarges the disk and the image's first boot grows the root fs to fill it. You do not\n  run `growfs` yourself — the wait-for-ssh loop is what accounts for it.\n- **FreeBSD's base system has no `bash`.** In-VM scripts must be POSIX `sh` (the two CI\n  smoke scripts already are). Don't reach for bashisms in a `$SSH /bin/sh` heredoc.\n- **Detach long in-VM builds.** The libs build (LLVM) takes hours; run it\n  `nohup … > /…/x.log 2>&1 &` and poll the log, so an ssh drop doesn't kill it.\n- **The one-time root bootstrap needs `expect`.** FreeBSD's `nuageinit` supports only\n  `ssh_authorized_keys` + `chpasswd` (not cloud-init's `write_files`/`runcmd`), so the\n  package install and `doas.conf` setup are driven through `su -m root` with `expect`,\n  exactly as CI does it. After that, `doas` is on disk and persists across reboots.\n\n## Step 0 — verify prerequisites (do NOT assume they're installed)\n\nThis skill needs, on the host:\n\n- an existing ponyc checkout (you run the `rsync` from it)\n- hardware-accelerated virtualization for QEMU (KVM on Linux, HVF on macOS)\n- enough free disk for the VM's sparse qcow2 disk (provisioned at 60 GB nominal; it grows\n  only as used — the libs build plus a debug build take the qcow2 to ~12 GB, and a release\n  build on top adds more), ~1 GB for the compressed image, and network access to\n  download.freebsd.org\n- `qemu-system-x86_64` and `qemu-img`\n- `cloud-localds` (from cloud-image-utils) or `genisoimage` to build the cloud-init\n  seed image\n- `expect` (drives the one-time `su` root bootstrap)\n- `xz`, `rsync`, `git`, `curl`, `python3`\n- an OpenSSH client (`ssh`, `scp`, `ssh-keygen`)\n\nNote this list is shorter than the DragonFly skill's: FreeBSD needs no `bsdtar`/`bunzip2`\n(no ISO header mining) and no PPM-to-PNG converter (no console screendumps).\n\nCheck that every required tool is present. Do not assume the environment has them, and do\nnot install anything yourself — installing them is the user's call (it needs privilege,\nand the package names vary by OS). If anything is missing, **stop, tell the user which\ntools/capabilities are missing, and ask them to install them with their platform's\npackage manager**, then re-run the check before proceeding.\n\n```sh\nmissing=\"\"\nfor t in qemu-system-x86_64 qemu-img expect xz rsync git ssh scp ssh-keygen curl python3; do\n  command -v \"$t\" >/dev/null 2>&1 || missing=\"$missing $t\"\ndone\ncommand -v cloud-localds >/dev/null 2>&1 || command -v genisoimage >/dev/null 2>&1 \\\n  || missing=\"$missing seed-builder(cloud-localds|genisoimage)\"\n# Hardware acceleration is host-specific — check the right accelerator for this OS.\ncase \"$(uname -s)\" in\n  Linux)  [ -w /dev/kvm ] || missing=\"$missing kvm(/dev/kvm-not-writable)\" ;;\n  Darwin) qemu-system-x86_64 -accel help 2>&1 | grep -q hvf || missing=\"$missing hvf-accelerator\" ;;\n  *)      echo \"NOTE: confirm this host has a QEMU hardware accelerator and adjust the boot accel= below\" ;;\nesac\n[ -n \"$missing\" ] && echo \"MISSING:$missing\" || echo \"all prerequisites present\"\n```\n\nIf the output is anything but `all prerequisites present`, name the missing\ntools/capabilities to the user and ask them to install them however their OS does — do\nNOT guess a distro package name. (`cloud-localds` ships in `cloud-image-utils` on\nDebian/Ubuntu; if it is unavailable, the cidata seed can also be built with\n`genisoimage -output seed.img -volid cidata -joliet -rock user-data meta-data`, which is\nwhat the OpenBSD CI path uses.) A missing hardware accelerator — no writable `/dev/kvm`\non Linux, no HVF on macOS — is a host/BIOS/virtualization fix only the user can make; the\nVM is unusably slow without it, so don't fall back to TCG emulation.\n\nThis flow is verified on Linux+KVM through the libs build and a debug build. The guest side\nis OS-agnostic, so macOS goes through the same steps with the HVF accelerator\n(`accel=kvm:hvf` in Step 3 selects it).\n\n## Setup\n\nPick a persistent VM directory and a FreeBSD version that **matches ponyc CI** (the tier-3\n`freebsd` job in `.github/workflows/ponyc-tier3.yml` tests a single version — currently\n15.1; the image URL is in `.ci-scripts/bsd/freebsd-provision.bash`). Use the same value\nin every block below.\n\n### 1. Download the image (one time; keep the `.xz` to avoid re-downloading)\n\nThe BASIC-CLOUDINIT image boots straight to a cloud-init-configured system — no installer,\nand it ships the full base system including `/usr/include`.\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1\nmkdir -p \"$VMDIR\" && cd \"$VMDIR\"\nbase=\"https://download.freebsd.org/releases/VM-IMAGES/${FREEBSD_VERSION}-RELEASE/amd64/Latest\"\ncurl -fL --retry 3 -o freebsd.qcow2.xz \\\n  \"$base/FreeBSD-${FREEBSD_VERSION}-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz\"\nxz -dk freebsd.qcow2.xz                 # -k keeps the .xz original\nqemu-img resize freebsd.qcow2 60G       # first boot grows the root fs to fill this\n```\n\n### 2. Key + cloud-init seed\n\n`nuageinit` reads a cidata seed image at boot: it installs your ssh public key for the\n`freebsd` user and sets root's password (so the Step 5 `su` bootstrap can reach root).\n\n```sh\nVMDIR=~/vms/freebsd-15.1; cd \"$VMDIR\"\ntest -f vm_key || ssh-keygen -t ed25519 -f vm_key -N \"\" -q\ncat > user-data <<USERDATA\n#cloud-config\nssh_authorized_keys:\n  - $(cat vm_key.pub)\nchpasswd:\n  expire: false\n  list:\n    - root:ciroot\nUSERDATA\ncat > meta-data <<'METADATA'\ninstance-id: freebsd-dev\nlocal-hostname: freebsd-dev\nMETADATA\ncloud-localds seed.img user-data meta-data\n```\n\n### 3. Boot (daemonized, persistent)\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1; cd \"$VMDIR\"\nqemu-system-x86_64 \\\n  -name freebsd-${FREEBSD_VERSION} \\\n  -machine pc,accel=kvm:hvf -cpu host -smp 8 -m 12G \\\n  -drive file=freebsd.qcow2,format=qcow2,if=virtio \\\n  -drive file=seed.img,format=raw,if=virtio \\\n  -netdev user,id=net0,hostfwd=tcp::2222-:22 \\\n  -device virtio-net-pci,netdev=net0 \\\n  -display none -pidfile freebsd.pid -daemonize\n```\n\n(`accel=kvm:hvf` picks KVM on Linux or HVF on macOS and errors if neither is available —\nit never silently falls back to slow TCG. `-smp`/`-m` are speed knobs; CI uses 4 CPUs /\n12G. `hostfwd 2222->22` is the ssh port — to run two FreeBSD VMs at once (e.g. CI's 15.1\nplus another version you want to compare against), give each its own `$VMDIR` and a\ndistinct hostfwd port, and pass that port to every ssh/scp/rsync `-p`. The seed drive stays attached — `nuageinit` skips it on\nlater boots once the instance-id is seen. This boot command is adapted from\n`.ci-scripts/bsd/freebsd-provision.bash`: it adds `-pidfile`/`-smp 8` and drops the CI\nhost-bootstrap; CI has no rng or monitor device for FreeBSD and neither does this.)\n\n### 4. Wait for ssh (first boot runs nuageinit + growfs)\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1; cd \"$VMDIR\"\nup=\"\"\nfor i in $(seq 1 150); do   # ~5 min, matching CI's timeout\n  if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=2 -i vm_key -p 2222 \\\n       freebsd@localhost true 2>/dev/null; then up=1; echo \"SSH up\"; break; fi\n  sleep 2\ndone\n[ -n \"$up\" ] || echo \"SSH NEVER CAME UP — see recovery below.\"\n```\n\nIf ssh never comes up, this VM has no VGA console to look at (it booted headless), so\ndiagnose in this order:\n\n- **Is the process alive?** `pgrep -af \"qemu-system-x86_64 -name freebsd-${FREEBSD_VERSION}\"`.\n  If it's gone, the boot died early (usually a bad/incomplete image from Step 1) — re-check\n  the Step 1 download/resize and re-boot.\n- **Watch the boot.** The image logs to the serial console, so re-boot with a serial log\n  added — `kill \"$(cat freebsd.pid)\"`, then re-run the Step 3 boot command with\n  `-serial file:$VMDIR/console.log` appended — and read `console.log` to see how far it got.\n- **ssh actively *refuses* the key (vs. just timing out)?** That means `nuageinit` never\n  installed it — the seed didn't apply. Confirm `seed.img` is attached as a drive in the\n  boot command (Step 3) and was built in Step 2, then re-boot.\n\n### 5. One-time root bootstrap (install deps + configure doas) via expect\n\nFreeBSD's `nuageinit` can't run `write_files`/`runcmd`, so — exactly as CI does — drive\n`su -m root` with `expect` to install the build dependencies and grant the `freebsd` user\npasswordless `doas`. This runs once; `doas.conf` persists on disk for the VM's life. (The\nexpect block is adapted from the one embedded in `freebsd-provision.bash`.)\n\n```sh\nVMDIR=~/vms/freebsd-15.1; cd \"$VMDIR\"\nexpect <<'EXPECT'\nset timeout 600\nspawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 -t freebsd@localhost su -m root\nexpect {\n  \"Password:\" { send \"ciroot\\r\" }\n  timeout { puts \"Timeout waiting for password prompt\"; exit 1 }\n}\nexpect {\n  \"su: Sorry\" { puts \"su failed - wrong password or nuageinit didn't set it\"; exit 1 }\n  \"#\" {}\n  timeout { puts \"Timeout waiting for root shell\"; exit 1 }\n}\nsend \"pkg install -y cmake gmake libunwind git python3 rsync doas\\r\"\nexpect {\n  \"#\" {}\n  timeout { puts \"Timeout during pkg install\"; exit 1 }\n}\nsend \"echo 'permit nopass freebsd' > /usr/local/etc/doas.conf\\r\"\nexpect {\n  \"#\" {}\n  timeout { puts \"Timeout writing doas.conf\"; exit 1 }\n}\nsend \"exit\\r\"\nexpect eof\nEXPECT\n```\n\nIf `expect` prints one of its `Timeout …` messages, it's safe to re-run this block — the\n`pkg install -y` and the clobbering `>` write to `doas.conf` are both idempotent. The one\nexception is `su failed`: that means the seed never set root's password, so root was never\nreachable. Re-running won't fix it — that's a boot/seed problem, so work through the Step 4\nrecovery (is the VM up? did `seed.img` apply?) first.\n\n### 6. Rsync the ponyc checkout in (exclude the host `build/`)\n\nRun from your ponyc checkout. Exclude the top-level `build/` (host artifacts; the VM\nbuilds its own). Keep `.git` (CMake runs `git rev-parse`). The vendored LLVM submodule\nunder `lib/llvm/src` IS transferred — the VM needs it for the libs build.\n\n```sh\nVMDIR=~/vms/freebsd-15.1\nrsync -az --exclude='/build' \\\n  -e \"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222\" \\\n  \"$(git rev-parse --show-toplevel)/\" freebsd@localhost:/home/freebsd/ponyc/\n```\n\n## Using the VM\n\nThe build uses FreeBSD's default clang — no `CC`/`CXX`/`LD_LIBRARY_PATH` exports (that is\nthe DragonFly-only gcc13 dance). `cmake -P lib/build-libs.cmake` builds the vendored LLVM and is the multi-hour\nlong pole — run it detached, then poll the log until it ends with `libs DONE rc=0` (a\nnonzero rc means it failed — read the log above the marker):\n\n```sh\nVMDIR=~/vms/freebsd-15.1\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 freebsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncat > /home/freebsd/run-libs.sh <<'S'\n#!/bin/sh\ncd /home/freebsd/ponyc && cmake -DJOBS=4 -P lib/build-libs.cmake\necho \"libs DONE rc=$?\"\nS\nchmod +x /home/freebsd/run-libs.sh\nnohup /home/freebsd/run-libs.sh > /home/freebsd/libs-build.log 2>&1 &\necho \"libs building pid $!\"\nEOF\n# poll until done: $SSH /bin/sh -c 'tail -3 /home/freebsd/libs-build.log'  -> wait for \"libs DONE rc=0\"\n```\n\nOnce `libs` is built (it persists in the VM), build and test with the same shell. Do not\nrun this block until the poll shows `libs DONE rc=0` — `cmake --build` against a half-built\n`build/libs` fails:\n\n```sh\nVMDIR=~/vms/freebsd-15.1\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 freebsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncd /home/freebsd/ponyc\ncmake --preset debug\ncmake --build --preset debug\nctest --preset debug -L ci-core\nEOF\n```\n\nTo iterate on a fix: edit on the host, re-run Step 6's `rsync` (it's incremental — only\nchanged files are sent), then re-run the build block above (long rebuilds should also be\ndetached + polled). For exact CI parity, the tier-3 `freebsd` job\n(`.github/workflows/ponyc-tier3.yml`) runs more than this — a `--static` embedded-LLD link\nsmoke, the self-hosted tool tests (`pony-doc-tests`/`pony-lint-tests`/`pony-lsp-tests`), a\nrelease build + the `ci-core` suite, and two extra smokes:\n`.ci-scripts/freebsd-sanitizer-smoke.sh`. Consult that job when validating a\nCI-matching issue.\n\n## Lifecycle\n\n```sh\nVMDIR=~/vms/freebsd-15.1; FREEBSD_VERSION=15.1; cd \"$VMDIR\"\npgrep -af \"qemu-system-x86_64 -name freebsd-${FREEBSD_VERSION}\"        # status\nssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 freebsd@localhost   # interactive shell\n# stop: CI stops this VM by killing the process; do the same. Disks persist for next time.\n# The pgrep/pkill are scoped to this version so a second FreeBSD VM (e.g. another\n# version you're comparing against) isn't matched; the pidfile path is per-$VMDIR anyway.\nkill \"$(cat freebsd.pid)\" 2>/dev/null || pkill -f \"qemu-system-x86_64 -name freebsd-${FREEBSD_VERSION}\"\n```\n\nTo restart later, re-run the step-3 boot command (the disk and installed deps persist; you\ndo not redo setup). On a reboot, ssh-as-`freebsd` works immediately (the key was written\nto disk on first boot) and `doas` still works (`doas.conf` persisted) — there is no\nre-bootstrap, unlike the DragonFly VM.\n\n## How this relates to CI (don't re-add the CI-only steps)\n\nThis mirrors `.ci-scripts/bsd/freebsd-provision.bash` (the source of truth — keep this\nskill in sync if it changes). The local setup deliberately differs from CI, and each\ndifference is safe:\n\n- Skips CI host-bootstrap (free disk space, `apt-get` qemu, make `/dev/kvm` writable) —\n  your host already has qemu and a usable hardware accelerator (Step 0 verified it).\n- Persistent VM instead of built-fresh-and-killed per run.\n- ssh adds `-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR` to quiet the host-key\n  warning: a persistent VM reused on `localhost:2222` collides with the stale\n  `known_hosts` entry from a prior VM. CI's ephemeral runners never reuse the port, so\n  `freebsd-provision.bash` doesn't bother.\n- `-smp 8` for build speed (CI uses 4), and a `-pidfile` for the lifecycle commands.\n- No GHCR libs cache (that's token-gated CI plumbing) — you just run `cmake -P lib/build-libs.cmake` once.\n\nThe DragonFly and OpenBSD CI VMs follow the same shape\n(`.ci-scripts/bsd/{dragonfly,openbsd}-provision.bash`) and each has its own skill:\n`create-dragonfly-dev-env` (gcc-only, and it drives a VGA console) and\n`create-openbsd-dev-env` (also cloud-init, but with static-PIE static-link checks, a raised\n`datasize` limit, and the source under a `/build` disk). This skill is FreeBSD-only; load a\nsibling if you need one of those.\n\n<!-- File: .claude/skills/create-openbsd-dev-env/SKILL.md (Tokens: ~6589 | Category: anthropic-skill) -->\n---\nname: create-openbsd-dev-env\ndescription: Load when you need to reproduce or validate an OpenBSD-specific ponyc issue on a local VM, or to stand up a local OpenBSD development VM that matches ponyc tier-3 CI. Covers the cloud-init seed (cdrom), the sudo-free QEMU/KVM boot, the doas-via-cloud-init setup (no expect bootstrap), the dedicated /build data disk, the datasize limit, the clang build env, and the gotchas (openbsd-user login, disklabel build disk, raised datasize, static-PIE static linking, pkg_add naming, detached builds, per-block shells) that make hand-rolling one error-prone.\ndisable-model-invocation: false\n---\n\n# Create an OpenBSD dev/test VM for ponyc\n\nStand up a local OpenBSD VM that matches the ponyc tier-3 CI job, so you can build\nand test ponyc on OpenBSD without GitHub Actions.\n\nOpenBSD's tier-3 job exercises paths the Linux jobs never do: the static-PIE `--static`\nembedded-LLD link recipe, OpenBSD's base clang/libc++, and the per-process `datasize`\nlimit that the LLVM build pushes against. It also asserts the `use=` build options that\ncan't work there are rejected. So this VM is the authoritative local environment for\n**OpenBSD-platform** ponyc issues. (It is clang-based — OpenBSD's base compiler is clang,\nnot gcc. For **gcc-specific** issues use the DragonFly skill instead.)\n\nRun the repo-relative commands (`rsync` of the source) from your ponyc checkout root. The\nVM itself lives in a separate directory (`$VMDIR`).\n\n**Each fenced `sh` block below is self-contained.** It re-sets `VMDIR`/`OPENBSD_VERSION`\n(and, where needed, the `SSH` command) at its top, because an agent runs each block as a\nfresh shell — environment variables and the working directory do NOT carry from one block\nto the next. Do not \"optimize\" by setting them once and dropping them from later blocks:\nan empty `$SSH` would make a `$SSH /bin/sh <<'EOF' ... EOF` heredoc run **on your host**\n(silently) instead of in the VM — and Step 5 contains `newfs` and Step 6 edits\n`/etc/login.conf`. Keep every block whole. Pick your `VMDIR`/`OPENBSD_VERSION` once and use\nthe same values in every block.\n\n## Gotchas that will bite you (read first)\n\nOpenBSD is a hybrid of the other two BSD skills: like FreeBSD it boots from a cloud-init\nseed and is driven entirely over ssh (no VGA-console automation), but like DragonFly the\nbuild lives on a dedicated data disk. These matter:\n\n- **Per-block fresh shells.** See above: re-establish `VMDIR`/`OPENBSD_VERSION`/`SSH` in\n  every block; never let `$SSH` be empty in front of a `/bin/sh` heredoc.\n- **You log in as `openbsd`, not `root`.** The cloud-init seed creates the `openbsd` user\n  in the `wheel` group and writes `/etc/doas.conf` (`permit nopass keepenv :wheel`). Build\n  and test run **as `openbsd`**; the few root operations (disk setup, package install,\n  editing `login.conf`) go through `doas`, which is configured before first login and\n  persists on disk. There is **no** `expect`/`su` bootstrap — OpenBSD's cloud-init runs\n  `write_files`, so `doas` is in place from the first boot (simpler than FreeBSD).\n- **`doas` is in the base system.** Nothing to install; cloud-init just drops\n  `/etc/doas.conf`. Keep its `0600 root:wheel` permissions exactly — `doas` refuses to run\n  with a group- or world-writable config.\n- **Cloud-init means no console driving.** Unlike the DragonFly skill there is no QEMU\n  `sendkey`/screendump/monitor dance — just boot and wait for ssh. But the **first** boot\n  runs cloud-init (user + key + `doas.conf`), so ssh is not instant; poll for it (Step 4).\n- **Build on the `/build` data disk, not `/home`.** OpenBSD's default disklabel confines\n  writable space to `/home` (~10.8G), which the LLVM 22 build overflows. A second qcow2 is\n  partitioned, `newfs`'d, and mounted at `/build`, and the source plus the build live\n  there — mirroring the DragonFly job. The disklabel setup is **one-time and not\n  idempotent** (`newfs` reformats), so run Step 5 exactly once.\n- **The `/build` mount is not persisted across reboot.** `disklabel`/`newfs`/`mount` are\n  manual one-time steps with no `/etc/fstab` entry; after a guest reboot, remount\n  `/dev/sd1a` at `/build`. `/etc/login.conf` and `/etc/doas.conf` are on the root disk and\n  **do** persist.\n- **Raise the `datasize` limit.** OpenBSD caps a process's data segment in\n  `/etc/login.conf` (1536M on the stock 7.9 image); the LLVM build needs more, so Step 6\n  raises `datasize-max`/`-cur` to at least 8192M. Without it the libs build dies with\n  allocation failures partway through. The edit is on the root disk and persists.\n- **OpenBSD's base system has no `bash`.** `/bin/sh` is a POSIX `ksh`; in-VM scripts must\n  be POSIX `sh`. Don't reach for bashisms in a `$SSH /bin/sh` heredoc.\n- **Two `pkg_add` names look like typos but aren't:** `python%3` (the `%3` selects the 3.x\n  branch) and `rsync--` (the trailing `--` picks the no-flavor build). Copy them verbatim.\n- **Detach long in-VM builds.** The libs build (LLVM) takes hours; run it\n  `nohup … > /build/x.log 2>&1 &` and poll the log, so an ssh drop doesn't kill it.\n\n## Step 0 — verify prerequisites (do NOT assume they're installed)\n\nThis skill needs, on the host:\n\n- an existing ponyc checkout (you run the `rsync` from it)\n- hardware-accelerated virtualization for QEMU (KVM on Linux, HVF on macOS)\n- enough free disk for the VM's two sparse qcow2 disks (the shipped root image plus a 50 GB\n  nominal data disk; they grow only as used — the libs build plus a debug build take the data\n  disk to several GB), network access to github.com (the image is a GitHub release asset)\n- `qemu-system-x86_64` and `qemu-img`\n- `genisoimage` (builds the cidata seed CD-ROM; `cloud-localds` can also produce an\n  equivalent cidata image)\n- `rsync`, `git`, `curl`\n- an OpenSSH client (`ssh`, `scp`, `ssh-keygen`)\n\nNote this is the shortest prerequisite list of the three BSD skills: OpenBSD needs no\n`expect` (cloud-init configures `doas` directly), no `xz` (the image ships as a plain\nqcow2 — no decompress, no root-fs resize), and no `bsdtar`/PPM-converter (it is headless\nwith no ISO header mining and no console screendumps).\n\nCheck that every required tool is present. Do not assume the environment has them, and do\nnot install anything yourself — installing them is the user's call (it needs privilege, and\nthe package names vary by OS). If anything is missing, **stop, tell the user which\ntools/capabilities are missing, and ask them to install them with their platform's package\nmanager**, then re-run the check before proceeding.\n\n```sh\nmissing=\"\"\nfor t in qemu-system-x86_64 qemu-img rsync git ssh scp ssh-keygen curl; do\n  command -v \"$t\" >/dev/null 2>&1 || missing=\"$missing $t\"\ndone\ncommand -v genisoimage >/dev/null 2>&1 || command -v cloud-localds >/dev/null 2>&1 \\\n  || missing=\"$missing seed-builder(genisoimage|cloud-localds)\"\n# Hardware acceleration is host-specific — check the right accelerator for this OS.\ncase \"$(uname -s)\" in\n  Linux)  [ -w /dev/kvm ] || missing=\"$missing kvm(/dev/kvm-not-writable)\" ;;\n  Darwin) qemu-system-x86_64 -accel help 2>&1 | grep -q hvf || missing=\"$missing hvf-accelerator\" ;;\n  *)      echo \"NOTE: confirm this host has a QEMU hardware accelerator and adjust the boot accel= below\" ;;\nesac\n[ -n \"$missing\" ] && echo \"MISSING:$missing\" || echo \"all prerequisites present\"\n```\n\nIf the output is anything but `all prerequisites present`, name the missing\ntools/capabilities to the user and ask them to install them however their OS does — do NOT\nguess a distro package name. (`genisoimage` ships in `genisoimage` or `cdrkit` on\nDebian/Ubuntu; CI uses `genisoimage`. If only `cloud-localds` is available it can build the\nsame cidata image — attach it as the cdrom in Step 3.) A missing hardware accelerator — no\nwritable `/dev/kvm` on Linux, no HVF on macOS — is a host/BIOS/virtualization fix only the\nuser can make; the VM is unusably slow without it, so don't fall back to TCG emulation.\n\nThis flow is verified on Linux+KVM through VM bring-up, the `/build` disk plus\n`doas`/`datasize` setup, dependency install, the source rsync, and the unsupported-build\nrejection smoke (`.ci-scripts/openbsd-reject-unsupported-builds.sh`, which runs `cmake …\n-DPONY_USES=…` and so needs no LLVM). The LLVM build (`cmake -P lib/build-libs.cmake`), the subsequent\n`cmake --preset debug`/build — which require the built LLVM (CMake's `find_package(LLVM)`\nfails without it) — and the test suites were **not** run locally; those steps mirror\n`.ci-scripts/bsd/openbsd-provision.bash` and the tier-3 `openbsd` job, which CI exercises\nend-to-end and keeps green. The guest side is OS-agnostic, so macOS goes through the same\nsteps with the HVF accelerator (`accel=kvm:hvf` in Step 3 selects it).\n\n## Setup\n\nPick a persistent VM directory and an OpenBSD version that **matches ponyc CI** (the tier-3\n`openbsd` job in `.github/workflows/ponyc-tier3.yml` runs OpenBSD 7.9; the exact image URL,\nincluding the dated build tag, is in `.ci-scripts/bsd/openbsd-provision.bash`). Use the same\ntwo values in every block below.\n\n### 1. Download the image + create the build disk (one time)\n\nOpenBSD ships no official cloud image, so CI uses the community\n`hcartiaux/openbsd-cloud-image` GitHub release — a plain qcow2 that boots straight to a\ncloud-init-configured system. Pin the **full dated release tag** that\n`openbsd-provision.bash` references (a bare `v7.9` is not a valid asset path). The root\nimage is left at its shipped size; the build does not live on it.\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9\nmkdir -p \"$VMDIR\" && cd \"$VMDIR\"\ncurl -fL --retry 3 -o openbsd.qcow2 \\\n  \"https://github.com/hcartiaux/openbsd-cloud-image/releases/download/v7.9_2026-06-03-20-46/openbsd-generic.qcow2\"\n# Build-workspace disk. OpenBSD's default disklabel confines builds to /home\n# (~10.8G), which the LLVM 22 build overflows, so build on a dedicated disk.\nqemu-img create -f qcow2 openbsd-data.qcow2 50G\n```\n\n### 2. Key + cloud-init seed\n\nOpenBSD's cloud-init reads a cidata seed at boot: it creates the `openbsd` user (in\n`wheel`) with your ssh public key, and writes `/etc/doas.conf` so the `openbsd` user gets\npasswordless `doas`. Build the seed as an ISO and attach it as a CD-ROM (Step 3).\n\n```sh\nVMDIR=~/vms/openbsd-7.9; cd \"$VMDIR\"\ntest -f vm_key || ssh-keygen -t ed25519 -f vm_key -N \"\" -q\ncat > user-data <<USERDATA\n#cloud-config\nusers:\n- name: openbsd\n  groups: wheel\n  ssh_authorized_keys:\n  - $(cat vm_key.pub)\nwrite_files:\n- path: /etc/doas.conf\n  content: |\n    permit nopass keepenv :wheel\n  owner: root:wheel\n  permissions: '0600'\nUSERDATA\ncat > meta-data <<'METADATA'\ninstance-id: openbsd-dev\nlocal-hostname: openbsd-dev\nMETADATA\ngenisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data\n```\n\n### 3. Boot (daemonized, persistent)\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9; cd \"$VMDIR\"\nqemu-system-x86_64 \\\n  -name openbsd-${OPENBSD_VERSION} \\\n  -machine pc,accel=kvm:hvf -cpu host -smp 8 -m 12G \\\n  -drive file=openbsd.qcow2,format=qcow2,if=virtio \\\n  -drive file=openbsd-data.qcow2,format=qcow2,if=virtio \\\n  -drive file=seed.iso,media=cdrom \\\n  -netdev user,id=net0,hostfwd=tcp::2222-:22 \\\n  -device virtio-net-pci,netdev=net0 \\\n  -display none -pidfile openbsd.pid -daemonize\n```\n\n(`accel=kvm:hvf` picks KVM on Linux or HVF on macOS and errors if neither is available — it\nnever silently falls back to slow TCG; CI uses bare `accel=kvm` because it always runs on\nLinux. `-smp`/`-m` are speed knobs; CI uses 4 CPUs / 12G. The build is capped by the 8192M\n`datasize` limit per process, not by total RAM. `hostfwd 2222->22` is the ssh port — to run\ntwo OpenBSD VMs at once, give each its own `$VMDIR` and a distinct hostfwd port, and pass\nthat port to every ssh/scp/rsync `-p`. The seed is attached as a **cdrom** — that is how\nOpenBSD's cloud-init finds the cidata volume; do not copy FreeBSD's raw-virtio-disk seed\nform here. This boot command is adapted from `.ci-scripts/bsd/openbsd-provision.bash`: it\nadds `-pidfile`/`-smp 8`, drops the CI host-bootstrap, and like CI uses no rng or monitor\ndevice.)\n\n### 4. Wait for ssh (first boot runs cloud-init)\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9; cd \"$VMDIR\"\nup=\"\"\nfor i in $(seq 1 150); do   # ~5 min, matching CI's timeout\n  if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=2 -i vm_key -p 2222 \\\n       openbsd@localhost true 2>/dev/null; then up=1; echo \"SSH up\"; break; fi\n  sleep 2\ndone\n[ -n \"$up\" ] || echo \"SSH NEVER CAME UP — see recovery below.\"\n```\n\nIf ssh never comes up, this VM has no VGA console to look at (it booted headless), so\ndiagnose in this order:\n\n- **Is the process alive?** `pgrep -af \"qemu-system-x86_64 -name openbsd-${OPENBSD_VERSION}\"`.\n  If it's gone, the boot died early (usually a bad/incomplete image from Step 1) — re-check\n  the Step 1 download and re-boot.\n- **Watch the boot.** The image logs to the serial console, so re-boot with a serial log\n  added — `kill \"$(cat openbsd.pid)\"`, then re-run the Step 3 boot command with\n  `-serial file:$VMDIR/console.log` appended — and read `console.log` to see how far it got.\n- **ssh actively *refuses* the key (vs. just timing out)?** That means cloud-init never\n  applied the seed. Confirm `seed.iso` is attached as a **cdrom** drive in the boot command\n  (Step 3) and was built in Step 2, then re-boot.\n\n### 5. One-time build-disk setup (disklabel, newfs, mount)\n\nOpenBSD sees the second virtio disk as `sd1`. Give it a single 4.2BSD partition spanning\nthe disk, `newfs` it, mount it at `/build`, and `chown` it to `openbsd` so the later rsync\nand build run without root. The `printf` drives the interactive `disklabel -E` editor (`a a`\nadd partition, then default offset/size/type, `w` write, `q` quit) — it is fiddly and copied\nverbatim from CI. **Run this once:** `newfs` reformats, so re-running destroys whatever is\non `/build`.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\nprintf 'a a\\n\\n\\n\\nw\\nq\\n' | doas disklabel -E sd1\ndoas newfs /dev/rsd1a\ndoas mkdir -p /build\ndoas mount /dev/sd1a /build\ndoas chown openbsd:wheel /build\ndf -h /build\nEOF\n```\n\nIf the `doas` commands here fail (permission denied, or `doas` prompts for a password),\ncloud-init's `write_files` never created `/etc/doas.conf` — the seed didn't apply. Don't\nretype the disk commands; nothing here works until `doas` does, so work through the Step 4\nrecovery (is the VM up? is `seed.iso` attached as a **cdrom**?) first.\n\n### 6. Install deps + raise the datasize limit\n\n`doas pkg_add` installs the build tools (the `python%3`/`rsync--` spellings are correct —\nsee Gotchas). Then raise the per-process `datasize` ceiling for the `openbsd` user — and\ntreat this as intent, not a fixed substitution. OpenBSD's `/etc/login.conf` caps each\nprocess's data segment in the `default` login class (the class the `openbsd` user falls\nunder), and the LLVM build needs roughly 4 GB, so both `datasize-cur` and `datasize-max`\nmust end up at least `8192M` (what CI sets). On the stock OpenBSD 7.9 image both default to\n`1536M`, so the two `sed`s below do it. But the goal is the `8192M` ceiling, not the literal\n`1536M`: if a future image ships a different default the `sed`s match nothing, so read the\ncurrent values (the trailing `grep` prints them) and, if either is below `8192M`, raise it\nto `8192M` directly — adjust the `sed` pattern or edit `/etc/login.conf`. Confirm both read\n`8192M` before continuing; too low and the libs build dies with allocation failures partway\nthrough, far from this step. The edit persists on the root disk and re-running is a no-op.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ndoas pkg_add -u && doas pkg_add cmake gmake git python%3 rsync--\n# Raise the openbsd user's datasize cap to 8192M. The stock 7.9 image\n# defaults both to 1536M; if a future image differs, the sed matches\n# nothing — read the grep below and set both to 8192M however fits.\ndoas sed -i 's/datasize-max=1536M/datasize-max=8192M/' /etc/login.conf\ndoas sed -i 's/datasize-cur=1536M/datasize-cur=8192M/' /etc/login.conf\ngrep -E 'datasize-(cur|max)=' /etc/login.conf   # confirm both now read 8192M\nEOF\n```\n\n### 7. Rsync the ponyc checkout in (exclude the host `build/`)\n\nRun from your ponyc checkout. Exclude the top-level `build/` (host artifacts; the VM builds\nits own — this `--exclude` is a deliberate divergence from CI, whose fresh checkout has none\nto skip). Keep `.git` (CMake runs `git rev-parse`). The vendored LLVM submodule under\n`lib/llvm/src` IS transferred — the VM needs it for the libs build.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nrsync -az --exclude='/build' \\\n  -e \"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222\" \\\n  \"$(git rev-parse --show-toplevel)/\" openbsd@localhost:/build/ponyc/\n```\n\n## Using the VM\n\nThe build uses OpenBSD's base clang — no `CC`/`CXX`/`LD_LIBRARY_PATH` exports (that is the\nDragonFly-only gcc13 dance). `cmake -P lib/build-libs.cmake` builds the vendored LLVM and is the multi-hour long\npole — run it detached, then poll the log until it ends with `libs DONE rc=0` (a nonzero rc\nmeans it failed — read the log above the marker). Keep `-DJOBS=4` (what CI uses):\neach parallel compile is bounded by the 8192M `datasize` cap, so higher parallelism is the\nfirst thing to suspect on a libs out-of-memory.\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncat > /build/run-libs.sh <<'S'\n#!/bin/sh\ncd /build/ponyc && cmake -DJOBS=4 -P lib/build-libs.cmake\necho \"libs DONE rc=$?\"\nS\nchmod +x /build/run-libs.sh\nnohup /build/run-libs.sh > /build/libs-build.log 2>&1 &\necho \"libs building pid $!\"\nEOF\n# poll until done: $SSH /bin/sh -c 'tail -3 /build/libs-build.log'  -> wait for \"libs DONE rc=0\"\n```\n\nOnce `libs` is built (it persists in the VM), build and test with the same shell. Do not run\nthis block until the poll shows `libs DONE rc=0` — even `cmake --preset debug` needs\nthe built LLVM (CMake's `find_package(LLVM)` fails without it), so it cannot run before\nthe libs build:\n\n```sh\nVMDIR=~/vms/openbsd-7.9\nSSH=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $VMDIR/vm_key -p 2222 openbsd@localhost\"\n$SSH /bin/sh <<'EOF'\nset -e\ncd /build/ponyc\ncmake --preset debug\ncmake --build --preset debug\nctest --preset debug -L ci-core\nEOF\n```\n\nTo iterate on a fix: edit on the host, re-run Step 7's `rsync` (it's incremental — only\nchanged files are sent), then re-run the build block above (long rebuilds should also be\ndetached + polled). For exact CI parity, the tier-3 `openbsd` job\n(`.github/workflows/ponyc-tier3.yml`) runs more than this. In order: an\nunsupported-build-rejection smoke that runs **before** the libs build (it needs no LLVM —\n`.ci-scripts/openbsd-reject-unsupported-builds.sh` asserts `use=address_sanitizer`,\n`thread_sanitizer`, `undefined_behavior_sanitizer`, `coverage`, and `valgrind` are\neach rejected with \"not supported on OpenBSD\"); then debug configure/build; a `--static`\nembedded-LLD link smoke (OpenBSD static is static-PIE, so the binary's `file` output reads\n\"shared object\" — the smoke instead asserts `readelf -d` shows **no** `(NEEDED)` entries);\nthe debug `ci-core` suite; the self-hosted tool tests (`pony-doc-tests`/`pony-lint-tests`/\n`pony-lsp-tests`); and a release build + the `ci-core` suite. Consult that job when validating a\nCI-matching issue.\n\n## Lifecycle\n\n```sh\nVMDIR=~/vms/openbsd-7.9; OPENBSD_VERSION=7.9; cd \"$VMDIR\"\npgrep -af \"qemu-system-x86_64 -name openbsd-${OPENBSD_VERSION}\"        # status\nssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i vm_key -p 2222 openbsd@localhost   # interactive shell\n# stop: kill the process. Disks persist for next time. The pgrep/pkill are scoped to this\n# version so a second OpenBSD VM isn't matched; the pidfile path is per-$VMDIR anyway.\nkill \"$(cat openbsd.pid)\" 2>/dev/null || pkill -f \"qemu-system-x86_64 -name openbsd-${OPENBSD_VERSION}\"\n```\n\nTo restart later, re-run the step-3 boot command (the disks and installed deps persist; you\ndo not redo setup). On a reboot, ssh-as-`openbsd` works immediately and `doas` still works\n(`/etc/doas.conf` persisted), and the raised `datasize` limit is still in `/etc/login.conf`\n— but the `/build` data disk is **not** auto-mounted (there is no `/etc/fstab` entry), so\nremount it before building: `doas mount /dev/sd1a /build`. The source under `/build/ponyc`\nis preserved on the data disk across the remount. For a long-lived VM, add the mount to\n`/etc/fstab` so it survives reboots.\n\n## How this relates to CI (don't re-add the CI-only steps)\n\nThis mirrors `.ci-scripts/bsd/openbsd-provision.bash` (the source of truth — keep this skill\nin sync if it changes). The local setup deliberately differs from CI, and each difference is\nsafe:\n\n- Skips CI host-bootstrap (free disk space, `apt-get` qemu, make `/dev/kvm` writable) — your\n  host already has qemu and a usable hardware accelerator (Step 0 verified it).\n- Persistent VM instead of built-fresh-and-killed per run.\n- ssh adds `-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR` to quiet the host-key\n  warning: a persistent VM reused on `localhost:2222` collides with the stale `known_hosts`\n  entry from a prior VM. CI's ephemeral runners never reuse the port, so\n  `openbsd-provision.bash` doesn't bother.\n- `-smp 8` / `-m 12G` for build speed (CI uses 4 / 12G), and a `-pidfile` for the lifecycle\n  commands.\n- `rsync --exclude='/build'` to skip the host's build artifacts (CI's fresh checkout has\n  none).\n- No GHCR libs cache (that's token-gated CI plumbing) — you just run `cmake -P lib/build-libs.cmake` once.\n\nThe FreeBSD and DragonFly CI VMs follow the same shape\n(`.ci-scripts/bsd/{freebsd,dragonfly}-provision.bash`) and each has its own skill:\n`create-freebsd-dev-env` (also cloud-init, but base libc++, an `expect`/`su` root\nbootstrap, and the source under `/home/freebsd` rather than a `/build` disk) and\n`create-dragonfly-dev-env` (gcc-only, and it drives a VGA console). This skill is\nOpenBSD-only; load a sibling if you need one of those.\n\n<!-- File: .claude/skills/update-ssl-builders/SKILL.md (Tokens: ~1490 | Category: anthropic-skill) -->\n---\nname: update-ssl-builders\ndescription: Twice-yearly workflow for bumping ponyc CI SSL builder images (OpenSSL, LibreSSL) to their latest series releases. Invoked via /update-ssl-builders.\ndisable-model-invocation: false\n---\n\n# Update SSL Builders\n\nponyc CI tests `packages/net/` and `packages/crypto/` against multiple SSL implementations. Each has its own Docker builder image under `.ci-dockerfiles/ubuntu26.04-builder-with-<library>-<version>/`. This skill walks through bumping those builders to the latest upstream releases.\n\n## Background\n\n**One version per series**: These are internal CI builders. Each series keeps exactly one version — an update replaces the old version with the new one in a single PR.\n\n**OpenSSL 1.1.1 is EOL**: The `openssl-1.1.1w` builder exists for backward-compatibility testing. This skill does not update or drop it.\n\n**Builders are amd64-only**: Each builder directory contains two files: `Dockerfile` and `build-and-push.bash`. No multiplatform build, no `combine-images.bash`, no `README.md`.\n\n**SSL flag mapping**: The `ssl-backends-test` matrix in `pr.yml` maps each SSL library to a Pony compiler flag:\n\n| Library series | Pony flag |\n|---|---|\n| `libressl-*` | `-Dlibressl` |\n| `openssl-3.*` | `-Dopenssl_3.0.x` |\n| `openssl-4.*` | `-Dopenssl_4.0.x` |\n\nIf a new major version line appears upstream (e.g., OpenSSL 5.x) that has no existing Pony compiler flag, do not add a builder. Open a task issue on this repo to add support for testing with the new version, noting the upstream release.\n\n**Three workflow files reference SSL builders**:\n\n- `rebuild-ssl-builder-images.yml` — `matrix.ssl` list; rebuilds all builders when the base image changes.\n- `build-builder-image.yml` — `inputs.builder-name` dropdown and per-builder job blocks; builds a single chosen builder on demand.\n- `pr.yml` — `ssl-backends-test.strategy.matrix.include`; runs `net` and `crypto` tests against each builder image.\n\n## Phase 1: Inventory\n\nList all `ubuntu26.04-builder-with-*` directories under `.ci-dockerfiles/` and group them by library + series. Ignore `openssl-1.1.1w`.\n\n## Phase 2: Fetch upstream releases\n\n- LibreSSL: https://www.libressl.org/releases.html\n- OpenSSL: https://openssl-library.org/source/\n\nFor each library, find the highest version number available across all currently-supported upstream branches.\n\n## Phase 3: Compute updates and confirm\n\n**Series definition (deliberate project policy)**: One major version line = one series. LibreSSL 3.x, LibreSSL 4.x, OpenSSL 3.x, OpenSSL 4.x are each a single series. This collapses OpenSSL's concurrently-supported LTS branches (e.g., 3.0/3.4/3.5/3.6) into one series — a new release in any 3.x branch counts as \"the latest of OpenSSL 3.x.\"\n\nFor each series (excluding OpenSSL 1.x):\n- If the upstream latest is newer than what we have, plan to **replace** the current version with the new one.\n- If upstream is not newer, no changes for that series.\n\nPresent the plan to the user and wait for explicit confirmation before making any changes.\n\n## Phase 4: Per-update workflow (one PR per series)\n\nEach series update gets its own PR. The PR replaces the old builder with the new one.\n\n1. Create a new branch.\n2. Create a new directory `.ci-dockerfiles/ubuntu26.04-builder-with-<library>-<new-version>/`.\n3. Copy the two files from the current version in the same series: `Dockerfile` and `build-and-push.bash`.\n4. Bump version references in each file:\n   - **Dockerfile**: The version ARG (`OPENSSL_VERSION` or `LIBRESSL_VERSION`). The download URL pattern is stable within a major version line and should not need changing.\n   - **build-and-push.bash**: The `NAME` variable (full GHCR image path including the version) and the `BUILDER` variable (strip dots and dashes from the version, e.g., `ssl-openssl372`, `ssl-libressl430`).\n5. Delete the old directory `.ci-dockerfiles/ubuntu26.04-builder-with-<library>-<old-version>/`.\n6. Edit `.github/workflows/rebuild-ssl-builder-images.yml`: replace the old `<library>-<old-version>` entry in `matrix.ssl` with `<library>-<new-version>`.\n7. Edit `.github/workflows/build-builder-image.yml`:\n   - Replace the old builder name with the new one in the `options` list.\n   - Replace the old job block with a new one. The job key uses underscores for dots (e.g., `ubuntu26_04-builder-with-openssl-3_7_0`). Copy the structure from the old job, updating the `if:` condition, `name:` field, and `run:` step to reference the new directory.\n8. Commit, push, open PR. Do **not** update `pr.yml` yet — there is no image to reference.\n9. Dispatch the build workflow on the branch to build and push the new image to GHCR:\n   ```\n   gh workflow run build-builder-image.yml --ref <branch> -f ref=<branch> -f builder-name=ubuntu26.04-builder-with-<library>-<new-version>\n   ```\n   `--ref <branch>` is required — the new builder name only exists in the branch's `choice` input list. The image is tagged with today's date (`YYYYMMDD` from `build-and-push.bash`).\n10. Wait for the workflow run to complete successfully. Verify the image exists on GHCR:\n    ```\n    gh api orgs/ponylang/packages/container/ponyc-ci-ubuntu26.04-builder-with-<library>-<new-version>/versions --jq '.[0].metadata.container.tags'\n    ```\n11. Edit `.github/workflows/pr.yml`: in `ssl-backends-test.strategy.matrix.include`, replace the old entry with the new one:\n    ```yaml\n    - ssl-name: <library>-<new-version>\n      ssl-flag: '<flag from mapping table>'\n      image: ghcr.io/ponylang/ponyc-ci-ubuntu26.04-builder-with-<library>-<new-version>:<YYYYMMDD>\n    ```\n12. Commit and push.\n13. Wait for PR CI to pass. The `ssl-backends-test` suite should now include the new builder and its `net`/`crypto` tests should pass.\n14. Squash-merge the PR.\n\nExisting tagged images for the old version on GHCR are left intact — they simply stop receiving updates because they are no longer in the rebuild workflow's matrix.\n\n<!-- File: .claude/skills/upgrade-llvm/SKILL.md (Tokens: ~3692 | Category: anthropic-skill) -->\n---\nname: upgrade-llvm\ndescription: Load when upgrading the vendored LLVM submodule in ponyc. Covers the per-version commit strategy, submodule mechanics, patch handling, hash updates, and common API migration patterns.\ndisable-model-invocation: false\n---\n\n# Upgrading the vendored LLVM in ponyc\n\nponyc vendors LLVM as a submodule at `lib/llvm/src`, pinned by hash in\n`lib/CMakeLists.txt` and patched from `lib/llvm/patches/`. Upgrading it means\nmoving the pin, re-checking the patches, fixing whatever LLVM removed or\nchanged under `src/libponyc/codegen/`, and rebuilding LLVM from source.\n\nRun every command from the repo root.\n\n## Gotchas that will bite you (read first)\n\n- **The submodule is a shallow clone.** `lib/CMakeLists.txt` clones it with\n  `--depth 1` and fetches no tags. A plain `git fetch origin tag llvmorg-XX.Y.Z`\n  deepens it to the whole of llvm-project's history up to that tag. Pass\n  `--depth 1`.\n- **A patch that fails the forward check has not necessarily been upstreamed.**\n  The forward check also fails when the patch is already applied to the tree,\n  and when `git apply` cannot read the tree at all. Work through step 4 before\n  you delete anything.\n- **Don't `rm -rf build`.** That directory holds every configured build tree,\n  not just LLVM's. The vendored LLVM builds in `build/build_libs` and installs\n  to `build/libs`; remove only those two.\n- **Stage the submodule pointer before you build.** `cmake -P\n  lib/build-libs.cmake` runs `git submodule update --init`, which resets\n  `lib/llvm/src` to the recorded pointer. A pointer change that exists only in\n  the working tree is lost.\n- **ponyc still builds when an intrinsic's signature changes.** The failure\n  comes later, when ponyc compiles a Pony program, so run the tests and not\n  just the build.\n\n## Strategy\n\nUpgrade one major version at a time, and give each version its own commit. One\nPR can carry more than one version. Separate commits show which LLVM version\neach source change came from, and let you bisect the branch when something\nbreaks. ponylang repos squash-merge, so those commits become one commit on\n`main` — you can only bisect them while the branch is open.\n\n## Steps\n\nSteps 1, 8, and 9 run once for the PR. Steps 2 through 7 repeat for each major\nversion you move through.\n\n### 1. Branch\n\nBranch off `main`.\n\n### 2. Update the submodule\n\n```bash\n# Discard any patches applied to the tree\ngit -C lib/llvm/src checkout -- .\n# Fetch and check out the new tag, keeping the clone shallow\ngit -C lib/llvm/src fetch --depth 1 origin tag llvmorg-XX.Y.Z\ngit -C lib/llvm/src checkout llvmorg-XX.Y.Z\n```\n\n`git checkout -- .` throws away every modification to a tracked file in the\nsubmodule, including a patch you rebased by hand. Save that work outside the\ntree first.\n\n### 3. Update `LLVM_DESIRED_HASH`\n\nGet the new commit hash:\n\n```bash\ngit -C lib/llvm/src rev-parse HEAD\n```\n\nSet `LLVM_DESIRED_HASH` in `lib/CMakeLists.txt` to this value.\n\n### 4. Check the patches\n\nThe build applies every `.diff` in `lib/llvm/patches/`, so check each one.\nRun the same check the build runs, with the same flags:\n\n```bash\ngit -C lib/llvm/src apply --check -p 1 --ignore-whitespace --whitespace=nowarn \\\n  ../patches/<patch>.diff\n```\n\nIf that fails, run the reverse check before concluding anything:\n\n```bash\ngit -C lib/llvm/src apply --reverse --check -p 1 --ignore-whitespace \\\n  --whitespace=nowarn ../patches/<patch>.diff\n```\n\n- Forward check passes: the patch still applies. Keep it.\n- Forward fails, reverse passes: the change is already in the tree. On a tree\n  you reset in step 2, LLVM upstreamed it, so delete the patch file. On a tree\n  you did not reset, an earlier build applied it — reset and check again.\n- Both fail: either LLVM moved the code the patch touches, or `git apply` could\n  not read the tree. In the second case `git apply` reports `fatal: not a git\n  repository`, which comes from a dangling submodule gitlink — a `.git` file\n  pointing at a `.git/modules/...` target that isn't there, left behind when\n  the tree was copied or rsynced without the parent's `.git/modules`.\n  Otherwise, rebase the patch onto the new source rather than deleting it.\n\n### 5. Commit the submodule pointer\n\n```bash\ngit add lib/llvm/src lib/CMakeLists.txt\ngit commit -m \"Upgrade LLVM AA.B.C -> XX.Y.Z\"\n```\n\n`cmake -P lib/build-libs.cmake` resets the submodule to the pointer recorded in\nthe index, so stage this before step 6 or the new pointer is lost. Amend this\ncommit as the next two steps produce changes.\n\n### 6. Build, and fix what breaks\n\n```bash\nrm -rf build/libs build/build_libs         # Drop the old LLVM; the next command rebuilds it\ncmake -DJOBS=<n> -P lib/build-libs.cmake   # Build LLVM; see BUILD.md for picking <n>\ncmake --preset release                     # Configure the ponyc build\ncmake --build --preset release             # Build ponyc\n```\n\n`JOBS` defaults to 4. BUILD.md says to pass your own core count and keep it\nmodest, because LLVM is memory-hungry. On Windows use the Windows presets\n(BUILD.md).\n\nTwo kinds of failure come out of this step.\n\n**A patch-hash mismatch**, if you added, removed, or edited a patch file in\nstep 4. The lib configure step prints both hashes and stops:\n\n```\nPatch hash actual '<actual>' does not match desired '<desired>'\n```\n\nCopy `<actual>` into `PATCHES_DESIRED_HASH` in `lib/CMakeLists.txt`. The hash\nis a SHA256 over the seed string `needed_if_no_patches` and the SHA256 of each\npatch file's text with newlines squashed to spaces, which is awkward to\nreproduce in a shell — let CMake compute it. If no patches remain at all, the\nvalue is `SHA256(\"needed_if_no_patches\")` =\n`3e16c097794cb669a8f6a0bd7600b440205ac5c29a6135750c2e83263eb16a95`.\n\n**Compile errors**, from LLVM APIs that were removed or changed. See \"Common\nmigration patterns\" below. Fix them, amend the commit, and build again.\n\n### 7. Run the tests\n\n```bash\nctest --preset release -L ci-core\n```\n\nThe `ci-core` label covers `libponyc.tests`, where the codegen assertions live,\nalong with the full-program, stdlib, and examples tests.\n\nThis is narrower than what the PR will run: CI runs `ci-core` in both debug and\nrelease on Linux, macOS, and Windows, and separately builds and runs\n`pony-compiler-tests`. A failure that only shows up in one config, or on one\nplatform, will not appear here.\n\nTo reproduce a tier-3 BSD failure, the `create-dragonfly-dev-env`,\n`create-freebsd-dev-env`, and `create-openbsd-dev-env` skills stand up a local\nVM matching that CI job.\n\n### 8. Add a release note\n\nAn LLVM upgrade is user-facing. Add `.release-notes/update-llvm-to-XX.md`:\n\n```markdown\n## Update to LLVM XX.Y.Z\n\nWe've updated the LLVM version used to build Pony from AA.B.C to XX.Y.Z.\n```\n\nDo not edit `next-release.md`; CI aggregates the individual files.\n\n### 9. Push and open the PR\n\nTitle the PR `Update to LLVM XX.Y.Z`, matching the release note. Before merging,\na Pony team member adds a `changelog - changed` label, which puts a line built\nfrom the PR title into that CHANGELOG section. That is a separate mechanism from\nthe release note file, which reaches `next-release.md` on its own.\n\n## Common migration patterns\n\n### `LLVMConst*` to `LLVMBuild*` (constant expression removal)\n\nLLVM removed a number of constant expression functions. The `LLVMBuild*`\nequivalents constant-fold when given constant operands, so they compute the\nsame thing; they take a builder and a name as well:\n\n```c\n// Before:\nreturn LLVMConstShl(l_value, r_value);\n// After:\nreturn LLVMBuildShl(c->builder, l_value, r_value, \"\");\n```\n\n`LLVMConstICmp`, `LLVMConstFCmp`, `LLVMConstShl`, and `LLVMConstMul` are gone\nfrom LLVM 22.1.6's `Core.h`. `LLVMConstAdd` and `LLVMConstSub` are still there\nand carry no deprecation, but ponyc calls none of the six.\n\n### Deprecated to newer API variants\n\nOn the clang and gcc builds ponyc's C files compile with `-Werror`, so a\ndeprecated LLVM C function fails the build if its declaration carries\n`LLVM_ATTRIBUTE_C_DEPRECATED`, which produces a compiler warning. The MSVC build\npasses `/wd4996` alongside `/WX`, so the same call compiles clean on Windows.\n\nA function marked deprecated only in a doc comment produces no warning\nanywhere. The headers use two spellings for that, `@deprecated` and a plain\n`Deprecated: use X instead`, so grep for both when you want to know what ponyc\ncalls that upstream has marked. The list below is a starting point, not a\ncomplete one.\n\n`LLVMGetMDKindID` carried the attribute rather than a doc comment, so it did\nfail the build. That migration is done; `src/` now calls\n`LLVMGetMDKindIDInContext`. `LLVMBuildNUWNeg` also carries the attribute — its\nreplacement is `LLVMBuildNeg` followed by `LLVMSetNUW`, which ponyc wraps as\n`LLVMSetNoUnsignedWrap` in `host.cc`. `LLVMBuildNSWNeg` is not deprecated.\n\n### A new link-time dependency\n\nAn LLVM upgrade can make ponyc need a system library it did not need before,\nand the need can be Windows-only, so a local Linux build will not show it. The\nLLVM 21 upgrade (`22e84c4a1`) added `ntdll.lib` to the Windows link line in\n`src/libponyc/platform/vcvars.c` and to three `CMakeLists.txt` files.\n\n### Intrinsic signature changes\n\nLLVM can change an intrinsic's signature in a major version. These do not show\nup as compile errors; ponyc builds clean. They surface as a module verification\nfailure when ponyc compiles a Pony program (`Incorrect number of arguments\npassed to called function!`), which is why step 7 runs the tests.\n\n- **LLVM 22**: `llvm.lifetime.start` and `llvm.lifetime.end` dropped their\n  leading `i64 size` argument and now take a single pointer. ponyc emits these\n  in `gencall_lifetime_start` and `gencall_lifetime_end` (`gencall.c`). Drop\n  the size operand, drop the `type` parameter the size was computed from, and\n  call with one argument:\n\n```c\n// Before (2 args: size, ptr):\nLLVMValueRef args[2];\nargs[0] = LLVMConstInt(c->i64, size, false);\nargs[1] = ptr;\nLLVMBuildCall2(c->builder, func_type, func, args, 2, \"\");\n// After (1 arg: ptr):\nLLVMBuildCall2(c->builder, func_type, func, &ptr, 1, \"\");\n```\n\n`CodegenTest.LifetimeIntrinsicsHaveSinglePointerArgument` in\n`test/libponyc/codegen.cc` asserts the emitted argument count. When a later\nLLVM changes another intrinsic's signature, assert its argument count the same\nway.\n\n### C++ API changes\n\nponyc also embeds LLD (`genexe.cc`) and clang's frontend (`gencshim.cc`). Those\nC++ APIs carry no stability guarantee and break more often than LLVM's C API.\n\n- **LLVM 19**: `DIBuilder::insertDeclare` returns `DbgInstPtr` (PointerUnion)\n  instead of `Instruction*`. If callers don't use the return value, change the\n  wrapper's return type to `void`.\n- **LLVM 20**: `Intrinsic::getDeclaration` to\n  `Intrinsic::getOrInsertDeclaration`.\n- **LLVM 20**: Optimizer extension point callbacks\n  (`registerOptimizerEarlyEPCallback`, `registerOptimizerLastEPCallback`) now\n  take a `ThinOrFullLTOPhase` parameter in the lambda.\n- **LLVM 21**: `Attribute::NoCapture` to\n  `Attribute::getWithCaptureInfo(ctx, CaptureInfo::none())`.\n- **LLVM 21**: `LintPass()` requires a `bool AbortOnError` parameter:\n  `LintPass(false)`.\n- **LLVM 22**: `createTargetMachine` takes a `const Triple&` instead of a\n  `StringRef`; wrap with `Triple(opt->triple)`. ponyc made this change under\n  LLVM 21, in `3eae0a119`, while the `StringRef` overload was still there and\n  deprecated.\n\n## APIs to check on every upgrade\n\nAPIs ponyc calls that a future LLVM may break:\n\n| API | Location | Likely replacement |\n|-----|----------|-------------------|\n| `LLVMArrayType` | codegen.c, gendesc.c, genident.c | `LLVMArrayType2` (`uint64_t` count) |\n| `LLVMConstArray` | gendesc.c, genident.c | `LLVMConstArray2` (`uint64_t` count) |\n| `LLVMConstStringInContext` | codegen.c | `LLVMConstStringInContext2` (`size_t` length) |\n| `LLVMConstNeg` | genoperator.c | `LLVMBuildNeg` |\n| `LLVMConstNot` | genoperator.c | `LLVMBuildNot` |\n| `LLVMConstXor` | genoperator.c | `LLVMBuildXor` |\n| `LLVMConstTrunc` | genreference.c | `LLVMBuildTrunc` |\n\nThe first three carry an `@deprecated` doc comment; the other four are not\ndeprecated. The build fails once one of them gains the deprecation attribute or\nis removed. Re-check this table against the new headers as part of the upgrade.\n\n## Files typically modified\n\n- `lib/CMakeLists.txt` — `LLVM_DESIRED_HASH`, `PATCHES_DESIRED_HASH`\n- `lib/llvm/patches/` — patch files, added or removed\n- `.release-notes/` — the release note\n- `src/libponyc/codegen/genoperator.c` — constant expression replacements\n- `src/libponyc/codegen/genreference.c` — constant expression replacements\n- `src/libponyc/codegen/gendebug.cc`, `gendebug.h` — debug info API changes\n- `src/libponyc/codegen/host.cc` — target machine, intrinsics\n- `src/libponyc/codegen/genopt.cc` — optimizer pass pipeline\n- `src/libponyc/codegen/gendesc.c` — array and descriptor construction\n- `src/libponyc/codegen/genident.c` — array construction\n- `src/libponyc/codegen/codegen.c` — core codegen types and helpers\n- `src/libponyc/codegen/gencontrol.c` — metadata APIs\n- `src/libponyc/codegen/gencall.c`, `gencall.h` — intrinsic emission\n  (lifetime markers, memcpy and memmove)\n- `src/libponyc/codegen/genexe.cc` — embedded LLD\n- `src/libponyc/codegen/gencshim.cc` — embedded clang frontend\n- `src/libponyc/platform/vcvars.c`, `src/ponyc/CMakeLists.txt`,\n  `test/libponyc/CMakeLists.txt`, `benchmark/libponyc/CMakeLists.txt` — link\n  dependencies\n- `test/libponyc/codegen.cc` — codegen assertions\n\nA bigger LLVM can also push a CI job past its disk limit. During the LLVM 22\nupgrade we moved the OpenBSD tier-3 job onto a dedicated 50 GB data disk; the\nscript that creates it is in `.ci-scripts/bsd/`.\n\n<!-- File: packages/net/AGENTS.md (Tokens: ~1934 | Category: anthropic-skill) -->\n# Net package\n\nThe `net` package provides Pony's networking. It reworks the original stdlib `net` package around a different split: the connection logic lives in a plain `class` (`TCPConnection`, `TCPListener`, `UDPSocket`) that the user's `actor` holds and delegates to, rather than being baked into a single actor.\n\n<!-- contributor-only -->\n## Contributing with an AI assistant\n\nThis is a Pony project. The ponylang org maintains a set of LLM coding skills. Get set up with them before contributing:\n\n- **Not set up yet?** Install them once:\n\n  ```bash\n  git clone https://github.com/ponylang/llm-skills.git\n  cd llm-skills\n  python install.py\n  ```\n\n- **Already set up?** Make sure you're on the latest. If you installed with the script above, `git pull` in the directory where you cloned `llm-skills` and the symlinked skills update automatically — if you set them up another way, refresh them however that setup expects.\n\nSee the [llm-skills README](https://github.com/ponylang/llm-skills) for details and other harnesses.\n\nWhen you start working on this project, load the `pony-skills` skill — it tells your assistant which Pony skill to use for each task.\n\nRead [CONTRIBUTING.md](../../CONTRIBUTING.md).\n<!-- /contributor-only -->\n\n## Architecture\n\nA client connect subscribes one socket per resolved address at once (Happy Eyeballs). Whichever connects first becomes the connection's own event; the rest are stragglers, and each has to be unsubscribed and its fd closed when its event arrives. That is why every state handles `own_event` and `foreign_event` separately, and what `_UnconnectedClosing` drains.\n\n### Connection lifecycle\n\n`TCPConnection` tracks its lifecycle with explicit state objects — the `_ConnectionState` trait and its implementers in `_connection_state.pony` — rather than boolean flags.\n\n```\n_ConnectionNone     → _ClientConnecting\n                    → _Open              (server, plaintext)\n                    → _SSLHandshaking    (server, SSL)\n                    → _Closed            (SSL session creation failed)\n_ClientConnecting   → _Open              (connected, plaintext)\n                    → _SSLHandshaking    (connected, SSL)\n                    → _UnconnectedClosing (close, drain stragglers)\n                    → _Closed            (hard_close / all attempts failed)\n_SSLHandshaking     → _Open              (ssl_handshake_complete)\n                    → _Closed            (hard_close / SSL error)\n_Open               → _Closing           (close)\n                    → _TLSUpgrading      (start_tls)\n                    → _Closed            (hard_close)\n_TLSUpgrading       → _Open              (ssl_handshake_complete)\n                    → _Closed            (hard_close / TLS error)\n_Closing            → _Closed            (drained, or hard_close)\n_UnconnectedClosing → _Closed            (drained, or hard_close)\n```\n\n### UDP lifecycle\n\n`UDPSocket` uses the same state-object pattern with three states.\n\n```\n_UDPNone    → _UDPOpen   (bind succeeded)\n            → _UDPClosed (bind failed, or dispose/init race)\n_UDPOpen    → _UDPClosed (close or error)\n```\n\nThe dispose/init race: `_finish_initialization` is a self→self behavior. If `dispose()` arrives first, `_UDPNone.close()` transitions to `_UDPClosed`, and `_finish_initialization` checks for that and skips the bind.\n\n### Connected UDP lifecycle\n\n`ConnectedUDPSocket` uses the same state-object pattern. Initialization has three outcomes: bind failure, connect failure, or success.\n\n```\n_ConnectedUDPNone    → _ConnectedUDPOpen   (bind and connect succeeded)\n                     → _ConnectedUDPClosed (bind failed, connect failed, or dispose/init race)\n_ConnectedUDPOpen    → _ConnectedUDPClosed (close or error)\n```\n\nThe dispose/init race works the same way as `UDPSocket`. The peer is fixed at creation — there is no disconnect or reconnect.\n\n## Traps\n\n- **One read loop; keep `_on_received` out of `_ssl_poll()`.** An earlier design had `_ssl_poll()` deliver application data from a second loop of its own, so mute and the liveness check had to be written twice. Both shipped as bugs before the second copy existed.\n\n- **`_ssl` is a `_TLSState`, not `(SSL | None)`.** `(SSL | None)` was the earlier design. It records whether a session exists but not whether one may be used, and that ambiguity was a segfault.\n\n- **Mint the send token before the flush.** The flush can close the connection, so a token minted after it loses the terminal callback for a send whose bytes already went out. It is also why `send()` returns `SendAccepted` rather than the token: `_on_sent` is a direct call, so the application has to be holding the token before the flush runs. Keep every error return upstream of the mint, so an errored send burns no token id.\n\n- **`_on_send_failed` is deferred; `_on_sent` is not.** `_on_sent` was deferred too, for the same-looking reason, and it was wrong: a send reaching the OS is its own event, and queueing it let callbacks for later events overtake it.\n\n- **The hard-close reason is the `_HardCloseCause` argument, not a field.** An earlier design set one of three fields (`_connect_timed_out` and the like) right before an argumentless `hard_close()` and dug it back out after. Do not put it back in a field.\n\n- **`close_notify` is deferred to `_Closing.drained()`, not sent in `_Open.close()`.** `ssl.close()` calls `SSL_shutdown`, which makes `SSL_read` return `SSL_ERROR_ZERO_RETURN` — buffered TLS records the read loop has not yet delivered are lost.\n\n- **`_set_unwriteable()` before `_asio.resubscribe_write()`.** The ponyc epoll backend's `pony_asio_event_resubscribe()` only includes `EPOLLOUT` when `!ev->writeable`. After `_dispatch_io_event()` sets writeable, the flag stays true, so a later resubscribe is a no-op for the write side. Clear the flag first.\n\n- **A stale foreign event is dropped once, in `_event_notify`, not in each state.** The check used to sit in each `foreign_event` instead. Three of those copies were removed because the suite still passed on Linux, where the second message does not arrive, and that shipped as a bug reachable only where kqueue is the backend. Do not push the check back down into the states.\n\n## Platform differences\n\nPOSIX and Windows share one readiness-based I/O path: one-shot readiness events (epoll/kqueue; `ProcessSocketNotifications` on Windows), resubscribe, then a synchronous `RuntimeBackend.receive`/`sendv`. Windows uses this path because ponyc removed IOCP; the floor is Windows 11 / Windows Server 2022.\n\nHow many messages one subscription delivers is platform-specific too. kqueue arms read and write as separate one-shot filters and sends a message from each (ponyc's `kqueue.c`), so a single subscribed socket can deliver two readiness messages; epoll and Windows combine both directions into one (`epoll.c`, `sock_notify.c`). Code written on the assumption of one message per subscription is wrong wherever kqueue is the backend — macOS is the one CI covers, but the BSDs use it too.\n\n## Conventions\n\n- `_Unreachable()` in a branch the compiler cannot prove impossible, rather than an empty `else`.\n- The runtime will not exit while an actor holds a live I/O resource, so a test has to dispose any actor that might still hold one when it ends, or CI hangs. That is the client a listener creates in `_on_listening` and every server actor returned from `_on_accept`. Happy Eyeballs can deliver more than one connection per client connect (one per resolved address), so store accepted servers in `let _servers: Array[Type]`, push in `_on_accept`, and iterate in `dispose`/`_on_closed`.\n- Tests bind listeners and sockets to port `\"0\"` (OS-assigned ephemeral port) and retrieve the actual port from `local_address()` in the ready callback (`_on_listening` for TCP, `_on_bound` for UDP, `_on_connected` for connected UDP). This avoids conflicts when multiple test runs execute concurrently on the same machine.\n- `\\nodoc\\` on test classes.\n- A new test goes in the `_test_*.pony` file for its functional area, registered in `Main.tests()` in `_test.pony`, which holds only the test runner.\n- Each example has a file-level docstring saying what it demonstrates, uses the Listener/Server/Client actor structure, and uses a unique port. Adding one means placing it under the appropriate `examples/networking/` subdirectory and adding it to `examples/README.md` under the Networking section.\n\n<!-- File: packages/net/CLAUDE.md (Tokens: ~5 | Category: anthropic-skill) -->\n@AGENTS.md\n\n<!-- File: tools/lib/README.md (Tokens: ~20 | Category: plugin-manifest) -->\n# tools/lib\n\nVendored library dependencies for the tools in the `tools/` directory.\n\n<!-- File: tools/pony-agent-server/README.md (Tokens: ~1495 | Category: plugin-manifest) -->\n# pony-agent-server\n\nA query server for AI coding agents that need Pony type information while editing code. It compiles a package and answers questions about types, definitions, capabilities, and structure.\n\nThe query interface is experimental and subject to change.\n\n## Usage\n\n```text\npony-agent-server <package-directory> [-D define]...\n```\n\nThe server compiles the package on startup, then reads one JSON query per line from stdin and writes one JSON response per line to stdout. Diagnostic messages go to stderr.\n\nPass `-D` flags the same way you would pass them to `ponyc`. For example, `-D openssl_3.0.x` for OpenSSL 3.x.\n\nRun `--queries` to print a summary of all queries and their input format.\n\n## Protocol\n\nEvery query is a JSON object with a `\"query\"` field. An optional `\"id\"` field (string or integer) is echoed back in the response.\n\nOn success, the response contains the query-specific fields described below. On error, the response contains an `\"error\"` field with a message.\n\n```json\n{\"query\":\"status\", \"id\":1}\n{\"id\":1, \"status\":\"ready\"}\n```\n\n## Queries\n\n### inspect\n\nType and definition of the AST node at a source position.\n\n```json\n{\"query\":\"inspect\", \"file\":\"/path/to/file.pony\", \"line\":10, \"column\":5}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `type` | string | The node's type as the compiler resolved it |\n| `node_kind` | string | AST node kind (e.g. `\"letref\"`, `\"call\"`) |\n| `line`, `column` | integer | Position of the node |\n| `definition` | object | Where the symbol is defined: `file`, `line`, `column`, `kind`, `declared_type` |\n| `enclosing` | object | Enclosing context: `method`, `receiver_cap`, `type` |\n| `in_recover` | boolean | Whether the position is inside a `recover` block |\n| `sendable` | boolean | Whether the type is sendable (iso, val, or tag) |\n\n### scope\n\nEvery name visible at a source position, with recover-block accessibility.\n\n```json\n{\"query\":\"scope\", \"file\":\"/path/to/file.pony\", \"line\":10, \"column\":5}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `in_recover` | boolean | Whether the position is inside a `recover` block |\n| `entries` | array | Each entry has `name`, `type`, `kind`, `accessible`, and optionally `reason` |\n\nWhen `accessible` is false, `reason` explains why (typically because a non-sendable reference is blocked by a recover expression).\n\n### exports\n\nPublic types exported by a package.\n\n```json\n{\"query\":\"exports\", \"package\":\"collections\"}\n```\n\nThe package can be a qualified name like `\"collections\"` or an absolute path.\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `package_path` | string | Resolved path to the package |\n| `entries` | array | Each entry has `name`, `kind` (class, actor, primitive, etc.), and optionally `docstring` |\n\n### type_api\n\nMethods callable on a type at a given capability. There are two ways to query: by type name with an explicit capability, or by source position (the type and capability are read from the compiler's AST).\n\nBy name:\n\n```json\n{\"query\":\"type_api\", \"type\":\"Array[String]\", \"cap\":\"ref\"}\n```\n\nBy position:\n\n```json\n{\"query\":\"type_api\", \"file\":\"/path/to/file.pony\", \"line\":10, \"column\":5}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `type` | string | The type name or expression |\n| `kind` | string | Entity kind: `\"class\"`, `\"actor\"`, `\"primitive\"`, `\"trait\"`, `\"interface\"`, `\"struct\"`, or `\"union\"` |\n| `callable` | array | Methods callable at this capability |\n| `not_callable` | array | Methods not callable, each with a `method` and `reason` |\n| `constructors` | array | Available constructors |\n| `traits` | array | Traits and interfaces the type provides |\n\nEach method object has: `name`, `receiver` (capability), `params` (array of `{name, type, has_default}`), `return`, `partial` (boolean), `kind` (`\"fun\"`, `\"be\"`, or `\"new\"`), and `source_type` (the type that defined the method).\n\n### check\n\nSubtype check between two types using the compiler's subtype checker.\n\n```json\n{\"query\":\"check\", \"source\":\"Collector ref\", \"target\":\"Describable ref\"}\n```\n\nTypes are a name optionally followed by a capability. Without a capability, the type's default is used (ref for classes, val for primitives, tag for actors).\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `compatible` | boolean | Whether the source is a subtype of the target |\n| `source_type` | string | Source type with resolved capability |\n| `target_type` | string | Target type with resolved capability |\n| `reason` | string | Explanation of the result |\n| `suggestions` | array | When incompatible, suggestions for making it work |\n\n### implementors\n\nTypes that implement a given trait or interface.\n\n```json\n{\"query\":\"implementors\", \"type\":\"Describable\"}\n```\n\nThe target must be a trait or interface.\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `type` | string | The queried trait or interface name |\n| `nominal` | array | Types that explicitly declare the trait/interface in their provides list |\n| `structural` | array | Types that match the interface's method signatures without declaring it (interfaces only; traits require nominal declaration) |\n\n### errors\n\nCompilation errors from the last compile or reload.\n\n```json\n{\"query\":\"errors\"}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `errors` | array | Each error has `message`, and optionally `file`, `line`, `column` |\n\n### reload\n\nRecompile the package. The server recompiles from the same directory it was started with and replaces the in-memory program on success. Queries issued after a failed reload get the previous successful compilation.\n\n```json\n{\"query\":\"reload\"}\n```\n\nThe response is the same as the initial compile: `{\"status\":\"ready\"}` on success, or `{\"status\":\"error\", \"errors\":[...]}` on failure.\n\n### status\n\nWhether the server has a compiled program ready for queries.\n\n```json\n{\"query\":\"status\"}\n```\n\nResponse fields:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `status` | string | `\"ready\"` or `\"not_compiled\"` |\n\n<!-- File: tools/pony-lsp/README.md (Tokens: ~1715 | Category: plugin-manifest) -->\n# pony-lsp - the Pony Language Server\n\n`pony-lsp` implements the [Language Server Protocol] for Pony. It communicates with editors via stdout/stdin, and is built and distributed alongside `ponyc` via `ponyup`.\n\nFor user documentation — installation and editor configuration — see the [pony-lsp documentation] on the Ponylang website.\n\n[Language Server Protocol]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/\n[pony-lsp documentation]: https://www.ponylang.io/use/pony-lsp/\n\n## Feature Support\n\n| **Feature** | **Description** |\n| ------- | ----------- |\n| **[Diagnostics][spec-diagnostics]** (push, pull, refresh and change notifications) | Ponyc errors and related information is reported as LSP diagnostics. |\n| **[Hover][spec-hover]** | Additional information is shown for: entities, methods, fields, local variables and references. |\n| **[Signature Help][spec-signature-help]** | Parameter hints are shown when the cursor is inside a call expression, with the active parameter highlighted. Includes the method's docstring when present. Requires the file to be saved — signature help is not available while the file has unsaved edits. |\n| **[Go To Definition][spec-definition]** | For most language constructs, you can go from a reference to its definition. |\n| **[Go To Declaration][spec-declaration]** | Navigate from a reference to the declaration site of a symbol. |\n| **[Go To Type Definition][spec-type-definition]** | Navigate to the type definition of the symbol under the cursor. |\n| **[Document Symbols][spec-document-symbols]** | pony-lsp provides a list of available symbols for each opened document. |\n| **[Workspace Symbols][spec-workspace-symbols]** | Fuzzy search over all symbols across the entire workspace. |\n| **[Document Highlight][spec-document-highlight]** | All occurrences of the symbol under the cursor are highlighted simultaneously across the document. |\n| **[Inlay Hints][spec-inlay-hints]** | Three kinds of hints are shown inline. For `let`, `var`, and field declarations with no type annotation, the full inferred type appears after the variable name. For annotated types where the capability is omitted — in variable and field declarations, function parameter types, generic type arguments, and union and tuple members — the missing capability keyword appears after the type name. For `fun` declarations, the implicit receiver capability appears before the function name and the implicit return type appears after the closing parenthesis. |\n| **[Find References][spec-references]** | All references to the symbol under the cursor are returned, with optional inclusion of the declaration site. |\n| **[Rename][spec-rename]** | Rename a symbol and all its references across the workspace. |\n| **[Folding Range][spec-folding-range]** | Code folding ranges are provided for blocks, methods, classes, and other structured constructs. |\n| **[Selection Range][spec-selection-range]** | Smart expand/shrink selection based on the AST structure of the document. |\n| **[Type Hierarchy][spec-type-hierarchy]** | Navigate the type hierarchy of Pony entities. `prepareTypeHierarchy` identifies the entity under the cursor; `supertypes` returns the types in its `is` clause; `subtypes` returns all types across the compiled module that directly provide it. |\n| **[Call Hierarchy][spec-call-hierarchy]** | Navigate the call hierarchy of Pony methods. `prepareCallHierarchy` identifies the method under the cursor; `incomingCalls` returns all callers across the workspace with their call sites; `outgoingCalls` returns all callees with their call sites within the method. |\n\n[spec-diagnostics]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#pull-diagnostics\n[spec-hover]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#hover-request-leftwards_arrow_with_hook\n[spec-signature-help]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_signatureHelp\n[spec-definition]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#goto-definition-request-leftwards_arrow_with_hook\n[spec-declaration]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#goto-declaration-request-leftwards_arrow_with_hook\n[spec-type-definition]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#goto-type-definition-request-leftwards_arrow_with_hook\n[spec-document-symbols]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#document-symbols-request-leftwards_arrow_with_hook\n[spec-workspace-symbols]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace-symbols-request-leftwards_arrow_with_hook\n[spec-document-highlight]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#document-highlights-request-leftwards_arrow_with_hook\n[spec-inlay-hints]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#inlay-hint-request-leftwards_arrow_with_hook\n[spec-references]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#find-references-request-leftwards_arrow_with_hook\n[spec-rename]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#rename-request-leftwards_arrow_with_hook\n[spec-folding-range]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#folding-range-request-leftwards_arrow_with_hook\n[spec-selection-range]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#selection-range-request-leftwards_arrow_with_hook\n[spec-type-hierarchy]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareTypeHierarchy\n[spec-call-hierarchy]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareCallHierarchy\n\nNew features are actively being added. Contributions are welcome — we are happy to provide help and guidance.\n\n## Settings\n\n`pony-lsp` supports settings via the [`workspace/configuration`] request and [`workspace/didChangeConfiguration`] notification.\n\n[`workspace/configuration`]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_configuration\n[`workspace/didChangeConfiguration`]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeConfiguration\n\n| **Setting** | **Type** | **Example** | **Description** |\n| ------- | ---- | ------- | ----------- |\n| `defines` | `Array[String]` | `[\"FOO\", \"BAR\"]` | Defines active during compilation, equivalent to the `-D` flag in `ponyc` |\n| `ponypath` | `Array[String]` | `[\"/path/to/pony/module\"]` | Additional module search paths |\n\nExample settings in JSON:\n\n```json\n{\n  \"defines\": [\"FOO\", \"BAR\"],\n  \"ponypath\": [\"/path/to/pony/module\", \"/another/path\"]\n}\n```\n\n## Building from source\n\nFirst, follow the [ponyc build documentation] and make sure you have everything to build `ponyc`. Then building `pony-lsp` is as easy as:\n\n```bash\nmake pony-lsp\n```\n\n[ponyc build documentation]: ../../BUILD.md\n\n## Reporting Issues\n\nIf `pony-lsp` is not working as expected, please [open an issue]. Include a minimal Pony program that reproduces the problem, the `pony-lsp` version, and the LSP client/editor you are using.\n\n[open an issue]: https://github.com/ponylang/ponyc/issues/new\n</yakaai_skills>"}