# Contributing to the print-bench platform The contributor guide for the system underneath the designs — the scripts, gates, CI, shared libraries, tools, product site, and autonomy routines. If you want to *design a printable part*, this is not your guide: the co-design workflow lives in [CLAUDE.md](../../CLAUDE.md), and a dedicated creator guide is planned. ## The map Read [Getting started](getting-started.md) first; after that the pages stand alone. | Page | What it covers | |---|---| | [Getting started](getting-started.md) | The toolchain, the environment contract (`OPENSCADPATH`, `OPENSCAD_BIN`, headless rendering), and your first green check run | | [CI and the gates](ci-and-gates.md) | What CI runs, how it decides what to run for your PR, what each gate proves, and the regen job that commits derived artifacts onto your branch | | [Conventions](conventions.md) | The standing rules the checks enforce — derived artifacts are CI's job, docs are drift-checked, every check proves it can fail, never trust an OpenSCAD exit code, the license boundary, and more | | [Extending the platform](extending.md) | Recipes for each kind of change: a new script, library, tool, smart-CI gate, scheduled routine, skill, top-level directory, or site change | | [The autonomy loop](autonomy.md) | The agent routines that triage, ship, chunk, propose and report — and the safety architecture that keeps each one advisory, narrowly scoped, or human-gated behind a draft PR | | [Pull requests](pull-requests.md) | The lifecycle of a PR here: what CI does to your branch, the review personas, the decision gate, and how forks differ | ## How these docs work Three rules keep this guide trustworthy, and they bind you when you edit it: - **Route, don't duplicate.** Anything already documented authoritatively once — the script inventory in [CLAUDE.md](../../CLAUDE.md), the architecture deep-dives in [docs/architecture/](../architecture/README.md), a tool's own README — is *linked* from here, not restated. A duplicated fact is a future lie: this repo's docs-drift check ([`scripts/docs-check.sh`](../../scripts/docs-check.sh)) mechanically verifies enumerations against the tree, and it only covers the enumerations that live in the files it checks. - **Docs are served, not just stored.** The product site serves these pages verbatim as raw markdown under `/docs/contributing/`, and indexes them (with every other markdown source it serves) at `/llms.txt`, following the [llms.txt](https://llmstxt.org/) convention — so an LLM agent can consume the whole guide without scraping HTML. `/llms-full.txt` is this guide plus the architecture docs in a single fetch. The index is derived from the docs' own titles and first paragraphs by [`site/lib/llms.mjs`](../../site/lib/llms.mjs), never hand-maintained. - **Broken links fail the build.** Because these pages are served, their local references are checked against the repo tree by the site build (`npm --prefix site test` runs the census; `./scripts/site.sh` runs both). Served verbatim means never rewritten: a repo-relative link here resolves in a checkout or on GitHub, not necessarily on the site host — that trade is deliberate and documented in [site/README.md](../../site/README.md). Open every new page with a prose paragraph whose **first sentence** works standalone — that sentence becomes the page's annotation in `/llms.txt`, and the census requires this index to link every sibling page. ## The platform in one paragraph print-bench is a co-design bench: humans bring design ideas, AI sessions model them in OpenSCAD, and a gauntlet of deterministic gates — full CGAL renders, mesh printability scoring, a real slicer pass, product-page and docs-drift checks — decides what ships. Around that core sits an automation layer that regenerates every derived artifact in CI (previews, galleries, product shots), a static product site generated from the committed tree, and a set of scheduled agent routines that triage the backlog, ship small issues, and report on the system's health — each one advisory-only, boxed in by narrow tool surfaces and deny backstops, or (for the ship routines) landing nothing a human hasn't reviewed, because their only output is a draft PR. The platform charter ([PM.md](../../PM.md)) names the non-negotiables; contributions that fight them will bounce in review. --- # The autonomy loop The scheduled agent routines that keep the backlog moving without a human driving each step — what each one does, and the safety architecture that makes an unattended agent with a repo secret an acceptable thing to run. Work on this layer is platform work of the highest care tier: read this page *and* the linked design docs before touching it. ## The loop's shape Issues are the loop's working substrate, routed by labels: - **`/label-issues`** (the labeler) is the front door: it triages issues carrying no routing label and applies the one that routes each — chiefly `autonomy-ok` (single-PR-sized, non-design, unambiguous), or `declined-too-big`, `design-brief`, `needs-decision`. Add-only and conservative by construction. - **The backlog burn** picks at most one `autonomy-ok` issue per firing — oldest first, skipping anything claimed or parked ([`tools/backlog-burn/README.md`](../../tools/backlog-burn/README.md) is the tested selection policy) — and runs `/ship-issue` on it, leaving a draft PR for human review. - **`/ship-issue`** declines an oversized issue with `declined-too-big`; **the chunker** picks those up and files dependency-ordered sub-issues, auto-arming the genuinely small ones — so the burn always has shippable work and the human gate moves up to curating what is marked oversized. - **The design run** is the same pattern pointed at `design-brief` issues: the whole co-design loop, converging on gates, never on taste — approving a *shape* stays the human's merge decision. - **The product scout** originates: it reads committed signals (telemetry, field-test logs, the catalog) and files well-formed `design-brief` proposals. Advisory by construction — it never arms, approves, or merges. - **The agent forge** ([docs/agent-forge.md](../agent-forge.md)) is the scout's tooling sibling with a judged second half: **Wright** (`/wright`) reads the bench's own pulse and files `agent-brief` proposals for new agents/tools; **Reeve's sign-off** (`/reeve-signoff`) rules on each — an approve arms it (`autonomy-ok` + `points-n`, so the burn builds it as a draft PR) behind a deterministic sensitive-path guard, a decline labels `wright-declined`, a needs-human parks it at the decision gate. The one routine whose write is escalation-shaped; shipped disarmed. - **The groomer** and **Reeve** report: each upserts exactly one sticky report issue (backlog health; the platform's ops pulse) from deterministic detectors — no LLM in any finding. - **The Oracle** posts one advisory cross-vendor review on every autonomy PR (`claude/*` head branches), run on the vendor the shipper did *not* use, blind to the shipper's session. - **The decision gate** ([docs/decision-gate.md](../decision-gate.md)) is the loop's brake: `needs-decision` durably parks an issue out of every routine until a maintainer answers with `/decide yes|no `; the label is the unspoofable verdict and a committed ledger is the audit trail. ## The safety architecture Every routine sits inside the same defense stack. These are the invariants to preserve — and the checklist a reviewer will hold a new routine to (the build recipe is in [Extending the platform](extending.md)): - **Two-key arming (the scheduled routines).** A scheduled routine acts only when its committed `.github/.conf` says `enabled: true` *and* its repo variable (`_ENABLED` — one per scheduled routine; the checked roster is the `ROUTINES` array in [`scripts/cadence-sync-check.sh`](../../scripts/cadence-sync-check.sh)) is `true`. The variable lives deliberately outside git: a fork can never inherit an armed agent, and flipping it off halts the routine in seconds. Some routines ship with the conf enabled and the variable unset — that is the intended disarmed state, not an inconsistency. The Oracle is the exception because it is event-driven, not scheduled: it fires on autonomy-PR events with no conf and no arming variable, and its off-switch is per-PR — the `no-oracle-review` label. - **The AI andon cord.** Above every arming variable sits one repo-wide control, `AI_ANDON_CORD` ([docs/andon-cord.md](../andon-cord.md)): set to the word `pulled` (case-insensitive; unset means released) it bypasses *every* AI-consuming workflow job — the scheduled routines, the groomer's narrative step, the reviewers and the Oracle, CI's product-page drafting, the AI image generators, the model smoke — grey/skipped, never red, no provider call, no `needs-decision` escalation, one `::notice::` per run, while every deterministic job keeps running. It overrides an armed routine without touching its variable or conf, so releasing it restores exactly the arming that was there. An hourly keyless reconciler (`andon.yml`, `tools/andon`) opens one `andon-cord` status issue while pulled and closes it with the timespan on release, and Reeve's report carries a 🛑 banner. The one fail-closed consequence: the `reviewer-signoff` status blocks a design PR lacking two clean current sign-offs until release (or `signoff-override`). Enforced by the model-registry drift guard, which requires the gate leg on every job that can touch a provider. - **Graded write scopes.** Permissions follow what each routine actually writes: the burn and design run push branches and open PRs; the chunker, labeler, scout, assessor and the forge's two halves write issues only (Wright files `agent-brief`s; the sign-off comments verdicts and applies them from a closed label taxonomy); the groomer and Reeve write one sticky report issue; the Oracle posts one comment. Nothing gets `contents: write` it doesn't need. - **Narrow surfaces.** The routines that read untrusted issue text while holding a provider key each get the narrowest surface that does their job, and the shapes differ by need — the labeler, scout, assessor and Oracle run `--permission-mode dontAsk` over an explicit allowlist; the chunker runs a narrow allowlist without it. Reads and simple writes go through a per-routine shell wrapper that enforces authorization itself (the labeler's wrapper refuses any issue outside the workflow-selected set; the chunker's files its sub-issues), while rich multi-line writes are per-routine MCP tools that hardcode their own guardrails — labels, markers, target pinning, per-run caps (the scout, assessor and Oracle). The ship routines that need the full toolchain run `bypassPermissions` instead — their boxing is the draft-PR human gate, not the tool surface. These postures are deliberate and not interchangeable; each workflow documents its own. - **Deny backstops.** Because settings allows merge additively, each of the narrow-surface routines (chunker, labeler, scout, Oracle, assessor, and the forge's two halves) passes its own `.claude/*-settings.json` denying the inherited Bash allows and the sibling write surfaces its perms-check enumerates — guarded by a dedicated `scripts/-perms-check.sh` in `check.sh`, which also asserts the routine's *own* surface is never denied (or the scheduled run fails closed with no other signal). The forge's pair goes one further: each half's backstop denies the *other half's* MCP server (`scripts/wright-perms-check.sh` pins both directions), so the proposer can never judge and the judge can never file. The groomer and Reeve need none: they run no agent at all. - **Default-branch pinning.** A secret-bearing scheduled workflow gates its job on the default branch *and* checks out the default branch with `persist-credentials: false` — a `workflow_dispatch` against a PR branch must never run unreviewed code with a secret. The Oracle goes further: it checks out the base branch and receives the PR strictly as data. - **Liveness without livelock.** Ship runs post a `🚢 SHIP-LOCK` claim; [`scripts/routine-lock-cleanup.sh`](../../scripts/routine-lock-cleanup.sh) withdraws a dead run's lock (step timeouts sit below the job timeout precisely so the cleanup stays reachable) and escalates to `needs-decision` after repeated deaths; a final workflow step turns a dead run red so starvation can't disguise itself as health. - **Models from the registry.** No workflow pins a model id: each consuming workflow resolves its chain — the stanza ids live in [`.github/models/registry.conf`](../../.github/models/registry.conf), which is the source of truth for chain names and contents alike (prose summaries elsewhere have drifted before — cite the file). The multi-link chains are walked link by link, first success winning; the deliberately single-link chains (the groomer narrative) are consumed as exactly one link, so deepening them is a workflow change too, not just a registry edit. Every scheduled routine walks its chain across providers since #544 — a GLM head (three GLM links for the four high-volume routines and Reeve's sign-off, one for the rest), then an Anthropic tail (claude-sonnet-5 → claude-haiku-4-5) — as literal ship steps in file order, each gated on every earlier link not succeeding and on its own provider's key; the conf's `provider:` names the HEAD provider (link 1 must sit on it) and the resolve step's `model-registry shape --head --layout ` refuses a chain that does not fit slot for slot; every tail leads with Sonnet rather than Opus, the review-tier routines included, because the Anthropic account carries a monthly usage cap that an unattended fall-through must not burn; `model-smoke.yml` proves a chain's ids are actually servable, which no static check can. - **GitHub mention hygiene for agent names.** Never `@`-mention (or write bare colliding display names in issue/PR comments for) an agent whose display name matches a real GitHub login. Deny-list starts with **`wright`** — that login is an unrelated human, not our toolwright. On GitHub say `toolwright`, `/wright`, "platform SA burn-down", or "eng prioritizer"; route eng SA work privately rather than naming the colliding display name on-PR. Full rule and incident: [docs/agent-forge.md](../agent-forge.md#github-mention-hygiene-agent-display-names) and issue #822. ## Operating it Maintainer controls are GitHub-native and authorized by real repository permission: the arming variables, the repo-wide `AI_ANDON_CORD` andon cord ([docs/andon-cord.md](../andon-cord.md)), `/backlog-burn set `, `/decide`, `/ci-gate`, and each workflow's manual dispatch (with `dry_run` where it applies). The README's ["Operating the automation"](../../README.md) section is the operator summary; [docs/actions-security.md](../actions-security.md) records the accepted security posture around write-scoped dispatchable workflows; and the per-routine deep dives live in [tools/backlog-burn/](../../tools/backlog-burn/README.md), [tools/backlog-groomer/](../../tools/backlog-groomer/README.md), [tools/reeve/](../../tools/reeve/README.md), [tools/model-registry/](../../tools/model-registry/README.md) and the routine bullets in [CLAUDE.md](../../CLAUDE.md). --- # CI and the gates How CI decides what to run for your PR, what each gate proves, and the one behavior that surprises newcomers most: CI pushes commits onto your branch. ## One classifier decides what runs CI's gate selection lives in exactly one script: [`scripts/ci-classify.sh`](../../scripts/ci-classify.sh). The workflow's `changes` job pipes your PR's changed-file list into it; `/preflight` runs the same script with `--local` on your working tree. That is why the local "would CI pass?" answer cannot drift from what CI actually does — and why, when you want to know what your PR will run, you can just ask: ```bash ./scripts/ci-classify.sh --local ``` The script's header documents its outputs, and its case arms (pinned by its own `--selftest`) are the authoritative tier membership — the shapes below are the map, with examples, not the inventory: - **Docs-only PRs** (`docs/` and nothing else) select exactly one job: `docs-standards` — a light presence/wiring gate over the architecture docs and the site's How-it-works page ([`scripts/docs-standards-check.sh`](../../scripts/docs-standards-check.sh)). No render toolchain spins up. A PR touching only `CLAUDE.md` or `README.md` selects *nothing* — those paths match no classifier case. Either way the always-on jobs (lint, regen, design-docs, site-build, smart-ci) still appear on the PR's checks tab; they just do no heavy work. - **Design PRs** gate the designs they touch, plus the *blast radius*: derivative descendants (via `./scripts/lineage.sh blast-radius`) and, for a style edit, every design declaring that style. Archived designs are dropped unless the PR touched their own files. - **Soft infra** — paths that can't move geometry but must stay honest (most of `scripts/`, `site/`, the non-geometry `tools/` trees the classifier lists, workflow files other than `ci.yml`…) — makes the required contexts *run* but gate an empty design list. That is deliberate: a check reported as "skipped" does not satisfy branch protection (the PR #50 lesson recorded at the top of [`ci.yml`](../../.github/workflows/ci.yml)), so required jobs run as fast formalities instead of skipping. - **Geo infra** — anything that can move an exported STL or the gate's own scope (`lib/`, `gate.sh`, `lineage.sh`, `ci-classify.sh` itself, `tools/printcheck`, `tools/lineage`, `ci.yml`, `.github/actions/`…) — gates the **entire design catalog**. Expect a slow CI run; that is the point. The consequence for you: **a new top-level directory must be taught to the classifier**, or PRs touching only it either strand or merge untested, depending on the branch-protection regime — the recipe (and both failure modes) is in [Extending the platform](extending.md#a-new-top-level-directory). ## What the gates prove Each gate exists because a cheaper signal was proven insufficient. In rough pipeline order: | Gate | Proves | Where | |---|---|---| | `check.sh` (stable + nightly jobs) | every `.scad` evaluates clean, shared-library demos still render, every guard still refuses, every declared fit still assembles, docs match the tree | [`scripts/check.sh`](../../scripts/check.sh) | | `gate.sh --slice` | each printable part renders, scores through [printcheck](../../tools/printcheck/README.md), survives a real PrusaSlicer test-slice, and every derivative's claimed override actually changed the mesh | [`scripts/gate.sh`](../../scripts/gate.sh) | | `readme-gate.sh` | every design ships a complete product page (the full requirement list is in the script header) | [`scripts/readme-gate.sh`](../../scripts/readme-gate.sh) | | `style-check.sh` | style packs are internally consistent and styled designs obey their pack's rules | [`scripts/style-check.sh`](../../scripts/style-check.sh) | | `site-build` | the site's unit tests pass and the same build Vercel runs resolves every local reference | [`scripts/site.sh`](../../scripts/site.sh) | | `lint` (always on) | shellcheck, actionlint, and a Python-3.10 syntax parse of every first-party `.py` — the safety net for paths no classifier case selects | [`ci.yml`](../../.github/workflows/ci.yml) | | `geo-diff` (advisory) | a geometric STL diff of merge-base vs head whenever designs are gated, posted as a sticky comment; job-level `continue-on-error`, so it can never block a merge | [`ci.yml`](../../.github/workflows/ci.yml) | Everything funnels into **`ci-ok`**, the summary context branch protection is *designed* to require — whether it actually does is a live GitHub setting no in-repo check can verify, and [`scripts/ci-ok-guard.sh`](../../scripts/ci-ok-guard.sh)'s header says exactly that; `ci.yml`'s own CAUTION still names five required job contexts, which is why the "run but gate nothing" formality modes exist and why job `name:` strings must stay stable. `ci-ok`'s `needs:` list is hand-maintained, so a new gating job must be added to it in the same PR that creates the job — the guard (run by `check.sh`) fails on the omission. In `ci-ok`, a *skipped* job counts as passing by design: a skip is the classifier's verdict that the job had nothing to judge. ## CI commits to your branch Anything **derived** from a design — preview PNGs, animation GIFs, product shots, the README gallery, drafted product pages — is CI's job to generate, not yours. The `regen` job re-runs the generators for every design your PR touches — blast radius included, and the whole non-archived catalog when `lib/` or a generator script moves — and **commits the result back onto your PR branch**. Don't commit generated artifacts by hand, and don't be surprised by the bot commit; the presence-only gates on those artifacts are safe precisely because the same run that gates the source regenerates the images beside it. Three mechanics worth knowing (the full rationale is in [CLAUDE.md](../../CLAUDE.md) and [docs/architecture/ci-platform.md](../architecture/ci-platform.md)): - The commit-back is pushed with the `REGEN_TOKEN` PAT, not `GITHUB_TOKEN` — a `GITHUB_TOKEN` push triggers no workflow, which would strand the PR head on a commit carrying no required checks. The PAT push re-triggers CI, so **a regenerating PR runs CI twice**; the second run is the verification pass, bounded by a loop guard that recognizes regen's own commit and refuses to push again. - A design is skipped when its input fingerprint ([`scripts/regen-stamp.sh`](../../scripts/regen-stamp.sh)) matches the committed `.regen-stamp` — re-rendering a Cycles shot to find it byte-identical costs minutes. - **On a fork PR the contract inverts** — see [Pull requests](pull-requests.md), the home of the fork differences. ## Smart CI: gates that don't exist yet On top of the deterministic classifier sits [`tools/ci-gates`](../../tools/ci-gates/README.md): detectors propose checks that would apply to your PR (a shell script with no shellcheck gate, a directory the classifier doesn't cover), and the committed registry [`.github/ci-gates/registry.conf`](../../.github/ci-gates/registry.conf) records each gate's decision and tier. **Advisory** gates run automatically and only warn; **gating** gates stay proposals in a sticky PR comment until a maintainer crosses one with a PR comment — `/ci-gate approve ` (also `decline`, `list`). The registry, not any prose summary, is the source of truth for what is currently enforced — gates get crossed over time, so check the file, and treat the sticky comment on your PR as live state. ## Before you push Run `/preflight` (or its command set by hand — [`.claude/skills/preflight/SKILL.md`](../../.claude/skills/preflight/SKILL.md)). It classifies your working tree exactly as CI will, runs the same checks in fastest-failure-first order, and tells you which local failures are expected because CI's regen job fixes them — and which are real. One validated push beats three speculative ones. --- # Conventions The standing rules platform code is held to — each one enforced by a check, each check born from a real failure. Knowing the rule is optional; the checks make sure of that. Knowing *why* saves you a red CI round. ## Derived artifacts are CI's job Never commit generated output. The `regen` job regenerates a design's derived artifacts (previews, GIFs, product shots, exploded views, the gallery, drafted product pages) and commits them in the same run that gates the source, which is what keeps presence-only gates honest (a committed image cannot be older than the source beside it). Two derived files follow different pipelines but the same rule: `telemetry/` is appended by the `render-gate` job on default-branch runs (never hand-edit it), and release bundles are built by `release.yml` and published as GitHub Release assets, never committed at all. Run the generators locally only to *look* at the output. The single exception is a PR from a fork, where CI cannot push — see [Pull requests](pull-requests.md). ## Docs are drift-checked [`scripts/docs-check.sh`](../../scripts/docs-check.sh) mechanically asserts the docs match the tree — its numbered checks are the full list; these are the ones an ordinary change hits most: - A new file in `scripts/` must be named in **CLAUDE.md's single "`scripts/`" layout bullet** (a mention anywhere else in CLAUDE.md does not count — a script once landed named only in the Commands block while the layout enumeration silently went stale) *and* in README.md. - A new first-party `lib/.scad` must be named in CLAUDE.md, backticked in README.md's `## Layout` section, and must ship a `lib/-demo.scad`. - A new skill under `.claude/skills/` must be mentioned in CLAUDE.md as `/` — and every `/` CLAUDE.md mentions must exist. - A new top-level directory (and each `tools//`) must be mentioned in both CLAUDE.md and README.md. - Every style pack must be complete and listed in `styles/README.md`. The scope discipline stated in that script's header binds new checks too: every docs check is a **mechanical fact** (a file exists, a name is mentioned), never a prose judgment. The corollary for doc writers: link the one authoritative enumeration instead of restating it — a second copy is a drift surface no check covers. ## Every check proves it can fail A check that cannot fail checks nothing, so the house pattern is a selftest or negative control beside every gate: - Gate scripts ship a `--selftest` that plants each failure and requires the gate to catch it. Note the wiring gotcha: `check.sh` invokes each selftest **explicitly by name**, so a new script's selftest does nothing until `check.sh` is edited to call it. (Library demos and the `*-guards.conf`/`*-mates.conf` cases, by contrast, are discovered by glob.) - A library whose modules carry asserts ships `lib/-guards.conf` — cases the library must *refuse* ([`scripts/guard-check.sh`](../../scripts/guard-check.sh)). A library with a tuned fit ships `lib/-mates.conf` — fits that must *assemble*, plus at least one deliberately-interfering case proving the check can fail ([`scripts/mate-check.sh`](../../scripts/mate-check.sh)). - Tool test suites carry a negative control per guard, and workflow-consuming tools carry drift guards that pin the tool and its consumer workflows together (the model registry's `test_workflow_drift.py` is the model). ## Never trust an OpenSCAD exit code OpenSCAD 2021.01 exits 0 on a failed assert under echo and CSG export, treats an unresolvable `use`/`include` as a warning-then-success, and hands you a watertight, 100/100-scoring STL with the features missing. Every check here therefore greps message text or measures the exported mesh — and a new check built on OpenSCAD's exit status is broken by construction. Related trap: byte-hashing an exported STL *seems* to work on small models and silently stops working on real ones (facet order is not stable between renders); use the canonicalising `mesh-hash` from [`tools/lineage`](../../tools/lineage/README.md) and reuse `lineage.sh`'s sourced helpers for the empty-vs-broken distinction. ## Shell and workflow floors - **Bash 3.2** (stock macOS) is the floor for locally-run scripts: no associative arrays, and guard empty-array `"${arr[@]}"` expansions under `set -u`. - The **committed** executable bit is what counts: a `scripts/` file with a shebang must be committed mode 100755 (`git update-index --chmod=+x`, not a local `chmod`), and a sourced helper without a shebang stays 100644. - A hardcoded `openscad` binary works everywhere except the one CI job that installs only the nightly — which is exactly where it fails — so route every invocation through `"$OPENSCAD_BIN"` (docs-check enforces it). - The default Actions shell has no `pipefail`: any `cmd | tee log` in a workflow step masks the failure unless the step sets `set -o pipefail` first. - Apt packages cached via `cache-apt-pkgs-action` must be verified postinst-free (`dpkg -I`) and added to docs-check's allowlist in the same PR — the cache restore skips postinst, so alternatives-managed binaries come back dangling one run *later*. - `gate.sh`'s output line shapes (`time : gated in s`, the derivative-check lines) are machine-parsed by `gate-summary.py` and `telemetry.sh` — changing an output format silently breaks the PR comment or telemetry capture. ## The license boundary The first-party tree is CC-BY-SA-4.0; vendored code keeps its own terms — `lib/BOSL2/` (BSD-2-Clause) and `lib/NopSCADlib/` (GPL-3.0) are never edited. The standing decision ([docs/licensing.md](../licensing.md), enforced by [`scripts/license-boundary-check.sh`](../../scripts/license-boundary-check.sh)): **copyleft never reaches shared first-party core** — no shared `lib/*.scad`, `scripts/` or `site/` code may combine with or bundle a copyleft library — while an individual *design* may opt in and must then disclose GPL-3.0 on its product page. Core may freely *invoke* GPL tools it doesn't ship (OpenSCAD, PrusaSlicer, Blender). Don't "fix" the check's deliberate scope (designs are unscanned on purpose), and vendoring any new copyleft library means extending `COPYLEFT_ROOTS`, `LICENSE` and the policy doc in one PR. ## Source of truth lives in git Policy that automation reads is a committed file — the smart-CI registry, each routine's `.github/.conf`, the model registry — never a GitHub setting alone. GitHub supplies interaction and authorization (comment commands checked against real repository permission); git supplies the reproducible decision. Live arming switches (the `*_ENABLED` repo variables) are the deliberate exception: kept *out* of git so a clone or fork can never inherit an armed agent. When you add configuration, follow that split rather than inventing a third place for state. ## Review history is preserved `audits/` holds before/after render comparisons from review rounds, and designs may carry closed work-phase docs beside `NOTES.md`. Treat both as history: keep them current or mark them closed, never delete them as scratch. The same instinct applies platform-wide — a decision worth making is worth recording where the next contributor will look (a script header, a doc, PM.md's decision log). --- # Extending the platform Recipes for each kind of platform change, each step tied to the check that enforces it where one exists. Where a check exists the linked file is the enforcement — and when a recipe and a check disagree, the check wins and this page has drifted. The remaining steps (a script's `--selftest` and its `check.sh` wiring, the Bash 3.2 floor, a tool's README and negative-control suite, the site house rules) are conventions review holds you to — no check will catch skipping them, which is exactly why they're listed. ## A new script in `scripts/` 1. Ship it with a shebang and the **committed** executable bit (`git update-index --chmod=+x`); a sourced helper gets neither. 2. Route any OpenSCAD invocation through `"$OPENSCAD_BIN"`, never a literal binary. 3. Name it in CLAUDE.md's "`scripts/`" layout bullet **and** in README.md — [`scripts/docs-check.sh`](../../scripts/docs-check.sh) fails otherwise. 4. Give it a `--selftest` proving it can fail, and wire that selftest into [`scripts/check.sh`](../../scripts/check.sh) by name — selftests are invoked explicitly, not discovered. 5. Keep it Bash 3.2-clean and shellcheck-clean (the always-on `lint` job runs shellcheck over `scripts/*.sh`). ## A new shared library in `lib/` 1. `lib/.scad`, named in CLAUDE.md and in README.md's `## Layout` section (backticked). 2. `lib/-demo.scad` exercising every module — `check.sh` CGAL-renders it automatically from the day it lands; this is the library's regression test. 3. If any module carries guards: `lib/-guards.conf`, one line per parameter set the library must **refuse** ([`scripts/guard-check.sh`](../../scripts/guard-check.sh) documents the format and why it matches messages, not exit codes). 4. If the library carries a tuned fit: `lib/-mates.conf`, one line per pair that must **assemble** plus at least one deliberately-interfering negative control ([`scripts/mate-check.sh`](../../scripts/mate-check.sh) documents the format and the zero-facets measurement). 5. Never `include` a copyleft-vendored library from shared `lib/` — [`scripts/license-boundary-check.sh`](../../scripts/license-boundary-check.sh) fails it. Changing `lib/` is geo-infra: CI re-gates the entire design catalog. Budget for the slow run. ## A new tool under `tools/` Follow the shape of [`tools/lineage`](../../tools/lineage/README.md) or [`tools/ci-gates`](../../tools/ci-gates/README.md): 1. **Stdlib-only at runtime** if CI runs it before installing anything (the classifier, smart-CI, telemetry capture and the scheduled routines all run on system Python); the `[test]` extra pulls pytest and nothing else. The tools CI must run from a bare checkout also carry a `tests/conftest.py` path shim so `python -m pytest tools//tests` works uninstalled; the `-tests` CI jobs pip-install each tool first either way. 2. A README of its own, and a pytest suite with a negative control per guard. 3. Wiring, all in one PR: a `tools//` mention in CLAUDE.md and README.md (docs-check's census covers `tools/` one level deep); a new classifier output plus case arms in [`scripts/ci-classify.sh`](../../scripts/ci-classify.sh) (a tools-only PR must still *run* the required contexts — soft infra); a `-tests` job in [`ci.yml`](../../.github/workflows/ci.yml); and that job added to `ci-ok`'s `needs:` list ([`scripts/ci-ok-guard.sh`](../../scripts/ci-ok-guard.sh) fails `check.sh` if you forget). ## A new smart-CI gate Three parts, kept separate on purpose ([`tools/ci-gates/README.md`](../../tools/ci-gates/README.md)): 1. A stanza in [`.github/ci-gates/registry.conf`](../../.github/ci-gates/registry.conf) declaring `tier`, `title`, `run`, optionally `setup` — and, required for a gating gate, `cross` (the approval command shown in the proposal comment; the parser refuses a gating stanza without one). A **gating** gate auto-lands as `proposed` and cannot fail a PR until a maintainer crosses it with `/ci-gate approve ` — a new blocking check never lands unannounced. An **advisory** gate defaults to on and only warns. 2. Optionally, a detector in `tools/ci-gates/src/ci_gates/detectors.py` — a pure function of the changed-file list and tree that narrows when the gate applies. A gate with no detector applies to every PR. 3. Tests in the tool's suite (the selection is pure and testable; a malformed stanza must fail). ## A new scheduled agentic routine The heaviest recipe, because an unattended agent with a secret is the platform's most dangerous surface. Copy an existing sibling wholesale — [`.github/labeler.conf`](../../.github/labeler.conf) is the canonical annotated conf, and the workflows share one skeleton — then check off: 1. **Two-key arming**: a committed `.github/.conf` (`enabled`, `provider`, `cadence` in the house format) *and* a repo Actions variable (`_ENABLED`) that ships unset, so no clone or fork inherits an armed agent. 2. **Cadence in two places** (Actions cannot read a file for `on.schedule`): the conf key and the workflow `cron:` literal — and add the routine to the `ROUTINES` array in [`scripts/cadence-sync-check.sh`](../../scripts/cadence-sync-check.sh), or the parity between them is simply never checked. 3. **Model from the registry, never pinned**: a `[chain:]` in [`.github/models/registry.conf`](../../.github/models/registry.conf), resolved by the workflow at run time, one ship step per link, every outcome expression covering every link. Extend `tools/model-registry`'s drift-guard tests to pin your workflow, and dispatch `model-smoke.yml` after editing a chain — static checks cannot prove a model id is servable by a key. 4. **A write surface split by direction**: reads on a narrow shell wrapper with trivial arguments; any rich multi-line write as an MCP tool (under `--permission-mode dontAsk`, the Bash matcher denies arguments that read as shell structure — a markdown table pipe is enough, and no quoting fixes it). The wrapper enforces authorization itself, not just the prompt. 5. **A deny backstop plus its guard**: a `.claude/-settings.json` denying every dev Bash allow and every sibling routine's write surface (allow rules merge additively across settings sources, so `--allowedTools` alone is never exclusive), never denying the routine's own surface — and a `scripts/-perms-check.sh` with a selftest, wired into `check.sh`. 6. **Workflow hygiene from the siblings**: minimal graded permissions; job gated on the default branch and checkout pinned to it with `persist-credentials: false`; a concurrency group with `cancel-in-progress: false`; a `::notice::`-and-stop path when the provider secret is absent; the action pinned by commit SHA. If the routine takes a `🚢 SHIP-LOCK` (today the burn and the design run), add the lock lifecycle too: step timeouts strictly below the job timeout so the cleanup ([`scripts/routine-lock-cleanup.sh`](../../scripts/routine-lock-cleanup.sh)) stays reachable, plus a final step that turns a dead run red. Lock-free routines need only the job timeout — their terminal ship step failing is the red signal. Then update CLAUDE.md (the routine's bullet and skill mention), README.md's "Operating the automation" list, and the platform charter if the routine changes what the system *is*. The full architecture of the loop is in [The autonomy loop](autonomy.md). ## A new skill Create `.claude/skills//SKILL.md` and mention `/` in CLAUDE.md (docs-check verifies both directions). For a reviewer persona specifically, [docs/reviewer-personas.md](../reviewer-personas.md) is the pattern and registration checklist — one lens, one reference pack, PM-gated tags, never a merge verdict. ## A new top-level directory Mention it in CLAUDE.md and README.md (docs-check), and give it a case arm in [`scripts/ci-classify.sh`](../../scripts/ci-classify.sh) — that is what makes PRs against it verifiable. Without one, a PR touching only the new directory either strands (under branch protection that names the required jobs, required contexts never run) or merges green having tested nothing (under a ci-ok-only protection, where skipped counts as passing) — both wrong. The advisory `classifier-coverage` gate flags an uncovered directory, but note its criterion is a literal `/` token in `ci.yml`'s text (it predates the classifier's extraction into the script), so the warning can outlive the fix until `ci.yml` mentions the directory. ## A site change Read [site/README.md](../../site/README.md) first — it states the three scoped guarantees new code must respect (deterministic offline build; deploy-time fetches only through best-effort, flag-gated seams like `lib/releases.mjs` that never fail the build and report a failed fetch as a visible `unavailable` state through `lib/seam.mjs`, never as empty data; served bytes reference nothing external — vendored assets only, no CDN). House rules that follow: new `site/lib/` modules get listed in site/README's Layout and tested in `site/test/` (`node --test`, fixture repos in a tmpdir, a negative control per rule); anything a page links must resolve on disk or the build fails; and remember Vercel runs only install+build — the tests run in `./scripts/site.sh` and CI's `site-build` job, so never skip those locally. If your change adds or removes site *inputs*, update [`scripts/vercel-ignore-build.sh`](../../scripts/vercel-ignore-build.sh)'s classification and selftest to match. --- # Getting started How to stand up the toolchain, understand the environment contract every script assumes, and get your first green check run — the on-ramp for platform contributions. ## Install the toolchain The repo installs its own tools. One script owns the recipe: ```bash .claude/hooks/session-start.sh --force ``` That is the SessionStart hook, and its self-installing path is narrower than it looks: it installs automatically only in Claude Code **on the web** (`CLAUDE_CODE_REMOTE=true`); everywhere else — including a local Claude Code session — it is a deliberate silent no-op without `--force`. It is idempotent — re-run it any time a tool goes missing — and installs the render/slice/preview toolchain via apt plus editable installs of [`tools/printcheck`](../../tools/printcheck/README.md) and [`tools/stylelift`](../../tools/stylelift/README.md) with pytest. The exact package list lives in one place, the script itself — its header comments are the authoritative install documentation: [`.claude/hooks/session-start.sh`](../../.claude/hooks/session-start.sh). Two tools are deliberately not in the default set: - **`bpy`** (Blender as a Python module, behind `product-shot.sh`) is a ~1 GB wheel you only need to preview product shots locally — CI renders and commits them. Install with `.claude/hooks/session-start.sh --force --with-bpy` (both flags; the hook documents the Python-3.11 and `python3 -m pip` caveats). - **`sca2d`** (behind the advisory `lint-scad.sh`) — `pip install sca2d` yourself if you want the static-analysis report. You also need `python3` (3.10+) — several tools run straight out of `tools/*/src` with no install step — and Node.js for the site build (`./scripts/site.sh` below; skip that command if you won't touch the site). ## The environment contract Every script in `scripts/` assumes three things; if you invoke `openscad` by hand you must supply them yourself: - **Headless rendering.** There is no display here; every OpenSCAD invocation goes through `xvfb-run -a`. A bare `openscad` fails. - **`OPENSCADPATH="$PWD/lib:$PWD"`** — `lib/` resolves library includes, the repo root resolves `include /style.scad>`. This one is a silent-failure trap: without it, an unresolved `use ` produces only a WARNING, exit 0, and a watertight, sliceable STL *with the features missing*. Nothing about the exit code tells you the geometry is wrong. - **`OPENSCAD_BIN` / `OPENSCAD_ARGS`** — the binary and extra flags are variables, never hardcoded (CI's fast render path runs `openscad-nightly` with `--backend=manifold`; the stable 2021.01 job keeps designs compatible with the release build). The rule and its enforcement are in [Conventions](conventions.md). The general lesson behind all three: **never trust an OpenSCAD exit code**. The repo's checks grep output text or measure the exported mesh instead — see [Conventions](conventions.md). ## Your first green run ```bash ./scripts/check.sh # the fast pre-commit check set ./scripts/gate.sh --slice # what CI enforces on designs: printcheck + a real test-slice ./scripts/site.sh # site unit tests + the same build Vercel runs (needs Node) ``` `check.sh` is much more than a syntax pass: it echo-checks every non-archived `.scad` (failing on wrong-geometry warnings OpenSCAD emits while exiting 0; a design frozen by an `ARCHIVED` marker is skipped), CGAL-renders every shared library's demo, runs the library guard and mate checks, the license boundary, the docs-drift check, and the selftest of nearly every gate script — the script's own body is the authoritative and still-growing list. If `check.sh` is green, the standing conventions are intact. Two local failures are *expected* and CI fixes them for you (except on a fork — see [Pull requests](pull-requests.md)): a stale README gallery, and a product page missing an image that CI's regen job renders and commits itself. The `/preflight` skill ([`.claude/skills/preflight/SKILL.md`](../../.claude/skills/preflight/SKILL.md)) is the canonical "would CI pass?" answer — it runs the exact checks CI runs, scoped the same way CI scopes them, and documents which local reds to ignore. ## Orient yourself - [docs/architecture/](../architecture/README.md) — the system's two-layer split (domain-agnostic CI platform, OpenSCAD design workflow) and the three seams joining them. Read `design-workflow.md` first (what the machine builds), then `ci-platform.md` (how it ships). - [CLAUDE.md](../../CLAUDE.md) — the working reference for sessions: the command block, the repository layout with every script and library enumerated (that enumeration is mechanically enforced — see [Conventions](conventions.md)), and the co-design workflow. - [PM.md](../../PM.md) — the platform charter: the non-negotiables (N1–N6), the "never" list, and the health invariants. Contributions that fight the charter bounce in review, so read it before proposing structural change. --- # Pull requests The lifecycle of a PR here — what CI will do to your branch, who reviews what, and where the process differs from an ordinary repo. ## Opening Open PRs as **drafts**. Branch names are free for humans; the `claude/issue--*` shape is a claim signal the autonomy selector reads, so don't use it for unrelated work. Before pushing, run `/preflight` (or its command set — see [CI and the gates](ci-and-gates.md)); a red gate is a stop, not a nuance. ## What happens to your branch Expect CI to be an active participant, not just a judge: - **The regen job may push a commit onto your branch** regenerating every derived artifact your change touches (previews and product shots through assembly views and drafted product pages — the regen job's header in [`ci.yml`](../../.github/workflows/ci.yml), and its fork failure message, carry the authoritative generator list), which re-triggers CI as a verification pass — so a regenerating PR runs twice by design. Don't fight the bot commit and don't commit derived artifacts yourself. - **Sticky comments** accumulate on the PR: the printcheck gate report, smart-CI's gate selection (with any proposed gates a maintainer can cross via `/ci-gate approve `), and — whenever designs are gated — the advisory geometric diff. - **Everything funnels into `ci-ok`**, the summary context branch protection is designed to require; today `ci.yml`'s own CAUTION still names five required job contexts, so treat every job `name:` string as load-bearing — renaming one can strand PRs, and rewiring a job id breaks `ci-ok`'s `needs:` list, which [`scripts/ci-ok-guard.sh`](../../scripts/ci-ok-guard.sh) catches in `check.sh`. Job selection per change class is the classifier's business ([CI and the gates](ci-and-gates.md)). ## Mentions on PR threads Agent display names can collide with real GitHub logins. **Never `@`-mention** an agent name on a PR or issue when that happens — GitHub will subscribe the unrelated human, and there is no API to unsubscribe them for you. Deny-list starts with **`wright`** (case-insensitive): use `toolwright`, `/wright`, "platform SA burn-down", or "eng prioritizer" instead, and keep eng SA routing off the public thread. See [docs/agent-forge.md](../agent-forge.md#github-mention-hygiene-agent-display-names) and issue #822. ## Reviews - **Design PRs** get the reviewer personas automatically ([docs/reviewer-personas.md](../reviewer-personas.md)): Jane (printability experience) and Drik (fitness-for-purpose) post tagged findings, and the design's PM triages every tagged finding — act-now, queue, or decline with a cited reason. The reviewers are feedback; the PM is the gate; CI holds the numbers. `/design-coach` can drive the rounds. - **Autonomy PRs** (`claude/*` heads) get one advisory Oracle review from the vendor that didn't ship the change — see [The autonomy loop](autonomy.md). - **Platform PRs** are reviewed by humans against the charter ([PM.md](../../PM.md)) and the conventions ([Conventions](conventions.md)). The likeliest review asks: where is the negative control, which check enforces this, and which authoritative doc did you update instead of duplicating. A PR that needs a human call an agent can't make gets parked with `needs-decision`; a maintainer answers with `/decide yes|no ` ([docs/decision-gate.md](../decision-gate.md)), and the label flip is what unblocks the work. ## PRs from forks Four things invert on a fork, all for the same reason — CI cannot push to your branch and repo variables/secrets don't follow the fork: 1. The **regen job fails instead of committing**, listing the files to regenerate — this is the one case where you run the generators locally and commit their output yourself (the failure message names the exact commands). 2. **`/ci-gate approve` can't commit to your branch** — the reply tells you to edit `.github/ci-gates/registry.conf` in the PR yourself. 3. **The sticky comments are not posted** (fork PR tokens are read-only) — the printcheck report, smart-CI selection and geo-diff results are still in each job's log and summary, just not on the thread. 4. **No scheduled routine can arm itself** on your fork; the arming variables are deliberately uninherited. ## Merging Merge when `ci-ok` is green and review is done. If your change added a gating CI job, the guard already forced you to wire it into `ci-ok`'s `needs:` — but remember the honest limitation recorded in [`scripts/ci-ok-guard.sh`](../../scripts/ci-ok-guard.sh): no in-repo check can prove branch protection actually requires `ci-ok`; that is a repository setting a maintainer owns. --- # Architecture Two layers, kept apart on purpose. 1. **[CI & automation platform](ci-platform.md)** — a generic pattern for gating a repository, regenerating its derived files, and running agentic workflows against it. Nothing in it knows what the repo builds. This is the part that becomes a template. 2. **[Design workflow](design-workflow.md)** — the OpenSCAD / FDM 3D-print domain: the geometry, the printability gates, the co-design loop. This is the part you replace. The point of the split is reuse. Everything in layer 1 talks about *files, gates, derived artifacts, and workflows* — never about millimeters or STLs. Everything domain-specific lives in layer 2 and plugs into layer 1 at three named seams. Swap layer 2 and you have the same machine building something else. ## The seams The platform never calls a domain tool directly. It reaches the domain through three interfaces, each a plain file or script contract: | Seam | Platform side | Domain side | |---|---|---| | **Classification** | `changes` CI job asks "what runs for this diff?" | `scripts/ci-classify.sh` maps changed paths → gate set | | **Regeneration** | `regen` CI job commits derived artifacts back | `render.sh`, `animate.sh`, `product-shot.sh`, `gallery.sh` produce them | | **Gating** | `render-gate` job runs whatever the classifier selected | `gate.sh`, `check.sh`, `readme-gate.sh`, `style-check.sh` are the gates | To retarget the template: rewrite the domain scripts behind these three seams, keep the CI jobs, the auth model, and the autonomy engine as they are. ## Reading order - New to the repo → [design-workflow.md](design-workflow.md) first (what it builds), then [ci-platform.md](ci-platform.md) (how it ships). - Extracting the template → [ci-platform.md](ci-platform.md), then this table. ## The rest of `docs/` These two files are the map. The deep dives stay in their existing single-topic docs and are linked from both: `licensing.md`, `derivative-designs.md`, `decision-gate.md`, `print-feedback.md`, `roadmap-board.md`, `actions-security.md`, `vercel-hosted-tooling.md`. --- # CI & automation platform The domain-agnostic half of print-bench: how any repository built this way selects its gates, keeps derived files honest, and runs unattended agents. Read it as the template skeleton. Where it names a domain script (`gate.sh`, `render.sh`) that name is a seam — see the [architecture index](README.md). ## Invariants Five rules the whole platform is built to hold. Break one and the failure is silent, which is why each has a mechanism, not a convention. 1. **A skipped required check does not satisfy branch protection.** GitHub treats a skipped required context as still-pending, and the PR never merges (learned on PR #50). So required jobs always *run* and decide internally whether they have work — they never gate themselves out with a workflow-level `paths:` filter. Job `name:` strings are load-bearing: branch protection matches on them. 2. **A committed derived file can never be older than its source.** Anything generated *from* tracked source is regenerated and committed by CI in the same run that gates the source. Gates on derived files are then presence-only, and still correct. 3. **The "what runs" decision has one source of truth.** CI and the local pre-push check read the *same* classifier script over the same diff, so "would CI pass?" locally can't drift from CI. 4. **A new blocking check never lands unannounced.** Gates that can fail a PR default to *proposed* and require an explicit human cross; only advisory (non-failing) gates auto-enable. 5. **Automation acts only when config-in-git and a live switch agree.** A clone or fork can't silently arm an agent; a human can disarm one in seconds. ## The pipeline `.github/workflows/ci.yml`. One `changes` job classifies the diff; every other job keys off its outputs. ```mermaid flowchart TD changes[changes: classify diff] --> gatesel{gate set} gatesel --> scad[scad-check + nightly] gatesel --> render[render-gate: gate artifacts, test] gatesel --> unit[per-tool unit tests] gatesel --> stylegate[style-gate] changes --> regen[regen: regenerate + commit derived files] regen --> docs[design-docs: gate product pages] regen --> site[site-build] lint[lint: shell + workflow + syntax] --> ok changes --> smart[smart-ci: propose new gates] changes --> docsstd[docs-standards: docs + page] scad --> ok[ci-ok: aggregate] render --> ok unit --> ok stylegate --> ok docs --> ok site --> ok smart --> ok regen --> ok docsstd --> ok ``` **Gate selection is deterministic.** `scripts/ci-classify.sh` takes the changed-file list and emits which gates run and over which units. A docs-only diff skips the render jobs; a single-design diff gates only that design (plus its dependents); an infra change (shared library, generator script, workflow) gates everything. The `changes` job only computes the diff and event mode — the script owns the policy, and `/preflight` runs it with `--local` so the local mirror is the same code. **`ci-ok` is the aggregation gate.** It `needs:` every other job and passes only if each is `success` or `skipped`. Branch-protect this one context instead of a dozen, and the classifier can skip jobs freely without stranding a PR — `ci-ok` runs on `!cancelled()` so it always reports. **Two render engines, on purpose.** A stable build is the compatibility baseline for what most users run; a nightly build with the faster backend is what the gate actually renders on. Both run so a nightly-only regression is caught before it reaches the gate. (Domain detail: [OpenSCAD](design-workflow.md).) ## Regenerate-and-commit The mechanism behind invariant 2, and the platform's most reusable idea. Derived artifacts (previews, galleries, generated pages) are **not** authored by hand. The ungated `regen` job runs the domain generators for the designs a PR touched and commits the result back to the PR branch. Three parts make it safe: - **Input fingerprint.** `scripts/regen-stamp.sh` hashes a unit's inputs against a committed stamp; an unchanged unit is skipped, and the stamp is written in the same commit as the artifacts so it can't claim false freshness. - **Loop guard.** The commit-back re-triggers CI (see tokens below), so a non-reproducible generator could push forever. The job recognises its own last commit at `HEAD` and refuses a second push — bounding the cycle at two runs and warning if the second pass produced different bytes. - **Fork fallback.** CI can't push to a fork, so there `regen` fails with the exact file list to regenerate by hand instead of skipping silently. ### The token rule (generic, and easy to get wrong) A push made with the default `GITHUB_TOKEN` triggers **no** workflow. That fact cuts both ways, and the platform uses both directions: | Push | Token | Why | |---|---|---| | `regen` commits artifacts back to a PR | **PAT** (`REGEN_TOKEN`) | must re-trigger CI so required checks attach to the commit that ships; a `GITHUB_TOKEN` commit would move the PR head onto a commit with no checks and nothing to add them — permanently unmergeable | | `ci-gate approve` edits the registry | **PAT** | same: the newly-enabled gate must run | | telemetry roll-up on main | **`GITHUB_TOKEN`** (deliberately) | must *not* re-trigger, or every telemetry commit would gate the catalog, record itself, and commit forever | Checkouts run `persist-credentials: false`; the push token is handed to the one step that pushes, so the generators that run in between never hold it. ## Smart CI: proposing gates that don't exist yet Above the classifier (which picks among gates that exist) sits `tools/ci-gates/`, which proposes new ones. Three parts, kept separate: - **Detectors** — pure functions of the changed-file list deciding whether a candidate gate applies (e.g. a shell script changed with no shellcheck gate). - **Registry** (`.github/ci-gates/registry.conf`) — the committed decision per candidate (`on`/`proposed`/`off`) and its tier. Git-tracked, so every run reads it identically; GitHub supplies only interaction and auth. - **Selector** — joins the two into buckets the `smart-ci` job runs and a sticky PR comment reports. The tier *is* the auto-approve policy (invariant 4): **advisory** gates run automatically; **gating** gates stay proposals until a human crosses them. ## Config-as-data + comment-commands + privileged workflows A pattern that recurs across the platform (`ci-gates`, `backlog-burn`, `decide`): the decision lives as **data in git**; a **PR/issue comment command** mutates it; a **privileged workflow** authorizes and applies the change. The security shape matters and is the same every time: - The privileged workflow triggers on `issue_comment` (so it holds secrets and the PAT). It therefore **never checks out or runs the PR head's code** — it edits the head branch's config *as data* with the base repo's trusted tooling, and commits via the Contents API. - Authorization is the commenter's real repository permission, not a string in the comment. - From a fork (nothing to commit to), the command's response says to edit the config file in the PR instead. See [`actions-security.md`](../actions-security.md) for the accepted posture on `workflow_dispatch`/`push` branch selection. ## The autonomy engine Unattended agents that turn issues into draft PRs. The reusable core is `tools/backlog-burn/`: a **pure, tested selector** that picks at most one issue per firing — filtered to an opt-in label, oldest-first, excluding anything already claimed by an active lock, an open closing PR, a work branch, or a human-decision hold. It has a negative control per guard. Everything else composes that one selector: - **Backlog burn** (`.github/workflows/backlog-burn.yml`) runs `/ship-issue` on the selected issue. - **Design run** and **chunker** point the *same* selector at different labels (`design-brief`, `declined-too-big`) via their own committed config files. There is one tested selector, not three. - **Arming is two-key** (invariant 5): a committed `*.conf` (the reproducible policy) *and* a repo variable (the live kill switch) must both agree. - **Provider is config.** The LLM behind a run is a committed `provider:` label; each provider has an explicit git-tracked ship step (Actions requires literal secret references). A run skips with a notice when its provider's secret is absent. The loop closes on itself: `/ship-issue` declines an over-large issue and labels it → the chunker splits it into one-PR-sized children and arms the small ones → the burn ships them. ### Human-in-the-loop decision gate When an agentic run hits a binary question a human must answer, it parks the issue with a `needs-decision` label (unspoofable — adding one needs write access) and stops. A maintainer answers with a `/decide yes|no` comment; the label is the authoritative verdict and `.github/decisions/ledger.conf` is the audit trail. Full design: [`decision-gate.md`](../decision-gate.md). ## Telemetry The repo measures itself. `render-gate` captures one JSON record per gate run (scores, per-unit wall time, budget headroom, what was skipped and why) and, on default-branch pushes only, appends it to a committed log — pushed with `GITHUB_TOKEN` precisely so it doesn't re-trigger (see the token table). The autonomy features read this log so their decisions can be data-driven rather than guessed. ## Static site `scripts/site.sh` builds a static product site from what the repo already commits — it invents no content. The local and CI build is deterministic and offline; the live deploy may fetch first-party data (release manifests, repo history) through a best-effort seam that never fails the build and shows a failed fetch as a visible "unavailable" state rather than as empty data. "No external references" governs the served bytes: everything the browser loads is vendored, never a CDN. A local reference that doesn't resolve fails the build. ## What to keep when templating Keep: the `changes`/`ci-classify` seam, `ci-ok` aggregation, the regenerate-and-commit job with its token rules and loop guard, Smart CI, the config-as-data/comment-command/privileged-workflow pattern, the backlog-burn selector and two-key arming, the decision gate, telemetry. Replace: the generator scripts, the gates, and the classifier's path→gate table. --- # Design workflow The domain half: what print-bench actually builds, and the gates that decide a design is done. This is the layer you replace to retarget the [platform](ci-platform.md). It plugs in at the three seams named in the [index](README.md) — classification, regeneration, gating. ## The design unit One directory per design, `designs//`. The entry point is the `.scad` matching the directory name; everything else supports it. | File | Role | Gated by | |---|---|---| | `.scad` | parametric source, the one authored artifact | render + printcheck + slice | | `NOTES.md` | engineering log — decisions, measurements, resume context | presence (`docs-check.sh`) | | `README.md` | **product page** — what it is, print settings, tunable params | `readme-gate.sh` | | `PM.md` | product charter — customer, non-negotiables, scope, backlog | — (read by `/pm`) | | `-coupon.scad` | "print this first" test wrapper for a tuned fit | gated like any part | | `ci.parts` | which `part=` values are printable parts to gate | drives `gate.sh` | | `previews/cameras.conf`, `animations.conf`, `shots.conf` | derived-artifact manifests | presence + budget | | `derives.conf` | lineage record for a derivative design | `lineage.sh` + render gate | | `style.conf` | names a style the design must conform to | `style-check.sh` | | `ARCHIVED` | freezes the design at v0.1, retires it from full-catalog CI | — | Everything under `previews/` is **derived** — CI regenerates and commits it (the regeneration seam). The author owns the `.scad`, the prose, and the manifests; CI owns the pixels. ## The gate stack A design is done when the geometry survives every gate below, not when it renders. `render.sh` alone is not the bar — `gate.sh --slice` is. ```mermaid flowchart TD scad[designs/name/name.scad] --> render[render.sh: STL + 4-view PNG] scad --> check[check.sh: syntax/eval + lib regression] check --> guards[guard-check: every lib guard still refuses] check --> mates[mate-check: every declared fit still assembles] render --> gate[gate.sh --slice] gate --> pc[printcheck: watertight, printable] gate --> slice[PrusaSlicer test-slice] gate --> deriv[derivative override gate] scad --> readme[readme-gate.sh: product page complete] scad --> stylechk[style-check.sh: conforms to declared style] ``` - **`render.sh `** → `build/.stl` + a 2×2 contact sheet (iso / top / front / bottom-iso). The bottom-iso view exists to catch overhangs and bed contact — look at it. - **`check.sh`** — fast pre-commit pass: echo-checks every `.scad`, CGAL-renders the library demos (they *are* the library regression tests), fires `guard-check.sh` (every `lib/*-guards.conf` case a library must refuse) and `mate-check.sh` (every `lib/*-mates.conf` fit that must assemble, plus a deliberately-interfering control), and runs `docs-check.sh`. - **`gate.sh --slice`** — the bar CI enforces: renders each printable part, runs the printcheck analyzer (watertightness, thin walls, overhangs), and test-slices with PrusaSlicer. Picks up the coupon automatically. - **`readme-gate.sh`** — every design ships a complete product page. Checks are presence-only (title, pitch, embedded preview under budget, Print settings + Parameters sections) because the regeneration seam guarantees the embedded images aren't stale. `/preflight` runs exactly this set, scoped the way CI scopes it. It's the single answer to "would CI pass?". ## Libraries OpenSCAD `include`/`use`, resolved through `OPENSCADPATH="$PWD/lib:$PWD"` (the scripts set it; call OpenSCAD directly and an unresolved include only *warns*, then ships a watertight STL with the feature silently missing). - **Vendored, third-party, never edited:** BOSL2 (`lib/BOSL2/`, BSD-2-Clause — permissive) and NopSCADlib (`lib/NopSCADlib/`, GPL-3.0 — copyleft). The license reach differs and is enforced: see [`licensing.md`](../licensing.md) and `license-boundary-check.sh`. - **First-party** (`lib/*.scad`): FDM helpers (`printability.scad`), printable threads (`threads-fdm.scad`), print-in-place mechanisms (`print-in-place.scad`), the NUGGS coupling standard (`nuggs-coupling.scad`), the print-feedback profile (`printer-conf.scad`). Anything used by two or more designs belongs here. Every first-party library ships enforcement a render alone can't provide: - a `lib/-demo.scad` exercising every module — CGAL-rendered by `check.sh`, so it's the regression test. - a `lib/-guards.conf` if its modules assert — one line per parameter set the library must *refuse*. A firing assert aborts its own render, so without these a guard could be deleted and every check stays green. - a `lib/-mates.conf` if it has a tuned fit — one line per pair that must assemble (measured as zero facets of interference), plus a deliberately-interfering control proving the check can fail. A demo can only restate a clearance formula; it can't measure that the built geometry honors it. ## The co-design loop The repo is used session-per-design: one idea in, one gated design out. ```mermaid flowchart LR brief[Brief: what it fits, measurements, style] --> scaffold[Scaffold: /new-design] scaffold --> iter[Iterate preview-first: render.sh, react to the PNG] iter --> iter iter --> finish[Finish: product page + shot/anim manifests] finish --> preflight[/preflight green + human approves preview/] preflight --> commit[Commit + push + draft PR] ``` Iteration is **preview-first**: after each meaningful change, render and react to the shape, not the code. The human approves the *look*; the gates approve the *printability*. Skills codify each step (`/new-design`, `/product-shots`, `/pm`, `/ship-issue`, and the reviewer personas `/jane-review`, `/drik-review`, `/design-coach`). `/design-run` runs the whole loop unattended but **converges on gates, never on taste** — it drives geometry to gate-clean and hands off, because approving a shape is the human's merge decision. ## Domain concepts - **Styles** (`styles//`) — a design language measured out of a reference mesh (edge softness, rounding vocabulary, chamfer grammar, feature sizes) into a checkable spec. `style.json` is the source of truth; `style.scad` and the docs are generated from it. A design opts in via `style.conf` and builds from the tokens, so it conforms by construction. Style constrains *look*, not printability — when they conflict, printability wins. - **Derivatives & lineage** — a design that `include`s another and redefines parts of it. Because OpenSCAD reports *nothing* when a redefinition binds nothing, `derives.conf` records the claimed overrides and the render gate proves each one actually changed the mesh. Read [`derivative-designs.md`](../derivative-designs.md) before building on another design — it documents the four silent failure modes. - **Coupons** — a design with a tuned fit (threads, sliding doors, press-fits) ships a ≤10-line "print this first" wrapper over the production modules (never copied geometry), so a user dials in the fit on a small print. - **Print-feedback loop** (opt-in, feature-flagged) — `printer.conf` carries a printer's measured tolerances; a design reads them instead of a generic clearance. FIELD-TEST entries in NOTES.md record real prints. Ships inert, so the off state is the default. See [`print-feedback.md`](../print-feedback.md). - **Product shots, tiered** — tier-1 is a geometry-true studio render (path-traced from the same STL export the part uses; deterministic, CI-gated). Tier-1.5 is an AI **product still** — the bare part in isolation, photoreal, image-to-image seeded from a tier-1 render (so its angle *is* the tier-1 shot it seeds); it is shown on the page and gated like any preview. Tier-2 is AI-generated lifestyle imagery/motion — a world around the part, or its movement — each AI hop image-to-image/-video seeded from the render before it (raytrace → product still → lifestyle scene → motion clip), though on the current provider the seed does not constrain the geometry to the real mesh (issue #302). Every AI tier is cosmetic and geometry-approximate, gated for presence *and* an honest disclosure label, never for pixels. - **Archiving** — an `ARCHIVED` marker freezes a design at v0.1 and retires it from every full-catalog CI run, so it stops spending render/slice cycles. It's still gated by a PR that edits its own files, and still appears on the site. Revive by working on it again or lifting the marker. ## Render engines OpenSCAD runs headless here — every invocation goes through `xvfb-run -a`. Two builds run in CI (the platform's "two engines" note): the stable 2021.01 release is the compatibility baseline most users have; the nightly snapshot with `--backend=manifold` is order-of-magnitude faster on these models and is what the gate renders on. Scripts honor `OPENSCAD_BIN` / `OPENSCAD_ARGS` so a design never hardcodes a nightly-only flag.