Environment
Required tooling
- A Mac running a current version of Xcode (Swift 6 toolchain, iOS 26 SDK or later — Foundation Models and the other frameworks this project depends on require it). This is the entire toolchain for the MVP.
- Git.
- A personal Apple ID for building and running on your own device — this is free. See DISTRIBUTION.md for the one place a paid account is actually required (TestFlight/App Store).
- A capable physical iPhone (iPhone 15 Pro or later — Foundation Models requires Apple Intelligence support) for on-device testing. The simulator cannot exercise the camera, LiDAR, or on-device model performance that this app is built around; published latency figures for on-device models shouldn’t be trusted, so benchmark on your own hardware before making architecture decisions that depend on them (see AI-MODELS.md).
Platform support
app/— the iOS app itself — is macOS-only by design: it depends on Xcode,xcodebuild, and a physical iPhone, and there is no cross-platform path planned (see AGENTS.md’s doctrines).- Everything else —
website/,docs/, and this repo’s own scripts and git hooks — works on Windows and Linux.scripts/*.shand.githooks/*are bash/POSIXsh; on Windows, run them from Git Bash (bundled with Git for Windows, which you need anyway to clone the repo) or WSL2 — nativecmd/PowerShell can’t execute a.shfile directly. Linux has bash natively.scripts/setup.shand the git hooks (.githooks/pre-commit’sscripts/lint.shcall,.githooks/pre-push’s build gate) skip the Xcode/Swift checks outside macOS instead of failing, so a website/docs-only contributor on Windows or Linux isn’t blocked by a toolchain this repo can’t run for them there.
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
- Clone the repository.
- Open the Xcode project under
app/(or runscripts/open-xcode.sh). - 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.pbxprojsetsCODE_SIGN_STYLE = Automaticand points every configuration atapp/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:- Xcode → Settings → Accounts → add your personal Apple ID (free — not the paid Developer Program).
- Create
app/Config/Signing.local.xcconfig(gitignored) containingDEVELOPMENT_TEAM = YOURTEAMID. Find the ID withsecurity 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 intoproject.pbxproj; move it to the local file rather than committing it. - Add
BUNDLE_ID_PREFIX = com.yournameto that same local file if signing fails with “the app identifier cannot be registered to your development team”.com.sensebridgeis 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 inproject.pbxprojas$(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 .... - 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,xcodebuildreaches 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. - 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.
- 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.
- 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.
- Run
scripts/setup.shonce — 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 rootnpm cihas run, else a dependency-free bash-regex fallback), a pre-push build gate that also refuses direct pushes tomain, and a post-merge check that flags manifest/toolchain files just pulled in — the same post-merge hook also refresheswebsite/tsconfig.debug.json’s diagnostics output (.tsbuildinfo.debug,trace/) whenever a merge intomaintoucheswebsite/, advisory-only and deliberately outside the test suite/pre-push gate (see the “Command center” section below andtsconfig.debug.json’s own header comment).gitleaks,ggshield,actionlint,shellcheck,osv-scanner, and Node are advisory for the hooks (brew install gitleaks;brew install ggshieldthenggshield auth login;brew install actionlint;brew install shellcheck;brew install osv-scanner; CI enforces commitlint and actionlint regardless via.github/workflows/commitlint.ymland.github/workflows/actionlint.yml).scripts/setup.shoffers to install any missing advisory tool via Homebrew, prompting per tool when run interactively;-y/--yesinstalls everything missing without asking (scripted/CI use),-n/--no-installreports only and never prompts (the default when stdin isn’t a terminal), and-h/--helpprints the full flag list.npm run lint(orscripts/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. |
baseConfigurationReference → Config/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-types — trace/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:
- It measures type-checking, nothing else. It cannot see bundle size,
runtime performance, or anything the browser does. For a jank or freeze in
the shipped site, record a Chrome DevTools performance profile instead, or
add a probe to a puppeteer check the way
website/scripts/check-bfcache.jsmeasures long tasks after a bfcache restore. trace/types.jsonruns to hundreds of megabytes. It is gitignored, andnpm run website -- cleandeletestrace/along with the rest of the build output — do that rather than leaving it on disk.
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.