Environment

Required tooling

Platform support

Configuration

None required for the MVP. The app has no backend and no API keys: no server, no accounts, no analytics — see ARCHITECTURE.md. If the optional, opt-in cloud reasoning adapter is ever enabled by a user, their own provider credential is stored in the Keychain, never in a committed file, an environment variable, or a log — see PRIVACY.md.

The marketing site (website/) is also zero-config to build, but it does take optional environment variables, documented in website/.env.example and read from an untracked website/.env. Every one has a local fallback, so npm run build and every npm run check:* pass on a fresh clone with no .env at all:

Variable Fallback What it controls
SITE_URL http://localhost:4321 Absolute origin of your deployment — canonical links, sitemap, robots.txt, OG/Twitter meta. Set it in your own host’s project settings rather than in a file.
ELEVENLABS_API_KEY none (script exits) Local-only, generation-time key for npm run generate:audio. Never deployed.
ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL_ID the script’s defaults Narration voice and model.
PUBLIC_SENTRY_DSN unset — the SDK is never added to the bundle Offers the error-monitoring opt-in on /privacy. Not a credential (write-only ingest), but it names one deployment’s Sentry project. Setting it collects nothing on its own; a visitor still has to consent.
PUBLIC_SENTRY_ENVIRONMENT development Tag separating a deployment’s errors from a developer’s.
SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT unset — source maps are not uploaded Build-time source-map upload, so traces are readable rather than minified. The token is a credential: env or CI secret store only, never PUBLIC_-prefixed, never committed.

Where to obtain each Sentry value, and the separate app/ setting (which is an xcconfig, not an environment variable), are documented together in TODO.md under “Sentry — environment variables and how to get each one”.

No deployment target is hardcoded in tracked source; npm run check:site-url is the gate that keeps it that way. See website/README.md.

.env is loaded automatically

There is nothing to remember at a call site — no --env-file flag, no FOO=bar npm run .... Two small loaders read .env for you:

Loader Covers
scripts/env.sh Shell scripts (scripts/*.sh) and every git hook in .githooks/. Sourced, not run.
website/scripts/load-env.js astro.config.mjs and every script under website/scripts/, so all of npm run build / dev / preview / check:* / generate:audio.

Both read the repo root’s .env first (see .env.example — shared values and git-hook tuning), then website/.env (see website/.env.example — site values), so site-specific settings win. Both leave an already-exported variable alone, so a CI secret, a hosting provider’s project setting, or a one-off SITE_URL=... npm run build always beats the file. Both files are git-ignored and both are optional.

The external-CLI npm scripts (railway:*, vercel:*) go through website/scripts/with-env.js, which loads .env and then execs the CLI unchanged — those tools read RAILWAY_TOKEN / VERCEL_TOKEN from the environment and have no .env support of their own.

env.sh parses .env rather than sourcing it: sourcing would execute the file’s contents inside your git hooks. Only KEY=value lines with a valid shell identifier are honored. scripts/check-env-loader.sh is the regression test for that property and runs in CI.

Local development

  1. Clone the repository.
  2. Open the Xcode project under app/ (or run scripts/open-xcode.sh).
  3. Select your personal Apple ID as the signing team for local, on-device builds — free, no Apple Developer Program enrollment needed (App Store Connect / TestFlight distribution needs the paid program — see DISTRIBUTION.md). app/SenseBridge.xcodeproj/project.pbxproj sets CODE_SIGN_STYLE = Automatic and points every configuration at app/Config/Signing.xcconfig, which is committed and deliberately names no team — a team ID identifies one person’s Apple Developer account, so it stays out of tracked files:
    1. Xcode → Settings → Accounts → add your personal Apple ID (free — not the paid Developer Program).
    2. Create app/Config/Signing.local.xcconfig (gitignored) containing DEVELOPMENT_TEAM = YOURTEAMID. Find the ID with security find-identity -v -p codesigning, or in Xcode under Settings → Accounts → Manage Certificates — it is not the identifier printed in parentheses after the certificate name, which belongs to the certificate rather than the team. Picking your team in the target’s Signing & Capabilities tab works too, but writes the ID into project.pbxproj; move it to the local file rather than committing it.
    3. Add BUNDLE_ID_PREFIX = com.yourname to that same local file if signing fails with “the app identifier cannot be registered to your development team”. com.sensebridge is registered to this project’s team, so it cannot be re-registered to yours; one line re-prefixes the app and both test bundles. An unconfigured clone keeps the original IDs — the default lives in project.pbxproj as $(BUNDLE_ID_PREFIX:default=com.sensebridge) — so this is only needed when you sign against your own team. Per command: xcodebuild BUNDLE_ID_PREFIX=com.yourname ....
    4. Enable Developer Mode on the device: Settings → Privacy & Security → Developer Mode → toggle on → restart → confirm “Turn On” in the lock-screen prompt. Required since iOS 16 for any developer-signed build (Xcode Run, xcodebuild, an ad-hoc IPA via AltStore/Sideloadly, etc.) — the only installs that skip it are App Store and TestFlight, both of which need the paid Developer Program (see DISTRIBUTION.md). Without it, xcodebuild reaches the device but times out waiting for the destination instead of building. One-time per device — it stays on until manually disabled, independent of the 7-day signing expiry below.
    5. Plug in your device, select it as the run destination, hit Run. First launch: on-device Settings → General → VPN & Device Management → trust your developer certificate.
    6. The catch: a free personal-team signature expires after 7 days — re-run from Xcode to re-sign. Fine for active development, annoying for a build you want to leave installed; it’s the free tier’s only real limitation. No API keys are involved in this path — see DISTRIBUTION.md for when one becomes relevant.
  4. Build and run on a physical device for anything touching camera, LiDAR, microphone, or on-device model performance; the simulator is fine for pure UI/VoiceOver-label work but cannot validate the perception pipeline.
  5. Run scripts/setup.sh once — it checks your toolchain and enables the repo’s git hooks (.githooks/): a pre-commit secret/sensitive-file scan plus lint plus actionlint on staged workflow files, a conventional-commit header check (commitlint when the root npm ci has run, else a dependency-free bash-regex fallback), a pre-push build gate that also refuses direct pushes to main, and a post-merge check that flags manifest/toolchain files just pulled in — the same post-merge hook also refreshes website/tsconfig.debug.json’s diagnostics output (.tsbuildinfo.debug, trace/) whenever a merge into main touches website/, advisory-only and deliberately outside the test suite/pre-push gate (see the “Command center” section below and tsconfig.debug.json’s own header comment). gitleaks, ggshield, actionlint, shellcheck, osv-scanner, and Node are advisory for the hooks (brew install gitleaks; brew install ggshield then ggshield auth login; brew install actionlint; brew install shellcheck; brew install osv-scanner; CI enforces commitlint and actionlint regardless via .github/workflows/commitlint.yml and .github/workflows/actionlint.yml). scripts/setup.sh offers to install any missing advisory tool via Homebrew, prompting per tool when run interactively; -y/--yes installs everything missing without asking (scripted/CI use), -n/--no-install reports only and never prompts (the default when stdin isn’t a terminal), and -h/--help prints the full flag list. npm run lint (or scripts/lint.sh) can also be run directly before committing — see the command center below.

The Xcode project file is the source of truth

app/SenseBridge.xcodeproj/project.pbxproj is edited directly and is authoritative. There is no XcodeGen spec: app/project.yml was removed on 2026-08-12 because build settings were being edited in two places and only one of them ever shipped. That drift was not theoretical — the spec was once missing INFOPLIST_KEY_UIBackgroundModes, so a regeneration would have silently dropped the spoken “hands-free awareness stopped” announcement, and a separate regeneration flattened the test targets’ bundle IDs. Nothing in package.json, scripts/, or .github/workflows/ ever invoked xcodegen, so the generator was a second unverified copy of the truth rather than a build step. Add new source and test files through Xcode, or by hand-editing the PBXBuildFile / PBXFileReference / group / Sources-phase entries — the pattern every recent session has used anyway.

The settings that are not self-explanatory, so the reasoning survives the spec’s deletion:

Setting Why
PRODUCT_BUNDLE_IDENTIFIER = $(BUNDLE_ID_PREFIX:default=com.sensebridge).* com.sensebridge is registered to this project’s team, so a contributor signing with their own free Apple ID overrides one variable instead of editing three targets. Set on the app and both test bundles — without it, a contributor gets a signable app and unsignable tests.
INFOPLIST_KEY_UIBackgroundModes = audio Solely so hands-free awareness can say out loud that it stopped when the app leaves the screen (see AmbientAwarenessSession). iOS revokes camera access on backgrounding regardless, so this buys no background observation.
INFOPLIST_KEY_SentryDSN = $(SENTRY_DSN) Carried through Info.plist rather than a Swift constant so the value never lands in a tracked source file; CrashReporting reads it from Bundle.main. A DSN is write-only ingest rather than a credential, but it names one deployment’s Sentry project, so a fork must not inherit this one. Empty unless Config/Sentry.local.xcconfig supplies it.
LM_FORCE_LINK_GENERATION = YES Clears “Metadata extraction skipped. No AppIntents.framework dependency found.” on every build. Nothing adopts App Intents, and linking the framework just to quiet the notice would add a dependency for no feature. LM_FILTER_WARNINGS does not suppress it.
baseConfigurationReferenceConfig/Signing.xcconfig on all six configurations A device test run needs DEVELOPMENT_TEAM, and it lives only in the gitignored Config/Signing.local.xcconfig this chains to. Missing it on the test targets is exactly the gap found on 2026-08-05.

Command center

Every routine command is an npm run script, so npm run with no arguments is the discoverable index of what this repo can do — for a person and for an agent alike. Nothing here is npm-specific work: the scripts wrap the same scripts/*.sh, tools/*.mjs, and xcodebuild invocations CI and the git hooks run, so a local run and a CI run are the same code path rather than two copies that drift.

Run npm install once at the root (commitlint, eslint, prettier, markdownlint-cli2 — no heavy dependencies).

Root — npm run <script>

Script What it does
setup scripts/setup.sh — toolchain check, offers to install missing tools, enables .githooks/
verify lint + format + check — the local mirror of CI’s non-Swift gates
test / app:test Simulator build + test of the SenseBridge scheme
app:build Simulator build, no code signing (what pre-push runs)
app:package-test xcodebuild test for every package under app/Packages/*
app:device Signed build for the attached iPhone
app:device-test Signed build + test on the attached iPhone — the only command that runs the suite against real ARKit, LiDAR, and arm64. Not green yet: its first run (2026-08-19) was 24 passed / 8 failed, in three classes tracked in TODO.md. Some of those are tests that assert the Simulator’s no-camera path, so they cannot pass on a phone; the contrast findings appear to be real.
app:install Device build, then install onto the attached iPhone
eval swift run eval-harness — the AI evaluation harness: renders synthetic fixtures through the real OCR/object services and LabelListSceneComposer, flags over-claiming compositions via ReasoningOutputValidator. See docs/TESTING.md
app:clean / app:open Clean build products / open the Xcode project
lint lint:swift + lint:md + lint:mjs
lint:swift SwiftFormat + SwiftLint (scripts/lint.sh)
lint:md / lint:md:fix markdownlint-cli2 over all Markdown + COMPLETED.todo
lint:mjs / lint:mjs:fix ESLint over the hand-authored .mjs tooling (.claude/hooks, .cursor/hooks, tools/)
lint:actions actionlint over .github/workflows/ (needs brew install actionlint)
lint:shell ShellCheck over scripts/ and .githooks/ (needs brew install shellcheck)
format / format:fix format:mjs / format:mjs:fix
format:mjs / format:mjs:fix Prettier (check / write) over the same .mjs files lint:mjs covers
check Every non-Swift gate below, in order
check:sensitive / :all tools/check-sensitive-files.mjs — staged, or the whole tree
check:contrast Every authored color asset clears WCAG AA (4.5:1) against the system backgrounds it renders on, in both appearances (tools/check-color-contrast.mjs). Device-free arithmetic that catches the class of contrast defect the on-device XCUIAccessibilityAudit reports as a bare “Contrast nearly passed”
check:skills Canonical skill tree, personas, and harness adapters match the hash-lock (tools/skill-lock.mjs)
check:hooks .claude/settings.json carries no owner-personal or double-registered hook (tools/check-settings-hooks.mjs)
check:bmad BMAD’s user_skill_level still reads expert in both config files (tools/check-bmad-config.mjs)
check:links Relative Markdown links resolve (scripts/check-links.sh)
check:docs-js docs/assets/js/docs.js parses — it ships unbundled
check:env-loader scripts/env.sh parses .env rather than executing it
check:linguist Vendored skill trees stay out of GitHub’s language bar and first-party .js/.mjs/.ts stays in it (tools/check-linguist-vendored.mjs)
check:codegraph The local CodeGraph index directory is 0700 (tools/check-codegraph-perms.mjs). .codegraph/codegraph.db holds the full text of every source file, and a fresh codegraph init creates it world-readable. Passes when no index exists
check:secrets gitleaks detect (needs brew install gitleaks)
check:deps osv-scanner over both lockfiles (needs brew install osv-scanner)
docs:build Render docs/ to tmp/_site with the github-pages gem bundle, containerized (needs Docker)
docs:a11y docs:build, then the accessibility gate over the result — installs pa11y/puppeteer on demand, deliberately not devDependencies, see TOOLING.md
design:detect Impeccable design detectors over website/
sync:skills Regenerate the mirrored skill copies from canonical
todo:sweep / :check Cut ticked items out of TODO.md’s To-Do sections straight into COMPLETED.todo (--check reports without writing)
todo:archive / logs:condense / wiki:home The tools/*.mjs maintenance jobs
website:* install, dev, build, check delegated into website/
website -- <script> Any other website/ script, e.g. npm run website -- typecheck:debug

website/npm --prefix website run <script>

Script What it does
dev / preview Astro dev server / preview of dist/
build / sync / clean Astro build / generate .astro types / delete build output
check Everything CI’s website lint job runs, plus the built-output checks
typecheck astro check — the .astro components
typecheck:tsc tsc -p tsconfig.json --noEmit — the .ts/.tsx sources
typecheck:watch The same, watching
typecheck:debug tsc -p tsconfig.debug.json — deep diagnostics, see below
typecheck:all typecheck + typecheck:tsc; this is the blocking pair
trace typecheck:debug, then trace:analyze — the whole “why is tsc slow” loop
trace:analyze / :analyze:types @typescript/analyze-trace over trace/, compact or with types expanded
trace:types simplify-trace-typestrace/types.json boiled down to readable JSON
lint / lint:fix Stylelint + ESLint, check or autofix
format / format:fix Prettier
check:* Disclaimer verbatim, zero-JS posture, SITE_URL, audio freshness, drag-to-orbit, bfcache restore
check:consent Drives a real browser over dist/ and asserts the Sentry chunk is never fetched before consent, that opting in fetches it, and that Global Privacy Control overrides a stored consent. Skips loudly on a build with no PUBLIC_SENTRY_DSN.
test:a11y pa11y-ci at WCAG2AA — serves dist/ itself, building it first if absent
chrome:install Installs the Chrome build Puppeteer expects. Run this if the browser-driven checks fail — see below
railway:* / vercel:* Deployment status, logs, and deploys

If check:csp, check:consent, check:scene-drag, check:bfcache, test:a11y, and doctor all fail at once with Could not find Chrome (ver. …), run npm --prefix website run chrome:install. Puppeteer’s own browsers install truncates its unpack on macOS — it writes 41 of the archive’s 651 entries, omits the .framework, and still exits 0 with a success line, so the failure surfaces six commands away from its cause. The download is not the problem and neither is the Node version (Node 22 and 26 both reproduce it); website/scripts/install-chrome.mjs fetches the same archive and unpacks it with ditto, then verifies by running the binary. A newer Chrome already in the cache does not satisfy Puppeteer — it wants the exact pinned build.

typecheck:debug is a diagnostics pass, not a gate. Its skipLibCheck: false surfaces conflicts inside generated .astro/*.d.ts and vendored node_modules typings, so it exits non-zero even when website/src is clean; read its output, don’t chain it. typecheck:all is the pair that gates.

It also writes a compiler perf trace to website/trace/ (generateTrace in tsconfig.debug.json). npm run website -- trace runs both halves: the diagnostics pass, then @typescript/analyze-trace over what it wrote, which reports the hot spots as a tree of files, declarations, and type comparisons with the milliseconds each cost. Use it to answer “which .d.ts is making tsc slow” — today that’s @typescript-eslint/utils and immutable, both vendored, neither ours. Two limits worth knowing before reaching for it:

Both accessibility gates are self-contained: each produces whatever it needs and cleans up after itself, so neither is CI-only any more.

test:a11y runs Astro’s preview server inside its own process (website/scripts/a11y.js) rather than starting astro preview as a child. That distinction is the whole design. A start/stop wrapper that dies between the two leaves an orphaned process holding port 4321 — which is exactly what this repo forbids — whereas an in-process listener is owned by that PID, so the kernel closes it however the run ends, SIGKILL included. If you already have npm run preview up, the script detects it and tests against yours instead of clashing.

docs:a11y builds docs/ first, via scripts/docs.sh, under the same github-pages gem bundle GitHub Pages itself runs — pinned to Jekyll 3.10 — in a ruby:3.3-slim container, so nothing lands on the host and a local run matches ci.yml’s docs-a11y job. The first run compiles the bundle’s native extensions and takes a few minutes; the gems persist in tmp/docs-jekyll/, so later runs are seconds. Do not substitute the jekyll/jekyll image — it lacks jekyll-relative-links and renders every in-docs link unrewritten. See CI-CD.md.

admin/npm --prefix admin run <script>

The single-owner dashboard, added 2026-08-12. Deliberately not wired into the root npm run check: two of its three tiles read files that exist only on the owner’s machine, so it is checked when it is touched rather than on every build. Full rationale in admin/README.md.

Script What it does
dev / start Next.js dev and production servers, port 4331
build / typecheck Production build; tsc --noEmit
smoke Starts the real production server and drives the auth gate over HTTP — 15 cases covering the fail-closed 503, 401s, a near-miss password, the 200, gated API routes, CSP headers, and a fail-soft tile
check typecheck + build + smoke

It refuses to serve without ADMIN_PASSWORD (503, not an open dashboard), so copy admin/.env.example to admin/.env.local before the first run.

Secret handling

The app itself needs no secrets — it is serverless and on-device, so nothing ships with a key. CI, deployment, and some local tooling do need credentials: every one of them is inventoried in SECRETS.md, along with where it is configured and what breaks when it is missing.

Keep secrets in the Keychain (on-device), GitHub Actions repository secrets (CI), or an untracked .env (local tooling) — never in the repository, a log, or a committed .env file. Run tools/check-sensitive-files.mjs before publishing changes that touch signing or credentials.


Need help? See SUPPORT.md.