Compare commits

Author SHA1 Message Date
jjbrunton 439f506fff feat(dares): seed 150-item catalogue 2026-09-05 22:30:39 +01:00
jjbrunton d998e32047 merge: require upfront OTA assessment 2026-09-05 22:15:42 +01:00
jjbrunton 7edd957547 docs(agents): require upfront OTA assessment 2026-09-05 22:15:29 +01:00
jjbrunton fdaa8c47bb merge: add full question catalogue draft 2026-09-05 21:41:20 +01:00
jjbrunton 04328fb3a8 fix(catalogue): integrate reviewed question sources 2026-09-05 21:36:20 +01:00
jjbrunton f373e3beb4 docs(catalogue): add full live catalogue draft 2026-09-05 21:03:46 +01:00
jjbrunton 583d38eb8c merge(staging): preserve main admin fixes 2026-09-05 20:51:32 +01:00
jjbrunton d604cd7a11 fix(catalogue): avoid literal em dash in validator 2026-09-05 20:28:26 +01:00
jjbrunton ddf1e33a4e merge: add play-safe spicy question draft 2026-09-05 20:27:55 +01:00
jjbrunton da1c310738 docs(catalogue): trim draft source whitespace 2026-09-05 20:27:31 +01:00
jjbrunton 5c8c9cc9a5 docs(catalogue): finalise spicy question draft 2026-09-05 20:24:27 +01:00
jjbrunton c297f03b56 docs(catalogue): add audio concept source 2026-09-05 20:16:34 +01:00
jjbrunton c79d993cf5 docs(catalogue): preserve text answer concepts 2026-09-05 20:14:08 +01:00
jjbrunton b5e6b70d54 docs(catalogue): repair spicy swipe source 2026-09-05 20:07:14 +01:00
jjbrunton 2a2cbe9e85 docs(catalogue): add concept set b source 2026-09-05 19:59:02 +01:00
jjbrunton eb54aa4daa docs(questions): add concept set a swipe drafts 2026-09-05 19:57:48 +01:00
jjbrunton e509ac732f docs(catalogue): replace swipe draft concepts 2026-09-05 19:45:51 +01:00
jjbrunton a72fe9c06d docs(catalogue): expand draft pack themes 2026-09-05 19:36:52 +01:00
jjbrunton 450c527d1e docs(question-types): document interaction contract 2026-09-05 19:33:17 +01:00
jjbrunton 6c7219c741 docs(catalogue): refine spicy draft copy 2026-09-05 19:31:57 +01:00
jjbrunton b02a105e10 docs(catalogue): add play-safe spicy draft 2026-09-05 19:29:05 +01:00
jjbrunton f4687f95fe Merge pull request #29 from jjbrunton/staging
fix(admin): prevent blank dashboard after login
2026-09-05 17:12:45 +01:00
jjbrunton 53a48ab6d5 fix(admin): use feature interest composite key 2026-09-05 17:02:07 +01:00
jjbrunton a6233d0175 fix(admin): render standalone feature interests 2026-09-05 16:57:57 +01:00
jjbrunton c776859183 Merge pull request #28 from jjbrunton/staging
Promote staging to main: Apple review onboarding fixes and v1.0.8 build 49
2026-09-01 16:16:37 +01:00
jjbrunton cc12f5f0ff Merge remote-tracking branch 'origin/main' into sync/staging-backmerge 2026-09-01 16:10:11 +01:00
jjbrunton 3659cc8c2f chore(ios): record v1.0.8 build 49 2026-09-01 14:08:05 +01:00
jjbrunton 4b227ad62c chore(mobile): prepare iOS build 48 2026-09-01 13:47:47 +01:00
jjbrunton 7c8d91c89c fix(mobile): address Apple review onboarding flows 2026-09-01 09:42:48 +01:00
jjbrunton fe1248c59d Merge pull request #25 from jjbrunton/staging
Staging
2026-08-31 20:56:43 +01:00
jjbrunton 6883befff3 fix(mobile): persist matches tutorial dismissal 2026-08-31 19:39:33 +01:00
jjbrunton a06f1b69e8 Merge pull request #27 from jjbrunton/hotfix/admin-cors-origin
fix(api): allow the admin console origin through CORS
2026-08-31 18:22:23 +01:00
jjbruntonandClaude Opus 5 3a019e62d3 fix(api): allow the admin console origin through CORS
The production allowlist held only https://sauci.app, so every
/v1/admin/* preflight from https://manage.sauci.app returned 204 with no
Access-Control-Allow-Origin and the browser blocked the request. Login
then reported "Access denied. You are not an administrator." because the
admin app treats any /v1/admin/me failure as a permissions failure.

Add the admin console origin and record in the README that new admin
hosts belong in the Compose contract rather than a Dokploy override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:26 +01:00
jjbruntonandClaude Opus 5 636811e66a fix(api): allow the admin console origin through CORS
The production allowlist held only https://sauci.app, so every
/v1/admin/* preflight from https://manage.sauci.app returned 204 with no
Access-Control-Allow-Origin and the browser blocked the request. Login
then reported "Access denied. You are not an administrator." because the
admin app treats any /v1/admin/me failure as a permissions failure.

Add the admin console origin and record in the README that new admin
hosts belong in the Compose contract rather than a Dokploy override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:10 +01:00
jjbrunton 84712a950e Merge pull request #26 from jjbrunton/hotfix/admin-docker-context
fix(admin): restore admin docker build context, allow hotfix branches into main
2026-08-31 18:08:31 +01:00
jjbruntonandClaude Opus 5 d14869d745 ci(branch-policy): allow hotfix and release branches into main
The policy accepted only `staging` as a head for main, so any change
production needed before the next promotion had nowhere to go: the PR was
unmergeable by CI and branch protection blocks direct pushes.

Accept `hotfix/*` and `release/*` heads as well. The naming requirement
keeps a direct-to-main change deliberate and visible, and the master
freeze plus the "features target staging" rule are unchanged. Document
the escape hatch and the need to land the same commit on staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:48 +01:00
jjbruntonandClaude Opus 5 f1573f4906 fix(admin): keep admin app in the docker build context
The root .dockerignore excluded apps/admin, so the monorepo build context
dropped that workspace entirely: npm ci skipped it and
`npm run build -w @sauci/admin` failed with "No workspaces found".
The ignore file was authored for the mcp image and later extended with
re-includes for web; admin never got the same treatment.

Re-include apps/admin, and re-exclude node_modules/dist last so the
re-includes cannot pull host build artefacts into any image context.

Verified with `docker build -f apps/admin/Dockerfile .`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:48 +01:00
jjbruntonandClaude Opus 5 d825e3d80c ci(branch-policy): allow hotfix and release branches into main
The policy accepted only `staging` as a head for main, so any change
production needed before the next promotion had nowhere to go: the PR was
unmergeable by CI and branch protection blocks direct pushes.

Accept `hotfix/*` and `release/*` heads as well. The naming requirement
keeps a direct-to-main change deliberate and visible, and the master
freeze plus the "features target staging" rule are unchanged. Document
the escape hatch and the need to land the same commit on staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:34 +01:00
jjbruntonandClaude Opus 5 13094267a6 fix(admin): keep admin app in the docker build context
The root .dockerignore excluded apps/admin, so the monorepo build context
dropped that workspace entirely: npm ci skipped it and
`npm run build -w @sauci/admin` failed with "No workspaces found".
The ignore file was authored for the mcp image and later extended with
re-includes for web; admin never got the same treatment.

Re-include apps/admin, and re-exclude node_modules/dist last so the
re-includes cannot pull host build artefacts into any image context.

Verified with `docker build -f apps/admin/Dockerfile .`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:56:41 +01:00
jjbruntonandClaude Fable 5 ecc31a13a2 feat(release): enforce manifest and Info.plist release invariants
Extend release-preflight to reject an Android manifest missing the
READ_MEDIA_IMAGES/READ_MEDIA_VIDEO tools:node="remove" attributes and
an iOS Info.plist that regained dev-launcher-only keys, both
regressions introduced silently by expo prebuild during the SDK 54 to
56 upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 16:03:04 +01:00
jjbruntonandClaude Fable 5 7a76936829 docs(agents): promote v1.0.8 release learnings
Record three confirmed v1.0.8 failure modes (expo prebuild native
regressions, npm 10/11 lockfile divergence, local EAS build
preconditions) and promote each into docs/releasing.md, a new
docs/development/dependencies.md, and the release-preflight script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 16:03:02 +01:00
jjbrunton 83df3b45f8 Merge pull request #23 from jjbrunton/staging
Release v1.0.8: Expo SDK 56 and EAS Observe
2026-08-31 15:47:29 +01:00
jjbruntonandClaude Fable 5 9609a9084b fix(deps): pin @react-native/metro-config to end npm 10/11 lock divergence
react-native-worklets depends on @react-native/metro-config with a
wildcard range; npm 11 (local) resolves and hoists 0.87.x where npm 10
(CI runners and Docker images) requires 0.85.3 at the same position, so
every lockfile regeneration broke one side. Pin 0.85.3 via the root
overrides and regenerate: npm ci now passes on npm 10.8.2 linux/amd64
(SWC, rollup, and lightningcss platform binaries all install) and on
local npm 11, with verify:fast green on both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:43:49 +01:00
jjbruntonandClaude Fable 5 90cde4aca4 fix(web): pin Next SWC binaries and regenerate lockfile with CI's npm
Since the SDK 56 dependency churn, arborist nests next under apps/web and
npm 10 skipped its platform-optional SWC binaries on the x64 CI runner,
breaking next build. Pin the linux and darwin binaries as direct
optionalDependencies of @sauci/web (bump alongside next) and regenerate
the lockfile from scratch with npm 10.8.2 to match CI. Verified in a
linux/amd64 node:20.19.4 container: npm ci installs the binary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:29:01 +01:00
jjbruntonandClaude Fable 5 ecff9e2ec8 fix(deps): regenerate lockfile with npm 10 for CI compatibility
Today's dependency changes left orphaned @react-native 0.87.x entries
that npm 10's ci validation rejects; npm 11 tolerated them locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:02:09 +01:00
jjbrunton ffc655f576 Merge remote-tracking branch 'origin/main' into staging 2026-08-31 14:59:44 +01:00
jjbruntonandClaude Fable 5 d8bedead0c chore(ios): sync widget Xcode build number to 47
EAS auto-increment updates the Info.plists but not the widget target's
CURRENT_PROJECT_VERSION; the shipped widget binary already carries 47
from its Info.plist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:59:02 +01:00
jjbruntonandClaude Fable 5 6c3a181613 chore(android): record v1.0.8 build 51
AAB sha256 4893ee6f64f90bb1b2e1f13c6b088cbe75103337fbc101751da7076224778c29
verified: com.sauci.app 1.0.8 (51), production upload key, four ABIs,
Worklets/Reanimated/Skia libraries, no broad media read permissions,
production OTA endpoint, channel, and code-signing certificate. Supersedes
the build 50 artifact, which predated the skia 2.6.9 bump and the
expo-av migration and was quarantined unshipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:58:10 +01:00
jjbruntonandClaude Fable 5 d8ff39a2f3 chore(ios): record v1.0.8 build 47
IPA sha256 760517ccf474d496c940c3f84825f4852306d840d913090610a77dc24450ee36
verified: com.sauci.app and com.sauci.app.LiveDrawWidget both 1.0.8 (47),
team BXVPBATDZY, associated domains, shared app group, production OTA
runtime, endpoint, and code-signing certificate, no dev-launcher keys.
Also syncs the tracked Podfile.lock to the archive's resolution (expo-av
removed; ExpoVideo, ExpoAudio, ExpoSystemUI added; skia 2.6.9).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:46:44 +01:00
jjbruntonandClaude Fable 5 b8cb2addff fix(mobile): migrate expo-av to expo-video and expo-audio
expo-av has no SDK 56 compatible release, and its EXEventEmitter.h
interface was removed from expo-modules-core, so the iOS release
archive fails compiling Pods/EXAV. Move video playback to
expo-video's useVideoPlayer/VideoView, and audio playback and
recording to expo-audio's createAudioPlayer/AudioModule.AudioRecorder,
preserving existing controls, mute/loop/resize behavior, and the
recording audio session configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:35:51 +01:00
jjbruntonandClaude Fable 5 5e28117662 fix(ios): bump react-native-skia to 2.6.9 for the base64 header fix
2.6.2 fails to archive under the SDK 56 toolchain: base64.cpp includes
third_party/base64.h, which the podspec header glob no longer resolves.
2.6.9 includes the header relative to its own directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:15:42 +01:00
jjbruntonandClaude Fable 5 196428f796 chore(android): record v1.0.8 build 50
AAB sha256 29c9bee3949e51b027c558637c04eefec96bf7f177151cb9571ca2a289ffee4e
verified: com.sauci.app 1.0.8 (50), production upload key, four ABIs,
Worklets/Reanimated/Skia libraries, no broad media read permissions,
production OTA endpoint, channel, and code-signing certificate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:07:58 +01:00
jjbruntonandClaude Fable 5 6bd04a6c06 fix(ios): keep dev-launcher keys out of the release Info.plist
The SDK 56 prebuild added NSBonjourServices, RCTMetroPort, and the Expo
Dev Launcher NSLocalNetworkUsageDescription to the tracked Info.plist.
v1.0.7 shipped without them and the production app has no local-network
feature to declare.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 13:43:56 +01:00
jjbruntonandClaude Fable 5 7aca39aa8f fix(android): restore blocked media permission removals in manifest
The SDK 56 prebuild dropped the tools:node=remove entries for
READ_MEDIA_IMAGES and READ_MEDIA_VIDEO, so the merged release manifest
regained permissions the Play media policy forbids; Sauci uses the system
photo picker. Restore the removals shipped in v1.0.7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 13:42:44 +01:00
jjbruntonandClaude Fable 5 79233f58aa fix(android): raise Gradle daemon memory for RN 0.85 toolchain
The prebuild-regenerated gradle.properties restored stock 2GB heap and
512MB Metaspace limits; KSP and CMake configuration under SDK 56 exhaust
Metaspace and fail the release build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 13:19:41 +01:00
jjbruntonandClaude Fable 5 c9e8835810 fix(mobile): scope Expo exports to native platforms
A bare expo export (used by the OTA preflight and by eoas publish) also
exported the unused web target, whose static render breaks under SDK 56.
XPREM serves only iOS and Android bundles and the website is apps/web, so
declare the Expo app native-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:12:18 +01:00
jjbruntonandClaude Fable 5 ff8f99f418 chore(release): prepare v1.0.8
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:07:13 +01:00
jjbruntonandClaude Fable 5 604eec3322 fix(ios): restore LiveDrawWidget identity after apple-targets 5 regeneration
The SDK 56 prebuild derived the widget bundle id from its folder name
(com.sauci.app.widget) and pointed CODE_SIGN_ENTITLEMENTS at the ignored
ios/.targets cache. Restore the shipped com.sauci.app.LiveDrawWidget
identity, the tracked entitlements file, and pin bundleIdentifier in the
target config so future prebuilds keep it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:07:13 +01:00
jjbrunton dbf548c7bd Merge remote-tracking branch 'origin/staging' into staging
# Conflicts:
#	docs/releasing.md
2026-08-31 11:54:50 +01:00
jjbrunton 4973f2c631 Merge remote-tracking branch 'origin/staging' into staging
# Conflicts:
#	.codex/skills/release-mobile/SKILL.md
#	apps/mobile/package.json
#	docs/releasing.md
2026-08-31 11:34:33 +01:00
jjbruntonandClaude Fable 5 2b05e50312 feat(release): add fastlane ship lanes and end-to-end release pipeline docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:32:32 +01:00
jjbruntonandClaude Opus 5 26312e5a58 docs(release): a release request carries through to rollout
The upload execution policy stopped an agent at upload: App Review
submission and rollout each needed their own explicit authorization, so a
request to ship a release ended with an artifact sitting in App Store
Connect and a question back to the user. That contradicted the harness
standard in documentation/standards/react-native.md, which has always
authorized the full pipeline, and it is the wrong default: every step from
here is recoverable. A submission can be cancelled while it waits for
review, a phased release paused, a staged rollout halted.

A release request now authorizes build, verification, upload, App Review
submission, and rollout for the platforms it names.

What still needs its own authorization is anything that is not part of
shipping the requested release: widening TestFlight distribution to new
groups, and re-uploading after an ambiguous upload result. The existing
automatic-TestFlight-distribution check before an iOS upload is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:31:08 +01:00
jjbrunton 66c25849e9 Merge remote-tracking branch 'origin/staging' into task/expo-56-observe
# Conflicts:
#	docs/releasing.md
2026-08-31 11:15:09 +01:00
jjbruntonandClaude Fable 5 5c4a4608b1 fix(mobile): record xprem_js_crash only for fatal JS errors
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:13:55 +01:00
jjbruntonandClaude Fable 5 659cee76b6 feat(mobile): add expo-observe update health tracking
Install expo-observe (56.0.29, matching the SDK 56 line) and configure
its endpoint under expo.extra.eas.observe in app.json, alongside the
existing EAS projectId. Leave dispatchInDebug and sampleRate at their
defaults.

Log an app_started custom event once per cold start from the root
layout's existing "run once" startup effect, and install a global JS
error handler that logs xprem_js_crash and then always delegates to
whatever handler was previously registered, so existing Crashlytics
reporting is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:11:43 +01:00
jjbruntonandClaude Fable 5 6bc4e31719 chore(mobile): upgrade to Expo SDK 56
Bump expo, react, react-native, and all expo-* / react-native-* peers to
the SDK 56 line via expo install --fix, adding expo-splash-screen and
expo-system-ui for config plugins the SDK split out of core. Bump
@bacons/apple-targets, react-native-view-shot, and other native modules
to versions compatible with React Native 0.85 and the new architecture.

Remove @react-navigation/* direct dependencies and route their hooks
through expo-router, which is no longer compatible with react-navigation
as of SDK 56. Fix RN 0.85's removed StyleSheet.absoluteFillObject (use
absoluteFill) and react-native-view-shot 5.x's ViewShotRef type across
call sites. Downgrade eslint-plugin-react-hooks v7's new React Compiler
era rules to warnings pending a dedicated refactor, and repair Jest
fallout from the jest-expo/reanimated/worklets/expo-media-library bumps.

Regenerate the committed ios/ and android/ native projects with
`expo prebuild --clean` (required for @bacons/apple-targets 5.x) against
the production release channel, and document that requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:11:37 +01:00
jjbruntonandClaude Opus 5 aa062c9fd0 fix(mobile): make ota-publish actually run under a path with spaces
The entrypoint guard compared process.argv[1] against
new URL(import.meta.url).pathname. A URL pathname percent-encodes spaces, so
in any checkout whose absolute path contains one the comparison never matched
and main() never ran.

Every ota:preflight:*, ota:publish:*, and ota:doctor:* script then exited 0
having done absolutely nothing, which is indistinguishable from success. A
publish that silently no-ops is worse than one that fails: it reports a
shipped update that never left the machine.

Compare resolved filesystem paths instead.

Verified from a checkout whose path contains a space: before the change
`node scripts/ota-publish.mjs production --preflight` exited 0 with no
output; after it runs a real export for both platforms and prints the
preflight confirmation. Argument validation still rejects unknown targets and
unsupported EOAS arguments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:09:38 +01:00
jjbrunton 5947220fc6 feat(release): add mobile build preflight 2026-08-31 10:18:20 +01:00
jjbrunton 8bf93f372b docs(release): authorize scoped store uploads 2026-08-31 10:08:03 +01:00
jjbrunton 2f05e9ae87 Merge pull request #21 from jjbrunton/staging
chore(release): publish v1.0.7 metadata
2026-08-31 01:29:58 +01:00
jjbrunton 3fc6f8aa1a chore(ios): record v1.0.7 build 46 2026-08-31 01:20:45 +01:00
jjbrunton 02a7349899 chore(android): record v1.0.7 build 49 2026-08-31 00:37:41 +01:00
jjbrunton 8f70dbcc00 fix(mobile): include OTA certificate in builds 2026-08-31 00:16:39 +01:00
jjbrunton ac80d6b614 chore(release): prepare v1.0.7 2026-08-31 00:02:15 +01:00
jjbrunton 056bb62d04 Merge pull request #20 from jjbrunton/staging
release: promote v1.0.7
2026-08-30 23:52:58 +01:00
jjbrunton 80ec1663f9 merge: fix android release native inputs 2026-08-30 23:48:17 +01:00
jjbrunton 8ff05c050b fix(mobile): order android release native inputs
Materialize the Worklets CMake input expected by Reanimated and order\nPrefab producers before the app release CMake configuration.
2026-08-30 23:47:05 +01:00
jjbrunton 549419892d fix(ota): propagate profile env to EOAS 2026-08-30 22:07:38 +01:00
jjbrunton a46b8b95e6 Merge commit '267f3c3cf9e5acf6087a51bc4f81141f8285fb76' into staging 2026-08-30 21:42:44 +01:00
jjbrunton e54f6d31ed fix(admin): forward local Auth settings 2026-08-30 21:40:09 +01:00
jjbrunton 267f3c3cf9 fix(ota): use explicit Expo runtime version 2026-08-30 21:37:28 +01:00
jjbrunton 02aa6e4a75 security(web): upgrade Next to 15.5.24 2026-08-30 21:14:33 +01:00
jjbrunton cf19d23d8a fix(e2e): align browser harness with local stack 2026-08-30 21:00:12 +01:00
jjbrunton db5980c9bd fix(mobile): prepare v1.0.7 OTA release 2026-08-30 20:41:08 +01:00
jjbruntonandClaude Fable 5 2664791233 Merge self-hosted XPREM OTA updates wiring into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:19:07 +01:00
jjbruntonandClaude Fable 5 f37673cad7 feat(mobile): finalize XPREM OTA config with real app id and certificate
Server registration is complete: app id 9a66f3c2-fda7-416f-87e2-6db833b32e9d,
real code-signing certificate (public half), staging/production channels
mapped server-side. Drops the app-id placeholder guard (cert guard stays) and
re-includes certificate.pem past the root *.pem ignore rule, which had been
silently excluding it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:16:12 +01:00
jjbruntonandClaude Fable 5 0ada812294 docs(mobile): document the XPREM over-the-air update flow
Add docs/mobile-ota.md covering how OTA works here, the branch and channel
model, the appVersion runtime version policy and its store-build rule, the
eoas publish commands per channel, both rollback paths, the pending native
prebuild, and the exact placeholders the server deployment still has to fill.

Index it and cross-link it from the release guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:12:51 +01:00
jjbruntonandClaude Fable 5 f4db567267 feat(mobile): wire expo-updates to the self-hosted XPREM OTA server
Install expo-updates and the eoas CLI, and configure the client against
https://ota.apps.jbrunton.co.uk.

app.json stays the static source of truth for version, buildNumber, and
versionCode, which EAS rewrites during a release build. A new app.config.js
spreads it and layers on the updates block, because XPREM selects a release
channel from a build-time request header and that value differs per EAS
build profile.

- updates.url points at the XPREM manifest endpoint, /manifest
- runtimeVersion uses the appVersion policy, so the runtime version is the
  store-visible expo.version and a native change requires a store build
- expo-channel-name comes from RELEASE_CHANNEL, set per EAS build profile:
  development, staging (preview profile), and production
- expo-app-id and an empty xprem-branch header are declared at build time,
  since expo-updates only sends headers that existed when the build was made
- code signing uses keyid main and rsa-v1_5-sha256 against a certificate at
  certs/certificate.pem, skipped when DISABLE_CODE_SIGNING is set so a dev
  client can run against the packager

The XPREM app id and the code-signing certificate are still placeholders
pending the update server deployment. The config throws a descriptive error
whenever RELEASE_CHANNEL is set while either is unresolved, so a
misconfigured build fails at config resolution rather than shipping a client
that can never validate a manifest.

The native projects are Expo CNG output and are left untouched: the
expo-updates config plugin embeds the real app id and certificate contents,
so they are regenerated as a reviewed source change once both values land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:12:51 +01:00
jjbruntonandClaude Fable 5 d298a33656 feat(admin): proxy /v1 API calls through the local admin dev server
The API has no CORS handling, so local admin development serves API calls
same-origin and forwards them to the local API process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:12:23 +01:00
jjbruntonandClaude Fable 5 af4e1d0cf6 test(api): include profile settings migration in identity integration schema
The profile select now includes usage_reason, which 0005_profile_settings.sql adds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:09:29 +01:00
jjbruntonandClaude Fable 5 9e6e4612f5 fix(db): journal 0021_dare_proof and renumber solo sealed answers migration to 0022
The dare proof migration file landed on staging without a journal entry, so the
drizzle migrator would never apply it. Register it in the journal and move the
solo sealed answers migration to 0022 to keep filenames and journal order aligned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:52:58 +01:00
jjbrunton 7d392e756d Merge solo sealed answers that unlock on pairing into staging 2026-08-30 19:49:51 +01:00
jjbrunton 3c08963a70 Merge skippable onboarding avatar with first-match prompt and usage-reason pack picks into staging 2026-08-30 19:49:23 +01:00
jjbrunton d86d57d5c2 Merge branded question share card into staging 2026-08-30 19:38:16 +01:00
jjbruntonandClaude Fable 5 57501230cd feat(share): branded question share card with share analytics
Replace the inline ShareableCard in SharePreviewModal with a designed
QuestionShareCard: Sauci logo + wordmark header, pack badge, and a
branded sauci.app footer pill on the captured story image. The card
deliberately never carries the couple join code - it is a private
pairing credential, not a referral link (a regression test enforces
this). Text-share fallbacks now carry a full https://sauci.app link,
and all three share paths fire a new question_shared analytics event
with the destination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:38:09 +01:00
jjbruntonandClaude Fable 5 986d0b21cb Merge dare proof of completion into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:37:08 +01:00
jjbruntonandClaude Fable 5 504eddaf68 feat(dares): optional photo/audio proof of completion
The sender can require photo or audio proof when sending any dare,
catalogue or custom. The recipient captures and uploads the proof as a
new couple-scoped 'dare_proof' media kind and attaches it on submit;
the server enforces ownership, kind, couple, and MIME-type match.
The proof renders inline on the dare card for the sender to review
before confirming completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:36:11 +01:00
jjbrunton f52b8536be Merge admin portal coverage for quiz, dares loop, subscriptions, couples, and live draw into staging 2026-08-30 19:35:22 +01:00
jjbruntonandClaude Fable 5 09255da7db feat(pairing): solo sealed answers that unlock on pairing
Unpaired users could only reach a waiting-room dead end and could not answer
questions until a partner joined. Responses.couple_id is now nullable so a
solo user answers immediately and banks "sealed answers" (couple_id IS NULL).
Pairing claims both members' sealed answers and computes matches for every
question both have now answered. Invite copy and the unpaired push nudge
escalate with the sealed answer count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:34:42 +01:00
jjbrunton 8e83cc0d85 feat(admin): add support views for dares, quiz, subscriptions, couples, and live draw
Give support read-only visibility into purchase history, streak locality
fields, notification preferences, the dares loop, couples quiz sessions,
invite-code lookup, and recent live-draw activity, all backed by resources
already allow-listed in the admin repository.
2026-08-30 19:21:56 +01:00
jjbrunton 8955e49aae Merge remote-tracking branch 'origin/staging' into task/peaceful-grothendieck-95a3aa 2026-08-30 19:19:19 +01:00
jjbruntonandClaude Fable 5 04822043ae fix(share): attach captured card image when sharing to Instagram Stories
Both share modals opened the instagram-stories:// scheme without ever
placing the captured PNG on the pasteboard, so Instagram opened an empty
story. Replace the manual scheme launch with react-native-share
shareSingle(INSTAGRAM_STORIES), which writes the image to UIPasteboard
under com.instagram.sharedSticker.backgroundImage on iOS and uses the
ADD_TO_STORY intent on Android. Gate the flow on the required Facebook
App ID (EXPO_PUBLIC_FACEBOOK_APP_ID) and Instagram being installed, with
fallback to the system share sheet, and remove the dead base64 reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:19:01 +01:00
jjbrunton eab73e6314 feat(admin): manage quiz questions and dare intensity
Add a Quiz Questions content page (list/filter/create/edit/activate-
deactivate, no delete since quiz_answers reference questions by id),
route it under manage_questions, and add a sidebar entry.

Fix DaresPage so intensity (1-5) is editable and shown in the table;
previously every admin-created dare silently defaulted to intensity 1.

Add authored min/max intensity fields to DarePacksPage: no trigger or
worker in apps/api computes these columns, so they are admin-set
thresholds gating pack visibility, not derived aggregates. avg_intensity
is displayed read-only since nothing writes to it and it is not used
for filtering.

Extract dare intensity and quiz option validation into small lib
modules with focused unit tests.
2026-08-30 19:13:08 +01:00
jjbruntonandClaude Fable 5 fd8981fa28 feat(onboarding): skippable avatar with first-match prompt and usage-reason pack picks
Lets new users skip the onboarding avatar step and instead defers the ask
to a lightweight, dismissible prompt shown once after the couple's first
match. Also personalizes the home screen's first pack recommendation using
the usage_reason already collected during onboarding, which now round-trips
through GET /v1/me.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:12:56 +01:00
jjbrunton 25b7c6238e feat(admin-api): expose notification_preferences as read-only
Resolve the earlier open question by mapping the profile-settings
domain's notification toggles to the existing notification_preferences
table (primary key user_id, no sensitive columns). Read-only, gated on
view_users, with a test covering read access and rejected writes.
2026-08-30 19:04:54 +01:00
jjbrunton 77f2718fdd feat(admin-api): expose quiz, dares loop, and profile settings resources
Extend the admin allow-list so upcoming admin screens can reach quiz
content and activity, sent dares, and dare chat messages. Also fix a
schema drift where question_packs.scheduled_release_at and
release_notified existed in the database but were missing from the
drizzle TS schema, which could have let a future generate propose
dropping them.

Note: no profile_settings table exists in the schema. The
profile-settings domain reads and writes the existing profiles and
notification_preferences tables, so that entry was intentionally
omitted pending a product decision on which table it should map to.
2026-08-30 19:03:26 +01:00
jjbruntonandClaude Fable 5 f22d4a3649 Merge quiz question variety into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:22:10 +01:00
jjbruntonandClaude Fable 5 45d24911fb feat(quiz): variety in quiz question selection
Expand the seeded pool from 12 to 36 questions (migration 0020) and
select 10 per session preferring questions the couple has never seen,
then least recently used, with random tiebreaks, so repeat quizzes stop
serving the same set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:21:55 +01:00
jjbrunton f38b6f14fa chore(release): promote invite funnel join links to main
Promote invite funnel join links to main
2026-08-30 18:16:36 +01:00
jjbruntonandClaude Fable 5 74c1acfe2d Merge couples quiz with shareable results card into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:11:47 +01:00
jjbrunton 23db4ed951 Merge branch 'staging' into task/quiz-viral 2026-08-30 18:10:24 +01:00
jjbrunton ccd3f03437 Merge invite funnel join links into staging 2026-08-30 18:09:54 +01:00
jjbruntonandClaude Fable 5 6523ddeb1c feat(quiz): couples quiz with shareable watermarked results card
Replaces the notify-me quiz stub with a full loop: quiz sessions per
couple, answer-and-guess flow, dual-direction scoring, and a score-only
share card (no question or answer content) captured via ViewShot with
Sauci branding.

API: migration 0019 (quiz_questions, quiz_sessions, quiz_answers, 12
seeded questions), new quiz domain with session start / current /
answers / result routes, couple isolation, route + integration +
scoring unit tests.

Mobile: src/features/quiz feature (intro, per-question self+guess flow,
waiting-for-partner nudge, results with breakdown), quizStore with
sign-out clearing, QuizShareModal following SharePreviewModal, discovery
QuizTile, analytics events, and tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:09:19 +01:00
jjbruntonandClaude Fable 5 4f9950f21b feat(invite-funnel): set real Android app signing cert fingerprint
Replace the assetlinks.json placeholder with the Play App Signing key
SHA-256 certificate fingerprint so Android App Links for
https://sauci.app/join/* can verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:06:42 +01:00
jjbruntonandClaude Fable 5 d48b44baa8 feat(invite-funnel): add web join page and mobile deep-link hand-off
Adds a shareable https://sauci.app/join/{code} link as an alternative to
manually typing an 8-character invite code:

- apps/web: /join/[code] page that validates the code shape client-side,
  attempts the app.sauci:// scheme link, and falls back to a large code
  display with copy button plus App Store/Play Store links; PostHog events
  for page view, code copy, and store-button clicks.
- apps/web: apple-app-site-association and assetlinks.json under
  /.well-known so iOS Universal Links and Android App Links can verify
  sauci.app for /join/*. The Android release cert SHA-256 fingerprint is a
  placeholder pending the real value.
- apps/mobile: associated domains (iOS) and an intent filter (Android) for
  https://sauci.app/join/*, alongside the existing app.sauci scheme.
- apps/mobile: parse incoming join links, pre-fill and highlight the code on
  the pairing screen, stash the code for signed-out/mid-onboarding users and
  apply it once they reach pairing, and offer a once-per-session clipboard
  hand-off that is never auto-applied.
- apps/mobile: share message now leads with the join link, keeping the raw
  code visible as a fallback.
- Analytics: invite_link_opened, pairing_code_prefilled (mobile);
  join_page_viewed, join_page_code_copied, join_page_store_button_clicked
  (web).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:58:43 +01:00
jjbrunton 5564f9bbab Merge iOS CocoaPods lock sync into staging 2026-08-30 17:54:38 +01:00
jjbrunton c972b1c236 fix(ios): synchronize CocoaPods lock checksums 2026-08-30 17:54:30 +01:00
jjbrunton 30aa7bc386 Merge iOS CocoaPods UTF-8 fix into staging 2026-08-30 17:35:30 +01:00
jjbrunton 8bbb0e8cea fix(dev-local): set UTF-8 for native builds 2026-08-30 17:35:18 +01:00
jjbrunton fbdb2a8fff Merge task/agent-fast-lane into staging 2026-08-30 17:34:04 +01:00
jjbrunton bdfe8b7171 perf(agents): streamline Terra execution
Keep Sol responsible for scope, steering, and final acceptance while assigning one continuous discovery, implementation, testing, and iteration unit to Terra. Route additional reviewers and runtime verification by risk and avoid redundant verification passes.
2026-08-30 17:33:48 +01:00
jjbrunton 2cbe9e9c55 chore(release): promote photo picker fix
Promote the verified Android photo-picker policy fix from staging to main after target-branch and pull-request checks passed.
2026-08-30 17:31:02 +01:00
jjbrunton 8d9a3f4f1c Merge local stack single-command fix into staging 2026-08-30 17:26:21 +01:00
jjbrunton 476e4c845f fix(dev-local): harden one-command startup 2026-08-30 17:25:30 +01:00
jjbrunton 3d53d001b7 Merge task/android-photo-picker into main 2026-08-30 17:16:08 +01:00
jjbrunton 8658fc38fe fix(android): use system photo picker
Block broad image and video read permissions in Expo config and the checked-in Android manifest. Keep picker access scoped to user-selected media and gallery saves write-only. Add regression coverage and document the release-manifest invariant.
2026-08-30 17:15:57 +01:00
jjbrunton 24de9af4cd fix(web): add account deletion page (#16)
Add the public deletion resource and repair the monorepo web container build contract.
2026-08-30 16:23:30 +01:00
jjbrunton 5e581efe51 Merge web container fix into staging 2026-08-30 16:19:20 +01:00
jjbrunton 12c6b8bcc9 fix(web): build from monorepo context 2026-08-30 16:18:55 +01:00
jjbrunton 599be22eb1 Merge task/google-account-deletion into staging 2026-08-30 16:09:59 +01:00
jjbrunton 9514835bf2 fix(web): add account deletion page 2026-08-30 16:07:19 +01:00
jjbrunton 65bd1bbd6b chore(release): record iOS build 45
chore(release): record iOS build 45
2026-08-30 10:51:43 +01:00
jjbrunton e0813ba5a3 docs(release): verify iOS extension versions
docs(release): verify iOS extension versions
2026-08-30 10:43:45 +01:00
jjbrunton f7c686d5cf docs(release): verify iOS extension versions 2026-08-30 10:39:33 +01:00
jjbrunton 314664ecd6 chore(ios): record v1.0.6 build 45
chore(ios): record v1.0.6 build 45
2026-08-30 10:37:43 +01:00
jjbrunton 11f89b1345 chore(ios): record v1.0.6 build 45 2026-08-30 10:33:16 +01:00
jjbrunton 2481956c11 chore(agents): prohibit em dashes 2026-08-30 10:17:37 +01:00
jjbrunton 8804e121b6 chore(release): promote iOS compliance fix
chore(release): promote iOS compliance fix
2026-08-30 10:15:27 +01:00
jjbrunton 7b3de48dbd Merge pull request #11 from jjbrunton/task/ios-export-compliance
chore(ios): declare exempt encryption
2026-08-30 10:07:24 +01:00
jjbrunton 5ebf57bdbf Merge branch 'staging' into task/ios-export-compliance 2026-08-30 10:07:10 +01:00
jjbrunton 089e629021 docs(agents): delegate substantive repository tasks 2026-08-30 10:06:37 +01:00
jjbrunton 58b70710db chore(ios): declare exempt encryption 2026-08-30 09:58:26 +01:00
jjbrunton d645d83c47 chore(ios): record v1.0.6 build 44 2026-08-30 01:29:55 +01:00
jjbrunton 179f912170 chore(android): record v1.0.6 build 48 2026-08-30 01:05:13 +01:00
jjbrunton a604f82110 chore(release): prepare v1.0.6 2026-08-30 00:46:46 +01:00
jjbrunton ae8e72ddb5 Merge pull request #10 from jjbrunton/staging
chore(release): complete mobile release configuration
2026-08-30 00:41:09 +01:00
jjbrunton 2e4cddd1c8 fix(mobile): restore iOS SDK 54 runtime 2026-08-30 00:29:45 +01:00
jjbrunton 440f224095 docs(release): require upload confirmation 2026-08-30 00:28:14 +01:00
jjbrunton 22871d037c chore(release): configure store submissions
Route Android uploads to an internal draft and bind iOS submissions to the verified App Store Connect app. Document explicit-path local artifact submission and preserve separate release authorization.
2026-08-30 00:13:40 +01:00
jjbrunton fd38b303f0 Merge pull request #9 from jjbrunton/staging
fix(api): keep auth sync timestamps monotonic
2026-08-29 23:57:39 +01:00
jjbrunton 266233a473 fix(api): keep auth sync timestamps monotonic
Use PostgreSQL's clock for both the initial auth sync timestamp and later identity refreshes so host clock skew cannot move the marker backwards.
2026-08-29 23:45:39 +01:00
jjbrunton 387b242aea Merge pull request #8 from jjbrunton/staging
chore(release): promote staging for v1.0.6
2026-08-29 23:34:30 +01:00
jjbrunton 2bc69c2790 feat(mobile): surface pending questions
Add a conditional Home cue for partner-answered questions and route both the card and catch-up reminders into the pending answer flow. Track prompt engagement and cover hidden, visible, CTA, and notification behavior.
2026-08-29 23:24:19 +01:00
jjbrunton 9da44217f7 feat(release): add generation workflow
Add a guarded mobile release skill that synchronizes public versions, writes on-brand notes, verifies signed iOS and Android artifacts, and tags the verified release commit.
2026-08-29 23:22:35 +01:00
jjbrunton 5f77b06044 test(coverage): enforce API and app floors
Measure untouched mobile source, add regression coverage for native media paths and account operations, and enforce ratcheting API and mobile thresholds in required CI checks.
2026-08-29 22:50:07 +01:00
jjbrunton 5c973c4ac5 perf(api): bound worker pools and add telemetry
Share one configured PostgreSQL pool across worker repositories and make API and worker connection budgets explicit.

Add privacy-safe aggregate request, query, pool, sync, and outbox telemetry, a loopback-only representative load fixture, bounded worker shutdown, and focused regression and integration coverage.

Document the database-wide budget formula and defer pg_stat_statements activation to a reviewed migration and planned restart.
2026-08-29 22:43:27 +01:00
jjbrunton a13bbc3f65 docs(agents): require published completion 2026-08-29 21:59:50 +01:00
jjbrunton 10c4ae8d84 docs(agents): require isolated implementation 2026-08-29 21:58:13 +01:00
jjbrunton ac4a05d5ed chore(staging): reconcile main ancestry 2026-08-29 21:49:46 +01:00
jjbrunton c7ee8a4851 perf(app): reduce redundant data fetching
Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware.

Add focused regression coverage, client freshness documentation, and repository agent role configuration.
2026-08-29 21:47:27 +01:00
jjbrunton 9957fa43f2 feat(agents): add Claude delegation skill
Add a repository-owned Codex skill and helper for bounded Claude Code execution, model and effort selection, resumable follow-ups, and independent acceptance checks.
2026-08-29 18:29:29 +01:00
jjbrunton 502452c9e4 Merge pull request #4 from jjbrunton/staging
chore(release): promote staging to production
2026-08-29 00:12:03 +01:00
jjbrunton e6bb368bc8 Merge pull request #7 from jjbrunton/task/production-cutover-import
feat(api): harden production data import
2026-08-29 00:08:15 +01:00
jjbrunton aedba2a480 feat(api): harden production data import
Preserve live Dokploy state while importing legacy Supabase data. Add schema parity, safe relationship reconciliation, storage quarantine, and production-shaped integration coverage.
2026-08-29 00:04:11 +01:00
jjbrunton 78900a24fb ci(staging): enable direct verification
Allow agents to run required checks manually against an exact commit before pushing it directly to staging, while retaining protected status checks.
2026-08-28 20:41:18 +01:00
jjbrunton 03443fe898 chore(staging): integrate verified changes
Combine the Android API 36 release, dares loop, RevenueCat documentation correction, and daily engagement features. Resolve migration ordering and preserve both dares expiry and streak reminder operations.
2026-08-28 20:36:05 +01:00
jjbrunton b60c788362 feat(engagement): localize limits and streaks
Use reported user timezones for daily response limits, expose couple streak state and reminders, and improve swipe and live-drawing synchronization with focused tests and documentation.
2026-08-28 20:28:31 +01:00
jjbrunton 61ec3692b7 chore(android): target API 36 with Expo 54
Upgrade the mobile native toolchain and Expo dependencies for Android 16 compatibility while preserving web and admin runtime isolation.
2026-08-28 20:28:31 +01:00
jjbrunton 1482305810 Merge remote-tracking branch 'origin/staging' into task/android-api-36 2026-08-28 20:22:16 +01:00
jjbrunton 8b37569818 Merge pull request #5 from jjbrunton/feat/dares-loop
feat(dares): ship the dares loop to mobile
2026-08-28 20:18:11 +01:00
jjbrunton 2178750d07 chore(android): bump version code to 47 2026-08-28 20:17:19 +01:00
jjbrunton 028e21e54c Merge origin/staging (Expo 54 / Android API 36) into feat/dares-loop 2026-08-28 20:12:55 +01:00
jjbrunton b24a402b4c Merge pull request #3 from jjbrunton/task/android-api-36
chore(android): target API 36 with Expo 54
2026-08-28 20:09:20 +01:00
jjbruntonandClaude Opus 5 ce0b188776 feat(dares): ship the dares loop to mobile
Completes the half-built dares feature: send a dare, respond, report done,
confirm, and get a push notification on every transition.

Backend (apps/api):
- New `dares` domain with couple-shared premium entitlement, a free weekly
  send allowance, and server-enforced actor/state rules on every transition.
- `submitted` status so the recipient reports completion and the sender
  confirms, rather than the sender guessing.
- Notifications ride the existing operations outbox via a trigger on
  `sent_dares`. Push copy is generic; store compliance forbids dare text in a
  preview and a test asserts no payload leaks it.
- Worker expiry sweep now covers `pending` as well as `active`/`submitted`;
  an invitation nobody opened previously stayed open forever.

Fixes two latent defects:
- Deleting any dare that had been sent aborted with a check violation, because
  `dare_id` is ON DELETE SET NULL while `sent_dares_dare_or_custom_check`
  required it. Dare text is now snapshotted at send time, which also keeps a
  couple's history stable across catalogue edits.
- The free send allowance was raceable: concurrent requests all read the same
  count and every one succeeded. The count and insert are now serialized on
  the sender's profile row.

Also ports `content_status` review metadata to the API catalogue, which had no
store-safety gating at all, and mirrors the review decision already enforced on
the legacy catalogue.

Mobile: dares screen (active/send/history), send sheet with duration presets,
home tile badging incoming dares, paywall on both proactive and 402 paths.

Verified: lint, typecheck, 379 unit tests, build, 71 integration tests against
local PostgreSQL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:03:11 +01:00
jjbrunton f35114dc77 chore(android): target API 36 with Expo 54
Upgrade the mobile runtime and tracked Android project to compile and target API 36. Align Expo modules, React Native, Node, Gradle, and Android build tooling while preserving the custom widget sources.\n\nAdapt file-system, notification, animation, test, and Metro compatibility for the SDK 54 runtime.
2026-08-28 20:01:15 +01:00
jjbruntonandClaude Opus 5 aacb4f8731 docs(subscription): correct stale Android RevenueCat status
The Platform Support section claimed Android was "not yet implemented".
Android has full native SDK support: getApiKey() selects the Android key,
com.android.vending.BILLING is declared in app.json, the paywall has no
platform gating, and the webhook passes event.store through unchanged so
PLAY_STORE events need no separate handling.

Also flag the entitlement ID mismatch between the "pro" fallback in
revenuecat.ts and the "Sauci Pro" value set in eas.json. A build profile
missing that variable yields isProUser: false with no error, which
presents as churn rather than as misconfiguration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:26:53 +01:00
jjbrunton 1be54175e4 Merge pull request #2 from jjbrunton/staging
chore(release): promote mobile build hardening
2026-08-28 18:44:58 +01:00
jjbrunton a7470310ae Merge pull request #1 from jjbrunton/task/mobile-release-hardening
chore(release): harden mobile build workflow
2026-08-28 18:39:14 +01:00
jjbrunton 3acadeff7c chore(release): harden mobile build workflow
Promote the reproduced release lessons into harness enforcement: keep tracked native regeneration separate from artifact builds, separate Android build and upload authorization, use stable login readiness in Maestro, and retain the iOS 43 and Android 46 version increments.
2026-08-28 18:34:00 +01:00
jjbrunton 91544629fa fix(worker): keep polling process alive 2026-08-28 15:11:12 +01:00
jjbrunton 32f5214751 fix(backend): allow deferred provider integrations 2026-08-28 15:00:57 +01:00
jjbrunton 96882642c7 fix(ci): start healthcheck-free worker separately 2026-08-28 14:56:53 +01:00
jjbrunton a455325147 ci(deploy): establish staged Dokploy promotion 2026-08-28 14:54:12 +01:00
jjbrunton 2dad7aa352 feat(admin): serve response media from standalone API 2026-08-28 14:54:07 +01:00
jjbrunton be20391788 Merge remote-tracking branch 'origin/master' into task/self-hosted-backend
# Conflicts:
#	apps/mobile/app/(app)/onboarding.tsx
#	apps/mobile/src/__tests__/stores/packsStore.test.ts
#	apps/mobile/src/features/profile/hooks/useProfileSettings.ts
#	apps/mobile/src/store/packsStore.ts
2026-08-28 14:44:51 +01:00
jjbrunton 006761d3b0 feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
2026-08-28 14:42:11 +01:00
jjbrunton 7a0de45482 feat(mobile): align store-safe experience
Remove client-side adult-content preferences and badges so the centrally reviewed catalogue is the single visibility boundary.\n\nUse neutral relationship-focused copy across onboarding, discovery, paywall, and login surfaces, and retain biometric controls under App Lock for both native platforms.
2026-08-28 14:36:33 +01:00
jjbrunton 72e031eb84 fix(content): keep catalogue migration portable
Classify every environment through the immutable allowlist and archive unknown concurrent additions. Remove the remote-project dependency while retaining fail-closed postconditions.
2026-08-28 13:24:43 +01:00
jjbrunton 2ab976ec42 fix(content): scope production catalogue guard
Keep exact audited-count assertions for the production project while allowing local and non-production fixture catalogues to be classified by the same immutable allowlist.
2026-08-28 13:21:35 +01:00
jjbrunton c0650ed5b7 feat(content): enforce reviewed catalogue
Hide archived and unreviewed catalogue content from existing clients through RLS and guarded recommendation RPCs. Reject stale writes, preserve historical rows, and seed reviewed defaults when the cutover removes a couple's full active selection.
2026-08-28 13:15:33 +01:00
jjbrunton 94c76535a4 feat(content): add catalogue review metadata
Add reversible review states, audit history, and super-admin controls for catalogue entities without changing current client visibility.

Stage 30 high-risk production packs for archival review, cap generated content at non-explicit language, and preserve a checksummed pre-change production catalogue snapshot.
2026-08-28 11:32:55 +01:00
jjbrunton b43804d8fd fix(redemption): show custom validation error 2026-08-27 22:57:27 +01:00
jjbrunton 79862dac13 fix(redemption): restore profile email on signup 2026-08-27 22:48:57 +01:00
jjbrunton bf2ef59688 fix(supabase): allow public code redemption
Mark redeem-code as verify_jwt=false in local configuration, matching its intentional unauthenticated email-and-code flow and production deployment settings.
2026-08-27 22:35:54 +01:00
jjbrunton d869592a8b fix(tests): narrow redeemed profile
Assert that the profile lookup returned a row before reading premium status so Deno strict type checking can execute the Supabase integration suite.
2026-08-27 22:30:39 +01:00
jjbrunton d43ae1ef05 fix(database): enable pg_net extension
Install the extension required by notification triggers and cron jobs so fresh local and preview databases expose net.http_post before fixtures are seeded.
2026-08-27 22:26:09 +01:00
jjbrunton 024dc4d288 fix(database): defer cron URL resolution
Resolve each Edge Function URL when its cron job executes so fresh local databases can replay migrations before environment data is seeded. Missing configuration remains fail-closed instead of aborting database bootstrap.
2026-08-27 22:21:28 +01:00
jjbrunton 04dee69888 fix(ci): scope Terraform deployments
Skip Terraform for function-only Supabase releases to avoid unrelated provider drift failures. Continue applying infrastructure for terraform path changes or explicit manual deployment requests.
2026-08-27 22:08:08 +01:00
jjbrunton 650c81856f feat(admin): preview response media
Show who-likely choices in human terms and provide protected photo and audio previews in activity and user response views. Fetch media by response ID through an authenticated Edge Function that enforces the view_responses permission.
2026-08-27 22:02:06 +01:00
jjbrunton 7545289dd3 fix(admin): use npm workspace in Docker
Align admin production and development images with the repository's committed npm workspace and lockfile. This ensures shared and Expo-linked dependencies are installed before the admin build runs.
2026-08-27 21:49:42 +01:00
jjbrunton 6df9442cd0 fix(admin): support typed question responses
Render database-backed text, audio, photo, and choice responses across admin activity and user detail views. Add support for the both_answered match state while retaining legacy swipe behavior.
2026-08-27 21:37:34 +01:00
jjbrunton 01bb8225ae chore(harness): make repository agent-ready
Add concise routed context, task-aware agent roles, focused skills, and structured learning promotion.\n\nUnify local development and workspace tooling, add layered test and E2E verification, harden MCP authentication, and gate deployment behind verification and explicit production promotion.
2026-08-27 20:58:48 +01:00
jjbrunton 5a69bbc1a5 fix: trigger Discord notify on onboarding complete 2026-02-02 19:16:09 +00:00
jjbrunton 3afb091e90 feat: add Fastlane Google Play upload and fix onboarding re-renders
- Add Fastlane setup for uploading AAB to Google Play
- Chain upload into release:android npm script
- Fix onboarding re-renders from authStore subscriptions
- Reduce unnecessary authStore state updates in fetchUser
- Bump Android versionCode to 45
2026-02-02 19:00:15 +00:00
jjbrunton de0d667f4f fix: move migration to correct apps/supabase path 2026-02-01 17:22:54 +00:00
jjbrunton 458f18be03 fix: add admin RLS policy for live draw sessions 2026-02-01 17:19:13 +00:00
jjbrunton 785e73d6c0 Update 2026-02-01 17:01:09 +00:00
jjbruntonandClaude Opus 4.5 4c18afaeae feat: add live draw feature and clean up docs
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 14:35:16 +00:00
jjbrunton 520d0a8444 fix: handle missing cron jobs in migration unschedule
Use DO block with exception handling so cron.unschedule doesn't
fail when jobs were already removed (e.g. on non-prod).
2026-02-01 10:15:19 +00:00
jjbrunton 964fc25fc7 fix: use dynamic URLs for cron jobs to prevent cross-env firing
Non-prod cron jobs were hardcoding production URLs, causing double
notifications. Adds get_supabase_edge_function_url() helper that
resolves from app_config.supabase_url per environment. Also adds
text_answer question type to pack generation docs.
2026-02-01 10:12:30 +00:00
jjbrunton c8aaa8967d feat: add catchup and unpaired reminder notifications
Add automatic push notifications for behind partners (escalating: 24h then
every 3 days) and daily unpaired user reminders. Includes edge functions,
migrations, notification preference toggles, and UI improvements to pairing
and swipe screens.
2026-01-31 23:38:27 +00:00
jjbruntonandClaude Opus 4.5 8a44fef764 fix(admin): remove enabled packs filter from outstanding questions query
The couple_packs table has no admin RLS policy, so the enabled packs
query returns empty for admin users. Query all partner-answered
questions instead — more useful for admin visibility anyway.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:58:21 +00:00
jjbruntonandClaude Opus 4.5 edb2cedda0 feat(admin): add outstanding questions tab and gap status to user detail
Shows questions the partner has answered that this user hasn't yet,
with intensity levels and partner's answer. Adds outstanding count
badge and gap-blocked indicator to the profile header.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:52:21 +00:00
jjbruntonandClaude Opus 4.5 73d7c1a200 docs: add strong guardrails against MCP migrations in CLAUDE.md and AGENTS.md
Prevents agents from using apply_migration or DDL via execute_sql,
which breaks CI by creating remote migration entries without local files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:21:48 +00:00
jjbruntonandClaude Opus 4.5 307af2c1e9 fix(ci): restore --include-all after backfilling prod migration history
All 57 pre-existing migrations have been registered in prod's
schema_migrations table so db push --include-all will skip them
and only apply new migrations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:15:36 +00:00
jjbruntonandClaude Opus 4.5 057c682c6c fix(ci): remove --include-all from db push to prevent re-applying old migrations
Production only had one migration tracked in schema_migrations.
With --include-all, db push tried to re-run all 57 earlier migrations.
Without it, only migrations newer than the latest tracked version are applied.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:08:58 +00:00
jjbruntonandClaude Opus 4.5 e29183f104 ci: re-trigger deployment after fixing prod migration history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:08:21 +00:00
jjbruntonandClaude Opus 4.5 f07f80b529 fix(ci): add missing discord notification migration file
Migration 20260127202703 was applied to prod via MCP but had no local
file, causing supabase db push to fail with a version mismatch.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:05:19 +00:00
jjbruntonandClaude Opus 4.5 25db338861 feat: add weekly summary notifications
Adds Sunday weekly recap push notifications summarizing couple match
activity. Includes edge function, migration, notification preference
toggle, and mobile notification handling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 21:56:32 +00:00
jjbrunton e380c0a31c feat(admin): add NSFW flag to packs, remove intensity
Re-add is_explicit toggle on pack and dare pack forms labeled as
"NSFW". Show NSFW badge on pack cards and user enabled packs.

Remove all intensity/comfort level references from admin UI including
form fields, analytics charts, review dialog columns, AI generation
prompts/scoring, cherry-pick balancing, and settings toggles. The
database columns remain unchanged.
2026-01-27 22:50:46 +00:00
jjbrunton 14b0199625 feat(mobile): replace Sentry with Firebase Crashlytics
Migrate error tracking from Sentry to Firebase Crashlytics.
Bump version to 1.0.4, add Discord notification triggers.
2026-01-27 22:08:43 +00:00
jjbrunton 620369a7ce fix(android): use Animated.FlatList for matches scroll
Use Reanimated's built-in Animated.FlatList instead of manually wrapping
with createAnimatedComponent to fix Android crash where animated scroll
handler binds before the native node is registered.

Also includes v1.0.3 version bump and expo-sharing pin.
2026-01-27 20:19:28 +00:00
jjbrunton ce419fee8b chore(logs): update US-005 progress 2026-01-26 14:32:59 +00:00
jjbrunton 368399f463 fix(validation): satisfy quality gate checks
- add root test script that runs mobile jest
- adjust hook usage to satisfy rules-of-hooks
- document validation commands for operators
2026-01-26 14:32:23 +00:00
jjbrunton 1370576dd5 chore(ralph): log commit action 2026-01-26 14:27:35 +00:00
jjbrunton 6957502985 chore(ralph): update progress logs 2026-01-26 14:27:04 +00:00
jjbrunton 5a9defb1ee refactor(swipe): split useSwipeScreen helpers 2026-01-26 14:26:34 +00:00
jjbrunton 70449659cf chore(ralph): log US-003 progress 2026-01-26 14:21:40 +00:00
jjbrunton f302cb6c63 refactor(ui): split IntensitySlider pieces
Extract the slider track, levels, card, and modal into\nfocused subcomponents with shared styles/constants.\nPreserve the existing IntensitySlider API.
2026-01-26 14:20:41 +00:00
jjbrunton a01079a7c3 chore(ralph): update progress logs 2026-01-25 21:07:06 +00:00
jjbrunton 8d4d9d8750 refactor(swipe): split swipe screen modules
Move swipe screen logic into hooks/services and extract
state-specific UI components to keep files small while
preserving existing behavior.

Reference: US-002
2026-01-25 21:05:54 +00:00
jjbrunton 209dc652d2 chore(ralph): update progress log 2026-01-25 20:46:28 +00:00
jjbrunton 06dfc5b245 docs(inventory): add oversized file plan 2026-01-25 20:45:46 +00:00
856 changed files with 140286 additions and 55912 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
---
id: YYYY-MM-DD-short-slug
date: YYYY-MM-DD
status: inbox
scope: path or workflow
evidence: command, test, or artifact
review_after: YYYY-MM-DD
---
## Symptom
Observable failure or friction.
## Reproduction
Exact bounded reproduction.
## Cause
Confirmed cause, not a hypothesis.
## Remediation
Proven correction.
## Evidence
Read-back, test, or other verification.
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,34 @@
---
id: 2026-08-27-migration-context-conflict
date: 2026-08-27
status: promoted
scope: database migration instructions
evidence: root and scoped agent context comparison
destination: AGENTS.md and scripts/lint-harness.mjs
review_after: 2027-02-27
---
## Symptom
Root instructions prohibited remote migration application while scoped Supabase
instructions authorized it.
## Reproduction
Compare the former root `AGENTS.md` migration rule with the former
`apps/supabase/agents.md` migration section.
## Cause
Runtime-specific instruction files evolved independently without an executable
consistency check.
## Remediation
Make root context authoritative, scope only additional local rules, and reject
positive `apply_migration` instructions in the harness lint.
## Evidence
`npm run lint:harness` checks instruction consistency and the authoritative
migration location.
@@ -0,0 +1,37 @@
---
id: 2026-08-28-maestro-stable-login-readiness
date: 2026-08-28
status: promoted
scope: native Auth-shell acceptance flows
evidence: iOS simulator login-shell acceptance on current main
destination: e2e/maestro
review_after: 2027-02-28
---
## Symptom
The credential-free native smoke failed even though the current app reached the
login screen and displayed its required controls.
## Reproduction
Run `e2e/maestro/login-screen-smoke.yaml` against current main. The flow waited
for the retired tagline `Explore intimacy together`; the app displayed updated
marketing copy while `Sauci` and `Password` were present.
## Cause
Login readiness was coupled to mutable marketing copy rather than a stable
required control.
## Remediation
Wait for the password control that defines login readiness, while retaining the
brand and password assertions. Apply the same readiness condition to the
authenticated and credential-free flows.
## Evidence
Simulator read-back confirmed the current login shell exposes `Sauci` and
`Password`. The maintained Maestro flows now wait for that stable control rather
than the tagline.
@@ -0,0 +1,40 @@
---
id: 2026-08-28-mobile-release-source-integrity
date: 2026-08-28
status: promoted
scope: mobile release build and upload workflow
evidence: clean-source local EAS builds for iOS 43 and Android 46
destination: scripts/lint-harness.mjs
review_after: 2027-02-28
---
## Symptom
A build-only request initially regenerated tracked native projects. The generated
Android widget provider replaced the checked-in implementation, and the Android
release script would also have uploaded after building.
## Reproduction
Run the former mobile release scripts or run `expo prebuild` before a local EAS
build, then inspect `git diff`. The reproduced diff included the Android widget
provider, widget XML, and Xcode project rather than version metadata alone.
## Cause
The release scripts combined three independently reviewed operations: native
regeneration, artifact creation, and store upload. Sauci already tracks native
projects, and EAS skips prebuild when those directories exist.
## Remediation
Keep native regeneration explicit, make release scripts artifact-only, expose
upload separately, and lint the scripts so those responsibilities cannot be
silently recombined.
## Evidence
Corrected local EAS logs reported `PREBUILD` skipped. Both production artifacts
built successfully from the clean tracked sources; the post-build worktree
contained only the four expected build-number files. `npm run lint:harness`
enforces the release-script boundary.
@@ -0,0 +1,43 @@
---
id: 2026-08-30-ios-extension-version-verification
date: 2026-08-30
status: promoted
scope: iOS release artifact verification
evidence: v1.0.6 exported IPA inspection and reproduced v1.0.7 Xcode build-setting mismatch
destination: apps/mobile/scripts/release-preflight.mjs and .codex/skills/release-mobile/SKILL.md
review_after: 2027-02-28
---
## Symptom
The signed iOS build logged that the widget extension build number 44 did not
match the containing app build number 45, even though export completed.
## Reproduction
Build iOS version 1.0.6 with EAS auto-increment from build 44 to 45 and retain the
archive log. Extract the resulting IPA with `ditto`, then read the containing app
and every packaged extension `Info.plist` with `PlistBuddy`.
## Cause
The v1.0.6 archive warning described an intermediate state and was not
authoritative for that exported package. The later v1.0.7 reproduction found a
separate real hazard: `CURRENT_PROJECT_VERSION` in both LiveDrawWidget
configurations can override the widget plist and package an older build number.
## Remediation
Before every iOS release, require public-version parity across app config, both
plists, and Xcode marketing settings. Require the widget Debug and Release
`CURRENT_PROJECT_VERSION` values to equal both plists. Then inspect the exported
app and all packaged extensions. Reject or rebuild only when the final IPA values
differ; do not accept or reject a release from archive warnings alone.
## Evidence
`PlistBuddy` read `CFBundleShortVersionString` 1.0.6 and `CFBundleVersion` 45 from
both v1.0.6 packaged plists. The v1.0.7 build later proved a stale widget Xcode
setting can survive plist-only review. `release-preflight.mjs` and its node test
now reject that mismatch before EAS allocation; exported IPA inspection remains
the final authority.
@@ -0,0 +1,66 @@
---
id: 2026-08-31-expo-prebuild-native-regression
date: 2026-08-31
status: promoted
scope: apps/mobile native projects (ios/, android/) and expo prebuild
evidence: v1.0.8 SDK 54 to 56 upgrade, commits 604eec3, 7aca39a, 6bd04a6, 79233f5, b8cb2add
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, and docs/releasing.md
review_after: 2026-11-30
---
## Symptom
`expo prebuild --clean` during the SDK 54 to 56 upgrade (and the
@bacons/apple-targets 0.1 to 5.0 bump) silently regressed four checked-in
native invariants. Each regression was caught only by a release gate or by
manual diffing against the last shipped tag (3fc6f8a, v1.0.7), not by
prebuild itself.
## Reproduction
Run `expo prebuild --clean` in apps/mobile against the upgraded SDK and diff
the regenerated ios/ and android/ projects against the previously committed
projects.
## Cause
1. Widget identity: apple-targets 5.x derives PRODUCT_BUNDLE_IDENTIFIER from
the target folder name, changing com.sauci.app.LiveDrawWidget to
com.sauci.app.widget, and repointed CODE_SIGN_ENTITLEMENTS to the
gitignored ios/.targets/LiveDrawWidget/generated.entitlements.
2. Android manifest lost the tools:node="remove" attributes on
READ_MEDIA_IMAGES and READ_MEDIA_VIDEO, so the built AAB regained
Play-forbidden permissions (verified via bundletool dump on the rejected
build 50 AAB).
3. iOS Info.plist gained dev-launcher-only keys NSBonjourServices
(_expo._tcp), NSLocalNetworkUsageDescription, and RCTMetroPort.
4. android/gradle.properties reset to stock -Xmx2048m
-XX:MaxMetaspaceSize=512m, causing Metaspace OOM in kspReleaseKotlin/CMake
under RN 0.85.
Separately, a package absent from the new SDK's bundledNativeModules
(expo-av) has no compatible release and fails the iOS archive (EXAV.h
imports removed ExpoModulesCore headers).
## Remediation
Fixed by restoring widget identity and tracking
targets/widget/generated.entitlements with a pinned bundleIdentifier
(604eec3); restoring manifest tools:node="remove" attributes (7aca39a);
removing the dev-launcher-only Info.plist keys (6bd04a6); raising
gradle.properties memory to 4096m/1024m (79233f5); and migrating expo-av to
expo-video/expo-audio (b8cb2add).
Extend apps/mobile/scripts/release-preflight.mjs with executable checks for
items 2 and 3 (item 1 is already covered by the existing LiveDrawWidget
preflight check; item 4 hard-fails the build by itself). Add an "After
native regeneration" checklist to docs/releasing.md.
## Evidence
`node apps/mobile/scripts/release-preflight.mjs` fails if the tracked
AndroidManifest.xml lacks tools:node="remove" on READ_MEDIA_IMAGES or
READ_MEDIA_VIDEO, or if ios/Sauci/Info.plist contains NSBonjourServices,
NSLocalNetworkUsageDescription, or RCTMetroPort.
`node --test apps/mobile/scripts/release-preflight.test.mjs` covers the new
checks.
@@ -0,0 +1,55 @@
---
id: 2026-08-31-local-eas-build-preconditions
date: 2026-08-31
status: promoted
scope: apps/mobile local EAS release builds
evidence: v1.0.8 local EAS build session, commit d8bedea
destination: docs/releasing.md
review_after: 2026-11-30
---
## Symptom
Local EAS release builds failed before native compilation for environment
reasons unrelated to the app source, and a successful iOS build left the
tracked source in a state that fails release-preflight afterward.
## Reproduction
Run `eas build --local` from a non-interactive shell without first exporting
ANDROID_HOME/ANDROID_SDK_ROOT/JAVA_HOME: fails with "SDK location not
found" because non-interactive shells do not load the user profile. Run it
with a non-empty EAS_LOCAL_BUILD_WORKINGDIR: fails with "Workingdir is not
empty". Run it outside apps/mobile: eas cannot find the project. Pipe the
build log through `tail`: the full log needed for diagnosis is lost. After a
successful iOS build, EAS auto-increment updates both Info.plists but not
the widget target's CURRENT_PROJECT_VERSION in the pbxproj, so
release-preflight fails until it is synced.
## Cause
`eas` must be installed as a global CLI (scripts call bare `eas`; eas-cli
23.0.0 verified). Non-interactive shells used for automation do not source
the profile that normally exports the Android/Java environment variables.
EAS_LOCAL_BUILD_WORKINGDIR must not exist or be empty. EAS iOS
auto-increment writes plist versions but does not touch the widget target's
pbxproj CURRENT_PROJECT_VERSION, so it drifts from the app's build number by
one increment after every successful build.
## Remediation
Before a local EAS build: verify `eas` is on PATH, explicitly export
ANDROID_HOME, ANDROID_SDK_ROOT, and JAVA_HOME (JDK 17), ensure
EAS_LOCAL_BUILD_WORKINGDIR does not exist or is empty, and run from
apps/mobile. Capture the full build log to a file rather than piping through
`tail`. After a successful build, sync the widget's CURRENT_PROJECT_VERSION
to the new build number (commit d8bedea, sed 46 to 47) and sync
ios/Podfile.lock from the successful build's working directory when
dependencies changed, since the archive's pod install resolution is
authoritative and local CocoaPods here is broken.
## Evidence
docs/releasing.md's local-build section documents the environment
preconditions and the post-build widget CURRENT_PROJECT_VERSION and
Podfile.lock sync steps; `npm run release:preflight` passes after the sync.
@@ -0,0 +1,49 @@
---
id: 2026-08-31-mobile-release-preflight-and-attempts
date: 2026-08-31
status: promoted
scope: mobile production release preparation and local EAS builds
evidence: v1.0.7 Android and iOS local EAS release investigation and verified artifacts
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, docs/releasing.md, and release skills
review_after: 2027-02-28
---
## Symptom
The v1.0.7 release spent repeated attempts on archive, plugin startup, package
installation, disk-space, signing, and iOS extension-version failures before
valid Android and iOS artifacts were accepted.
## Reproduction
Run a local production EAS build with an ignored public OTA certificate, a
package-directory plugin path, insufficient disk space, or divergent widget
`CURRENT_PROJECT_VERSION`. Each condition fails or produces an invalid input
before a valid final artifact can be accepted.
## Cause
Pre-compilation setup failures were treated as rebuild failures. Source parity,
archive inclusion, local EAS plugin shape, disk capacity, and effective iOS widget
build settings were not checked together before EAS allocation.
## Remediation
Run the deterministic mobile release preflight before every production build.
Classify attempts before retrying: archive, plugin, npm, and ENOSPC failures do
not consume a native build; signing, profile, and capability failures require
provider read-back before one replacement build. Retain work directories and logs
at stable ignored paths. Accept only independently inspected artifacts and keep
rejected artifacts out of the upload path.
The Android native input failure was a Gradle task ordering issue: Prefab inputs
must be available before dependent native tasks. Preserve the explicit ordering
fix rather than removing Firebase or changing dependency versions without proof.
## Evidence
The preflight node tests reject an ignored OTA certificate and an effective widget
build-setting mismatch. The checked-in source invariants pass the preflight. The
maintained release guide and skills record local EAS path requirements, provider
read-back, phase-specific status language, artifact inspection, store read-back,
and scoped upload authorization boundaries.
@@ -0,0 +1,55 @@
---
id: 2026-08-31-npm-lockfile-divergence
date: 2026-08-31
status: promoted
scope: root package.json, package-lock.json, apps/web dependencies, CI
evidence: v1.0.8 lockfile investigation, commits 90cde4a, 9609a90
destination: docs/development/dependencies.md
review_after: 2026-11-30
---
## Symptom
CI and the API Docker image (node 20.19.4/npm 10.8.2, per .nvmrc) broke
invisibly while a lockfile regenerated on this dev machine (node 26/npm
11.19) passed local `npm ci`. Pushes to staging bypass required status
checks, so the red CI ran unnoticed for hours.
## Reproduction
Regenerate package-lock.json with npm 11 on a wildcard-range dependency
(react-native-worklets depends on `@react-native/metro-config: "*"`), then
run `npm ci` under node 20.19.4/npm 10.8.2. npm 10 requires 0.85.3 at that
tree position; npm 11 resolves/hoists 0.87.x there. `npm ci` fails with
"Invalid: lock file's ... does not satisfy". The reverse direction also
fails: a lock regenerated by npm 10 fails `npm ci` under npm 11 with missing
platform-optional binaries (@next/swc-linux-x64-gnu breaking `next build`,
@rollup/rollup-linux-x64-gnu breaking the API Docker tsup build, lightningcss
variants breaking local npm ci), matching the npm/cli#4828-style
optional-dependency resolution gap.
## Cause
Two different npm major versions in active use (CI/Docker on npm 10, this
dev machine on npm 11) resolve wildcard/range dependencies to different
versions at the same tree position, and each npm's own platform-optional
binary selection is not portable to the other npm's lockfile.
## Remediation
Pin the wildcard-range dependency via a root package.json override
(`"@react-native/metro-config": "0.85.3"`, alongside the existing
rollup/react-native/expo pins). Pin apps/web `@next/swc` linux/darwin
binaries as direct optionalDependencies (must be bumped together with any
`next` version bump). Regenerate the lock and validate it both ways: local
`npm ci` plus `npm run verify:fast`, and
`docker run --platform linux/amd64 node:20.19.4 ... npm ci` (the
`--platform` flag is mandatory; Apple Silicon Docker otherwise runs arm64
and the platform-binary check is meaningless). After any push that bypassed
required status checks, check `gh run list --branch staging`.
## Evidence
commits 90cde4a and 9609a90 add the override and optionalDependencies pins
and record a lockfile that passes both the amd64-emulated `npm ci` under
node 20.19.4 and local `npm ci` plus `npm run verify:fast`.
@@ -0,0 +1,40 @@
---
id: 2026-09-05-question-type-contract-drift
date: 2026-09-05
status: promoted
scope: question interaction contract documentation
evidence: npm run lint:harness and npm run learn:check
destination: docs/question-types.md, scripts/lint-harness.mjs, AGENTS.md, and .codex/skills/question-pack/SKILL.md
review_after: 2026-12-05
---
## Symptom
Maintained documentation did not identify the implemented question interaction
types, their response payloads, or their matching semantics. The only detailed
reference was a historical implementation plan, so content and product work
could not rely on maintained documentation.
## Reproduction
Compare `QuestionType` in `packages/shared/src/types/index.ts` and the question
renderer and answer matching code with `docs/index.md`, `docs/schema.md`, and
`docs/question-selection.md`.
## Cause
The interaction contract was added to code without a maintained documentation
owner or a verification check linking the document to the canonical union.
## Remediation
Create `docs/question-types.md` as the maintained interaction contract. Require
agents and the question-pack workflow to update it with type or semantic
changes. Add a harness invariant that compares its type marker to the shared
`QuestionType` union.
## Evidence
`npm run lint:harness` reads the shared union and the documentation marker and
fails when their ordered type lists differ. `npm run learn:check` validates this
promoted learning record and its destination metadata.
+129
View File
@@ -0,0 +1,129 @@
# Build
You are an autonomous coding agent. Your task is to complete the work for exactly one story and record the outcome.
## Paths
- PRD: {{PRD_PATH}}
- AGENTS (optional): {{AGENTS_PATH}}
- Progress Log: {{PROGRESS_PATH}}
- Guardrails: {{GUARDRAILS_PATH}}
- Guardrails Reference: {{GUARDRAILS_REF}}
- Context Reference: {{CONTEXT_REF}}
- Errors Log: {{ERRORS_LOG_PATH}}
- Activity Log: {{ACTIVITY_LOG_PATH}}
- Activity Logger: {{ACTIVITY_CMD}}
- No-commit: {{NO_COMMIT}}
- Repo Root: {{REPO_ROOT}}
- Run ID: {{RUN_ID}}
- Iteration: {{ITERATION}}
- Run Log: {{RUN_LOG_PATH}}
- Run Summary: {{RUN_META_PATH}}
## Global Quality Gates (apply to every story)
{{QUALITY_GATES}}
## Selected Story (Do not change scope)
ID: {{STORY_ID}}
Title: {{STORY_TITLE}}
Story details:
{{STORY_BLOCK}}
If the story details are empty or missing, STOP and report that the PRD story format could not be parsed.
## Rules (Non-Negotiable)
- Implement **only** the work required to complete the selected story.
- Complete all tasks associated with this story (and only this story).
- Do NOT ask the user questions.
- Do NOT change unrelated code.
- Do NOT assume something is unimplemented — confirm by reading code.
- Implement completely; no placeholders or stubs.
- If No-commit is true, do NOT commit or push changes.
- Do NOT edit the PRD JSON (status is handled by the loop).
- Commit only task-owned source, tests, and maintained documentation. Runtime logs
and evidence remain ignored.
- Before committing, perform a final **security**, **performance**, and **regression** review of your changes.
## Your Task (Do this in order)
1. Read {{GUARDRAILS_PATH}} before any code changes.
2. Read {{ERRORS_LOG_PATH}} for repeated failures to avoid.
3. Read {{PRD_PATH}} for global context (do not edit).
4. Fully audit and read all necessary files to understand the task end-to-end before implementing. Do not assume missing functionality.
5. If {{AGENTS_PATH}} exists, follow its build/test instructions.
6. Implement only the tasks that belong to {{STORY_ID}}.
7. Run verification commands listed in the story, the global quality gates, and in {{AGENTS_PATH}} (if required).
8. If the project has a build or dev workflow, run what applies:
- Build step (e.g., `npm run build`) if defined.
- Dev server (e.g., `npm run dev`, `wrangler dev`) if it is the normal validation path.
- Confirm no runtime/build errors in the console.
9. Perform a brief audit before committing:
- **Security:** check for obvious vulnerabilities or unsafe handling introduced by your changes.
- **Performance:** check for avoidable regressions (extra queries, heavy loops, unnecessary re-renders).
- **Regression:** verify existing behavior that could be impacted still works.
10. If No-commit is false, commit changes using the `$commit` skill.
- Stage only the files owned by this story; never blanket-stage unrelated work.
- Confirm a clean working tree after commit: `git status --porcelain` should be empty.
- After committing, capture the commit hash and subject using:
`git show -s --format="%h %s" HEAD`.
11. Append a progress entry to {{PROGRESS_PATH}} with run/commit/test details (format below).
If No-commit is true, skip committing and note it in the progress entry.
## Progress Entry Format (Append Only)
```
## [Date/Time] - {{STORY_ID}}: {{STORY_TITLE}}
Thread: [codex exec session id if available, otherwise leave blank]
Run: {{RUN_ID}} (iteration {{ITERATION}})
Run log: {{RUN_LOG_PATH}}
Run summary: {{RUN_META_PATH}}
- Guardrails reviewed: yes
- No-commit run: {{NO_COMMIT}}
- Commit: <hash> <subject> (or `none` + reason)
- Post-commit status: `clean` or list remaining files
- Verification:
- Command: <exact command> -> PASS/FAIL
- Command: <exact command> -> PASS/FAIL
- Files changed:
- <file path>
- <file path>
- What was implemented
- **Learnings for future iterations:**
- Patterns discovered
- Gotchas encountered
- Useful context
---
```
## Completion Signal
Only output the completion signal when the **selected story** is fully complete and verified.
When the selected story is complete, output:
<promise>COMPLETE</promise>
Otherwise, end normally without the signal.
## Additional Guardrails
- When authoring documentation, capture the why (tests + implementation intent).
- If you learn how to run/build/test the project, update {{AGENTS_PATH}} briefly (operational only).
- Keep AGENTS operational only; progress notes belong in {{PROGRESS_PATH}}.
- If you reproduce a reusable failure, record it using
`.agents/learnings/TEMPLATE.md`. Promote it only after review into a maintained
doc, skill, fixture, or executable invariant; do not add speculative Signs.
## Activity Logging (Required)
Log major actions to {{ACTIVITY_LOG_PATH}} using the helper:
```
{{ACTIVITY_CMD}} "message"
```
Log at least:
- Start of work on the story
- After major code changes
- After tests/verification
- After updating progress log
## Browser Testing (Required for Frontend Stories)
If the selected story changes UI, you MUST verify it in the browser:
1. Load the `dev-browser` skill.
2. Navigate to the relevant page.
3. Verify the UI changes work as expected.
4. Take a screenshot if helpful for the progress log.
A frontend story is NOT complete until browser verification passes.
+190
View File
@@ -0,0 +1,190 @@
# Ralph
![Ralph](ralph.webp)
Ralph is a minimal, file‑based agent loop for autonomous coding. Each iteration starts fresh, reads the same on‑disk state, and commits work for one story at a time.
## How it works
Ralph treats **files and git** as memory, not the model context:
- **PRD (JSON)** defines stories, gates, and status
- **Loop** executes one story per iteration
- **State** persists in `.ralph/`
![Ralph architecture](diagram.svg)
## Global CLI (recommended)
Install and run Ralph from anywhere:
```bash
npm i -g @iannuttall/ralph
ralph prd # launches an interactive prompt
ralph build 1 # one Ralph run
```
### Template hierarchy
Ralph will look for templates in this order:
1. `.agents/ralph/` in the current project (if present)
2. Bundled defaults shipped with this repo
State and logs always go to `.ralph/` in the project.
### Install templates into a project (optional overrides)
```bash
ralph install
```
This creates `.agents/ralph/` in the current repo so you can customize prompts and loop behavior. During install, you’ll be asked if you want to add the required skills.
### Install required skills (optional)
```bash
ralph install --skills
```
You’ll be prompted for agent (codex/claude/droid/opencode) and local vs global install. Skills installed: **commit**, **dev-browser**, **prd**.
If you skipped skills during `ralph install`, you can run `ralph install --skills` anytime.
## Quick start (project)
1) Create your PRD (JSON) or generate one:
```
ralph prd
```
Requires the **prd** skill (install via `ralph install --skills`).
Example prompt text:
```
A lightweight uptime monitor (Hono app), deployed on Cloudflare, with email alerts via AWS SES
```
Default output (agent chooses a short filename in `.agents/tasks/`):
```
.agents/tasks/prd-<short>.json
```
2) Run one build iteration:
```
ralph build 1 # one Ralph run
```
No‑commit dry run:
```
ralph build 1 --no-commit # one Ralph run
```
Override PRD output for `ralph prd`:
```
ralph prd --out .agents/tasks/prd-api.json
```
Optional human overview (generated from JSON):
```
ralph overview
```
This writes a tiny overview alongside the PRD: `prd-<slug>.overview.md`.
PRD story status fields are updated automatically by the loop:
- `open` → selectable
- `in_progress` → locked by a running loop (with `startedAt`)
- `done` → completed (with `completedAt`)
If a loop crashes and a story stays `in_progress`, you can set `STALE_SECONDS` in `.agents/ralph/config.sh` to allow Ralph to automatically reopen stalled stories.
## Override PRD paths
You can point Ralph at a different PRD JSON file via CLI flags:
```bash
ralph build 1 --prd .agents/tasks/prd-api.json # one Ralph run
```
Optional progress override:
```bash
ralph build 1 --progress .ralph/progress-api.md # one Ralph run
```
If multiple PRD JSON files exist in `.agents/tasks/` and you omit `--prd`, Ralph will prompt you to choose.
Optional config file (if you installed templates):
```
.agents/ralph/config.sh
```
## Choose the agent runner
Set `AGENT_CMD` in `.agents/ralph/config.sh` to switch agents:
```
AGENT_CMD="codex exec --yolo -"
AGENT_CMD="claude -p --dangerously-skip-permissions \"\$(cat {prompt})\""
AGENT_CMD="droid exec --skip-permissions-unsafe -f {prompt}"
AGENT_CMD="opencode run \"$(cat {prompt})\""
```
Or override per run:
```
ralph prd --agent=codex
ralph build 1 --agent=codex # one Ralph run
ralph build 1 --agent=claude # one Ralph run
ralph build 1 --agent=droid # one Ralph run
ralph build 1 --agent=opencode # one Ralph run
```
If the CLI isn’t installed, Ralph prints install hints:
```
codex -> npm i -g @openai/codex
claude -> curl -fsSL https://claude.ai/install.sh | bash
droid -> curl -fsSL https://app.factory.ai/cli | sh
opencode -> curl -fsSL https://opencode.ai/install.sh | bash
```
## State files (.ralph/)
- `progress.md` — append‑only progress log
- `guardrails.md` — “Signs” (lessons learned)
- `activity.log` — activity + timing log
- `errors.log` — repeated failures and notes
- `runs/` — raw run logs + summaries
## Notes
- `.agents/ralph` is portable and can be copied between repos.
- `.ralph` is per‑project state.
- Use `{prompt}` in `AGENT_CMD` when agent needs a file path instead of stdin.
- Examples: see `examples/commands.md`.
- **OpenCode server mode**: For faster performance with OpenCode, run `opencode serve` in a separate terminal and uncomment the `AGENT_OPENCODE_CMD` lines in `.agents/ralph/agents.sh` to use `--attach http://localhost:4096`. This avoids cold boot on every run.
## Tests
Dry-run smoke tests (no agent required):
```bash
npm test
```
Fast agent health check (real agent call, minimal output):
```bash
npm run test:ping
```
Optional integration test (requires agents installed):
```bash
RALPH_INTEGRATION=1 npm test
```
Full real-agent loop test:
```bash
npm run test:real
```
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Default agent command templates (used by loop.sh and CLI).
AGENT_CODEX_CMD="codex exec --yolo --skip-git-repo-check -"
AGENT_CODEX_INTERACTIVE_CMD="codex --yolo {prompt}"
AGENT_CLAUDE_CMD="claude -p --dangerously-skip-permissions \"\$(cat {prompt})\""
AGENT_CLAUDE_INTERACTIVE_CMD="claude --dangerously-skip-permissions {prompt}"
AGENT_DROID_CMD="droid exec --skip-permissions-unsafe -f {prompt}"
AGENT_DROID_INTERACTIVE_CMD="droid --skip-permissions-unsafe {prompt}"
AGENT_OPENCODE_CMD="opencode run \"\$(cat {prompt})\""
AGENT_OPENCODE_INTERACTIVE_CMD="opencode --prompt {prompt}"
# Uncomment to use server mode (faster, avoids cold boot):
# AGENT_OPENCODE_CMD="opencode run --attach http://localhost:4096 \"\$(cat {prompt})\""
# AGENT_OPENCODE_INTERACTIVE_CMD="opencode --prompt {prompt} --attach http://localhost:4096"
DEFAULT_AGENT="codex"
+25
View File
@@ -0,0 +1,25 @@
# Optional Ralph config overrides.
# All paths are relative to repo root unless absolute.
# Uncomment and edit as needed.
# PRD_PATH=".agents/tasks/prd.json"
# PROGRESS_PATH=".ralph/progress.md"
# GUARDRAILS_PATH=".ralph/guardrails.md"
# ERRORS_LOG_PATH=".ralph/errors.log"
# ACTIVITY_LOG_PATH=".ralph/activity.log"
# TMP_DIR=".ralph/.tmp"
# RUNS_DIR=".ralph/runs"
# GUARDRAILS_REF=".agents/ralph/references/GUARDRAILS.md"
# CONTEXT_REF=".agents/ralph/references/CONTEXT_ENGINEERING.md"
# ACTIVITY_CMD=".agents/ralph/log-activity.sh"
# AGENT_CMD defaults are defined in agents.sh. Override here if needed.
# AGENT_CMD="codex exec --yolo --skip-git-repo-check -"
# PRD_AGENT_CMD defaults are defined in agents.sh (interactive).
# PRD_AGENT_CMD="codex --yolo --skip-git-repo-check {prompt}"
# AGENT_CMD="claude -p --dangerously-skip-permissions \"\$(cat {prompt})\""
# AGENT_CMD="droid exec --skip-permissions-unsafe -f {prompt}"
# AGENTS_PATH="AGENTS.md"
# PROMPT_BUILD=".agents/ralph/PROMPT_build.md"
# NO_COMMIT=false
# MAX_ITERATIONS=25
# STALE_SECONDS=0
+55
View File
@@ -0,0 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 520" role="img" aria-label="Ralph architecture diagram">
<style>
.bg { fill: #0f1218; }
.box { fill: #141a23; stroke: #e6e6e6; stroke-width: 1.5; }
.line { stroke: #e6e6e6; stroke-width: 1.5; }
.text { fill: #f3f3f3; font-family: Menlo, Consolas, Monaco, monospace; font-size: 16px; }
.small { font-size: 13px; opacity: 0.9; }
.muted { opacity: 0.7; }
</style>
<rect class="bg" x="0" y="0" width="1000" height="520" rx="0" />
<!-- Top boxes -->
<rect class="box" x="390" y="30" width="220" height="44" rx="0" />
<text class="text" x="500" y="58" text-anchor="middle">ralph CLI</text>
<rect class="box" x="240" y="114" width="520" height="48" rx="0" />
<text class="text small muted" x="500" y="144" text-anchor="middle">agent CLI: codex | claude | droid</text>
<!-- Arrow from top to agent (gap matches lower arrows) -->
<line class="line" x1="500" y1="74" x2="500" y2="96" />
<polygon points="496,96 500,104 504,96" fill="#e6e6e6" />
<!-- Branch lines (gap for heads) -->
<line class="line" x1="500" y1="162" x2="500" y2="200" />
<line class="line" x1="500" y1="200" x2="280" y2="200" />
<line class="line" x1="500" y1="200" x2="720" y2="200" />
<line class="line" x1="280" y1="200" x2="280" y2="214" />
<line class="line" x1="720" y1="200" x2="720" y2="214" />
<polygon points="276,214 280,222 284,214" fill="#e6e6e6" />
<polygon points="716,214 720,222 724,214" fill="#e6e6e6" />
<!-- Bottom boxes -->
<rect class="box" x="90" y="226" width="380" height="230" rx="0" />
<text class="text" x="110" y="256">.agents/ralph/</text>
<text class="text small muted" x="110" y="278">local overrides (optional)</text>
<text class="text small" x="110" y="306">loop.sh</text>
<text class="text small" x="110" y="328">PROMPT_build.md</text>
<text class="text small" x="110" y="350">references/</text>
<text class="text small" x="110" y="372">log-activity.sh</text>
<text class="text small" x="110" y="394">config.sh (optional)</text>
<rect class="box" x="530" y="226" width="380" height="230" rx="0" />
<text class="text" x="550" y="256">.ralph/</text>
<text class="text small muted" x="550" y="278">state + logs</text>
<text class="text small" x="550" y="306">errors.log</text>
<text class="text small" x="550" y="328">progress.md</text>
<text class="text small" x="550" y="350">guardrails.md</text>
<text class="text small" x="550" y="372">activity.log</text>
<text class="text small" x="550" y="394">runs/</text>
<text class="text small muted" x="500" y="500" text-anchor="middle">
Local templates override bundled defaults. State persists between runs.
</text>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
ACTIVITY_LOG="$ROOT_DIR/.ralph/activity.log"
if [ $# -lt 1 ]; then
echo "Usage: $0 \"message\""
exit 1
fi
mkdir -p "$(dirname "$ACTIVITY_LOG")"
TS=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$TS] $*" >> "$ACTIVITY_LOG"
+955
View File
@@ -0,0 +1,955 @@
#!/bin/bash
# Ralph loop — simple, portable, single-agent
# Usage:
# ./.agents/ralph/loop.sh # build mode, default iterations
# ./.agents/ralph/loop.sh build # build mode
# ./.agents/ralph/loop.sh prd "request" # generate PRD via agent
# ./.agents/ralph/loop.sh 10 # build mode, 10 iterations
# ./.agents/ralph/loop.sh build 1 --no-commit
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${RALPH_ROOT:-${SCRIPT_DIR}/../..}" && pwd)"
CONFIG_FILE="${SCRIPT_DIR}/config.sh"
DEFAULT_PRD_PATH=".agents/tasks/prd.json"
DEFAULT_PROGRESS_PATH=".ralph/progress.md"
DEFAULT_AGENTS_PATH="AGENTS.md"
DEFAULT_PROMPT_BUILD=".agents/ralph/PROMPT_build.md"
DEFAULT_GUARDRAILS_PATH=".ralph/guardrails.md"
DEFAULT_ERRORS_LOG_PATH=".ralph/errors.log"
DEFAULT_ACTIVITY_LOG_PATH=".ralph/activity.log"
DEFAULT_TMP_DIR=".ralph/.tmp"
DEFAULT_RUNS_DIR=".ralph/runs"
DEFAULT_GUARDRAILS_REF=".agents/ralph/references/GUARDRAILS.md"
DEFAULT_CONTEXT_REF=".agents/ralph/references/CONTEXT_ENGINEERING.md"
DEFAULT_ACTIVITY_CMD=".agents/ralph/log-activity.sh"
if [[ -n "${RALPH_ROOT:-}" ]]; then
agents_path="$RALPH_ROOT/.agents/ralph/agents.sh"
else
agents_path="$SCRIPT_DIR/agents.sh"
fi
if [[ -f "$agents_path" ]]; then
# shellcheck source=/dev/null
source "$agents_path"
fi
DEFAULT_MAX_ITERATIONS=25
DEFAULT_NO_COMMIT=false
DEFAULT_STALE_SECONDS=0
PRD_REQUEST_PATH=""
PRD_INLINE=""
# Optional config overrides (simple shell vars)
if [ -f "$CONFIG_FILE" ]; then
# shellcheck source=/dev/null
. "$CONFIG_FILE"
fi
DEFAULT_AGENT_NAME="${DEFAULT_AGENT:-codex}"
resolve_agent_cmd() {
local name="$1"
case "$name" in
claude)
echo "${AGENT_CLAUDE_CMD:-claude -p --dangerously-skip-permissions \"\$(cat {prompt})\"}"
;;
droid)
echo "${AGENT_DROID_CMD:-droid exec --skip-permissions-unsafe -f {prompt}}"
;;
codex|"")
echo "${AGENT_CODEX_CMD:-codex exec --yolo --skip-git-repo-check -}"
;;
*)
echo "${AGENT_CODEX_CMD:-codex exec --yolo --skip-git-repo-check -}"
;;
esac
}
DEFAULT_AGENT_CMD="$(resolve_agent_cmd "$DEFAULT_AGENT_NAME")"
PRD_PATH="${PRD_PATH:-$DEFAULT_PRD_PATH}"
PROGRESS_PATH="${PROGRESS_PATH:-$DEFAULT_PROGRESS_PATH}"
AGENTS_PATH="${AGENTS_PATH:-$DEFAULT_AGENTS_PATH}"
PROMPT_BUILD="${PROMPT_BUILD:-$DEFAULT_PROMPT_BUILD}"
GUARDRAILS_PATH="${GUARDRAILS_PATH:-$DEFAULT_GUARDRAILS_PATH}"
ERRORS_LOG_PATH="${ERRORS_LOG_PATH:-$DEFAULT_ERRORS_LOG_PATH}"
ACTIVITY_LOG_PATH="${ACTIVITY_LOG_PATH:-$DEFAULT_ACTIVITY_LOG_PATH}"
TMP_DIR="${TMP_DIR:-$DEFAULT_TMP_DIR}"
RUNS_DIR="${RUNS_DIR:-$DEFAULT_RUNS_DIR}"
GUARDRAILS_REF="${GUARDRAILS_REF:-$DEFAULT_GUARDRAILS_REF}"
CONTEXT_REF="${CONTEXT_REF:-$DEFAULT_CONTEXT_REF}"
ACTIVITY_CMD="${ACTIVITY_CMD:-$DEFAULT_ACTIVITY_CMD}"
AGENT_CMD="${AGENT_CMD:-$DEFAULT_AGENT_CMD}"
MAX_ITERATIONS="${MAX_ITERATIONS:-$DEFAULT_MAX_ITERATIONS}"
NO_COMMIT="${NO_COMMIT:-$DEFAULT_NO_COMMIT}"
STALE_SECONDS="${STALE_SECONDS:-$DEFAULT_STALE_SECONDS}"
abs_path() {
local p="$1"
if [[ "$p" = /* ]]; then
echo "$p"
else
echo "$ROOT_DIR/$p"
fi
}
PRD_PATH="$(abs_path "$PRD_PATH")"
PROGRESS_PATH="$(abs_path "$PROGRESS_PATH")"
AGENTS_PATH="$(abs_path "$AGENTS_PATH")"
PROMPT_BUILD="$(abs_path "$PROMPT_BUILD")"
GUARDRAILS_PATH="$(abs_path "$GUARDRAILS_PATH")"
ERRORS_LOG_PATH="$(abs_path "$ERRORS_LOG_PATH")"
ACTIVITY_LOG_PATH="$(abs_path "$ACTIVITY_LOG_PATH")"
TMP_DIR="$(abs_path "$TMP_DIR")"
RUNS_DIR="$(abs_path "$RUNS_DIR")"
GUARDRAILS_REF="$(abs_path "$GUARDRAILS_REF")"
CONTEXT_REF="$(abs_path "$CONTEXT_REF")"
ACTIVITY_CMD="$(abs_path "$ACTIVITY_CMD")"
require_agent() {
local agent_cmd="${1:-$AGENT_CMD}"
local agent_bin
agent_bin="${agent_cmd%% *}"
if [ -z "$agent_bin" ]; then
echo "AGENT_CMD is empty. Set it in config.sh."
exit 1
fi
if ! command -v "$agent_bin" >/dev/null 2>&1; then
echo "Agent command not found: $agent_bin"
case "$agent_bin" in
codex)
echo "Install: npm i -g @openai/codex"
;;
claude)
echo "Install: curl -fsSL https://claude.ai/install.sh | bash"
;;
droid)
echo "Install: curl -fsSL https://app.factory.ai/cli | sh"
;;
opencode)
echo "Install: curl -fsSL https://opencode.ai/install.sh | bash"
;;
esac
echo "Then authenticate per the CLI's instructions."
exit 1
fi
}
run_agent() {
local prompt_file="$1"
if [[ "$AGENT_CMD" == *"{prompt}"* ]]; then
local escaped
escaped=$(printf '%q' "$prompt_file")
local cmd="${AGENT_CMD//\{prompt\}/$escaped}"
eval "$cmd"
else
cat "$prompt_file" | eval "$AGENT_CMD"
fi
}
run_agent_inline() {
local prompt_file="$1"
local prompt_content
prompt_content="$(cat "$prompt_file")"
local escaped
escaped=$(printf "%s" "$prompt_content" | sed "s/'/'\\\\''/g")
local cmd="${PRD_AGENT_CMD:-$AGENT_CMD}"
if [[ "$cmd" == *"{prompt}"* ]]; then
cmd="${cmd//\{prompt\}/'$escaped'}"
else
cmd="$cmd '$escaped'"
fi
eval "$cmd"
}
MODE="build"
while [ $# -gt 0 ]; do
case "$1" in
build|prd)
MODE="$1"
shift
;;
--prompt)
PRD_REQUEST_PATH="$2"
shift 2
;;
--no-commit)
NO_COMMIT=true
shift
;;
*)
if [ "$MODE" = "prd" ]; then
PRD_INLINE="${PRD_INLINE:+$PRD_INLINE }$1"
shift
elif [[ "$1" =~ ^[0-9]+$ ]]; then
MAX_ITERATIONS="$1"
shift
else
echo "Unknown arg: $1"
exit 1
fi
;;
esac
done
PROMPT_FILE="$PROMPT_BUILD"
if [ "$MODE" = "prd" ]; then
PRD_USE_INLINE=1
if [ -z "${PRD_AGENT_CMD:-}" ]; then
PRD_AGENT_CMD="$AGENT_CMD"
PRD_USE_INLINE=0
fi
if [ "${RALPH_DRY_RUN:-}" != "1" ]; then
require_agent "${PRD_AGENT_CMD:-$AGENT_CMD}"
fi
if [[ "$PRD_PATH" == *.json ]]; then
mkdir -p "$(dirname "$PRD_PATH")" "$TMP_DIR"
else
mkdir -p "$PRD_PATH" "$TMP_DIR"
fi
if [ -z "$PRD_REQUEST_PATH" ] && [ -n "$PRD_INLINE" ]; then
PRD_REQUEST_PATH="$TMP_DIR/prd-request-$(date +%Y%m%d-%H%M%S)-$$.txt"
printf '%s\n' "$PRD_INLINE" > "$PRD_REQUEST_PATH"
fi
if [ -z "$PRD_REQUEST_PATH" ] || [ ! -f "$PRD_REQUEST_PATH" ]; then
echo "PRD request missing. Provide a prompt string or --prompt <file>."
exit 1
fi
if [ "${RALPH_DRY_RUN:-}" = "1" ]; then
if [[ "$PRD_PATH" == *.json ]]; then
if [ ! -f "$PRD_PATH" ]; then
{
echo '{'
echo ' "version": 1,'
echo ' "project": "ralph",'
echo ' "qualityGates": [],'
echo ' "stories": []'
echo '}'
} > "$PRD_PATH"
fi
fi
exit 0
fi
PRD_PROMPT_FILE="$TMP_DIR/prd-prompt-$(date +%Y%m%d-%H%M%S)-$$.md"
{
echo "You are an autonomous coding agent."
echo "Use the \$prd skill to create a Product Requirements Document in JSON."
if [[ "$PRD_PATH" == *.json ]]; then
echo "Save the PRD to: $PRD_PATH"
else
echo "Save the PRD as JSON in directory: $PRD_PATH"
echo "Filename rules: prd-<short-slug>.json using 1-3 meaningful words."
echo "Examples: prd-workout-tracker.json, prd-usage-billing.json"
fi
echo "Do NOT implement anything."
echo "After creating the PRD, end with:"
echo "PRD JSON saved to <path>. Close this chat and run \`ralph build\`."
echo ""
echo "User request:"
cat "$PRD_REQUEST_PATH"
} > "$PRD_PROMPT_FILE"
if [ "$PRD_USE_INLINE" -eq 1 ]; then
run_agent_inline "$PRD_PROMPT_FILE"
else
run_agent "$PRD_PROMPT_FILE"
fi
exit 0
fi
if [ "${RALPH_DRY_RUN:-}" != "1" ]; then
require_agent
fi
if [ ! -f "$PROMPT_FILE" ]; then
echo "Prompt not found: $PROMPT_FILE"
exit 1
fi
if [ "$MODE" != "prd" ] && [ ! -f "$PRD_PATH" ]; then
echo "PRD not found: $PRD_PATH"
exit 1
fi
mkdir -p "$(dirname "$PROGRESS_PATH")" "$TMP_DIR" "$RUNS_DIR"
if [ ! -f "$PROGRESS_PATH" ]; then
{
echo "# Progress Log"
echo "Started: $(date)"
echo ""
echo "## Codebase Patterns"
echo "- (add reusable patterns here)"
echo ""
echo "---"
} > "$PROGRESS_PATH"
fi
if [ ! -f "$GUARDRAILS_PATH" ]; then
{
echo "# Guardrails (Signs)"
echo ""
echo "> Lessons learned from failures. Read before acting."
echo ""
echo "## Core Signs"
echo ""
echo "### Sign: Read Before Writing"
echo "- **Trigger**: Before modifying any file"
echo "- **Instruction**: Read the file first"
echo "- **Added after**: Core principle"
echo ""
echo "### Sign: Test Before Commit"
echo "- **Trigger**: Before committing changes"
echo "- **Instruction**: Run required tests and verify outputs"
echo "- **Added after**: Core principle"
echo ""
echo "---"
echo ""
echo "## Learned Signs"
echo ""
} > "$GUARDRAILS_PATH"
fi
if [ ! -f "$ERRORS_LOG_PATH" ]; then
{
echo "# Error Log"
echo ""
echo "> Failures and repeated issues. Use this to add guardrails."
echo ""
} > "$ERRORS_LOG_PATH"
fi
if [ ! -f "$ACTIVITY_LOG_PATH" ]; then
{
echo "# Activity Log"
echo ""
echo "## Run Summary"
echo ""
echo "## Events"
echo ""
} > "$ACTIVITY_LOG_PATH"
fi
RUN_TAG="$(date +%Y%m%d-%H%M%S)-$$"
render_prompt() {
local src="$1"
local dst="$2"
local story_meta="$3"
local story_block="$4"
local run_id="$5"
local iter="$6"
local run_log="$7"
local run_meta="$8"
python3 - "$src" "$dst" "$PRD_PATH" "$AGENTS_PATH" "$PROGRESS_PATH" "$ROOT_DIR" "$GUARDRAILS_PATH" "$ERRORS_LOG_PATH" "$ACTIVITY_LOG_PATH" "$GUARDRAILS_REF" "$CONTEXT_REF" "$ACTIVITY_CMD" "$NO_COMMIT" "$story_meta" "$story_block" "$run_id" "$iter" "$run_log" "$run_meta" <<'PY'
import sys
from pathlib import Path
src = Path(sys.argv[1]).read_text()
prd, agents, progress, root = sys.argv[3:7]
guardrails = sys.argv[7]
errors_log = sys.argv[8]
activity_log = sys.argv[9]
guardrails_ref = sys.argv[10]
context_ref = sys.argv[11]
activity_cmd = sys.argv[12]
no_commit = sys.argv[13]
meta_path = sys.argv[14] if len(sys.argv) > 14 else ""
block_path = sys.argv[15] if len(sys.argv) > 15 else ""
run_id = sys.argv[16] if len(sys.argv) > 16 else ""
iteration = sys.argv[17] if len(sys.argv) > 17 else ""
run_log = sys.argv[18] if len(sys.argv) > 18 else ""
run_meta = sys.argv[19] if len(sys.argv) > 19 else ""
repl = {
"PRD_PATH": prd,
"AGENTS_PATH": agents,
"PROGRESS_PATH": progress,
"REPO_ROOT": root,
"GUARDRAILS_PATH": guardrails,
"ERRORS_LOG_PATH": errors_log,
"ACTIVITY_LOG_PATH": activity_log,
"GUARDRAILS_REF": guardrails_ref,
"CONTEXT_REF": context_ref,
"ACTIVITY_CMD": activity_cmd,
"NO_COMMIT": no_commit,
"RUN_ID": run_id,
"ITERATION": iteration,
"RUN_LOG_PATH": run_log,
"RUN_META_PATH": run_meta,
}
story = {"id": "", "title": "", "block": ""}
quality_gates = []
if meta_path:
try:
import json
meta = json.loads(Path(meta_path).read_text())
story["id"] = meta.get("id", "") or ""
story["title"] = meta.get("title", "") or ""
quality_gates = meta.get("quality_gates", []) or []
except Exception:
pass
if block_path and Path(block_path).exists():
story["block"] = Path(block_path).read_text()
repl["STORY_ID"] = story["id"]
repl["STORY_TITLE"] = story["title"]
repl["STORY_BLOCK"] = story["block"]
if quality_gates:
repl["QUALITY_GATES"] = "\n".join([f"- {g}" for g in quality_gates])
else:
repl["QUALITY_GATES"] = "- (none)"
for k, v in repl.items():
src = src.replace("{{" + k + "}}", v)
Path(sys.argv[2]).write_text(src)
PY
}
select_story() {
local meta_out="$1"
local block_out="$2"
python3 - "$PRD_PATH" "$meta_out" "$block_out" "$STALE_SECONDS" <<'PY'
import json
import os
import sys
from pathlib import Path
from datetime import datetime, timezone
try:
import fcntl
except Exception:
fcntl = None
prd_path = Path(sys.argv[1])
meta_out = Path(sys.argv[2])
block_out = Path(sys.argv[3])
stale_seconds = 0
if len(sys.argv) > 4:
try:
stale_seconds = int(sys.argv[4])
except Exception:
stale_seconds = 0
if not prd_path.exists():
meta_out.write_text(json.dumps({"ok": False, "error": "PRD not found"}, indent=2) + "\n")
block_out.write_text("")
sys.exit(0)
def normalize_status(value):
if value is None:
return "open"
return str(value).strip().lower()
def parse_ts(value):
if not value:
return None
text = str(value).strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
return datetime.fromisoformat(text)
except Exception:
return None
def now_iso():
return datetime.now(timezone.utc).isoformat()
with prd_path.open("r+", encoding="utf-8") as fh:
if fcntl is not None:
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
try:
try:
data = json.load(fh)
except Exception as exc:
meta_out.write_text(json.dumps({"ok": False, "error": f"Invalid PRD JSON: {exc}"}, indent=2) + "\n")
block_out.write_text("")
sys.exit(0)
stories = data.get("stories") if isinstance(data, dict) else None
if not isinstance(stories, list) or not stories:
meta_out.write_text(json.dumps({"ok": False, "error": "No stories found in PRD"}, indent=2) + "\n")
block_out.write_text("")
sys.exit(0)
story_index = {s.get("id"): s for s in stories if isinstance(s, dict)}
def is_done(story_id: str) -> bool:
target = story_index.get(story_id)
if not isinstance(target, dict):
return False
return normalize_status(target.get("status")) == "done"
if stale_seconds > 0:
now = datetime.now(timezone.utc)
for story in stories:
if not isinstance(story, dict):
continue
if normalize_status(story.get("status")) != "in_progress":
continue
started = parse_ts(story.get("startedAt"))
if started is None or (now - started).total_seconds() > stale_seconds:
story["status"] = "open"
story["startedAt"] = None
story["completedAt"] = None
story["updatedAt"] = now_iso()
candidate = None
for story in stories:
if not isinstance(story, dict):
continue
if normalize_status(story.get("status")) != "open":
continue
deps = story.get("dependsOn") or []
if not isinstance(deps, list):
deps = []
if all(is_done(dep) for dep in deps):
candidate = story
break
remaining = sum(
1 for story in stories
if isinstance(story, dict) and normalize_status(story.get("status")) != "done"
)
meta = {
"ok": True,
"total": len(stories),
"remaining": remaining,
"quality_gates": data.get("qualityGates", []) or [],
}
if candidate:
candidate["status"] = "in_progress"
if not candidate.get("startedAt"):
candidate["startedAt"] = now_iso()
candidate["completedAt"] = None
candidate["updatedAt"] = now_iso()
meta.update({
"id": candidate.get("id", ""),
"title": candidate.get("title", ""),
})
depends = candidate.get("dependsOn") or []
if not isinstance(depends, list):
depends = []
acceptance = candidate.get("acceptanceCriteria") or []
if not isinstance(acceptance, list):
acceptance = []
description = candidate.get("description") or ""
block_lines = []
block_lines.append(f"### {candidate.get('id', '')}: {candidate.get('title', '')}")
block_lines.append(f"Status: {candidate.get('status', 'open')}")
block_lines.append(
f"Depends on: {', '.join(depends) if depends else 'None'}"
)
block_lines.append("")
block_lines.append("Description:")
block_lines.append(description if description else "(none)")
block_lines.append("")
block_lines.append("Acceptance Criteria:")
if acceptance:
block_lines.extend([f"- [ ] {item}" for item in acceptance])
else:
block_lines.append("- (none)")
block_out.write_text("\n".join(block_lines).rstrip() + "\n")
else:
block_out.write_text("")
fh.seek(0)
fh.truncate()
json.dump(data, fh, indent=2)
fh.write("\n")
fh.flush()
os.fsync(fh.fileno())
finally:
if fcntl is not None:
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
meta_out.write_text(json.dumps(meta, indent=2) + "\n")
PY
}
remaining_stories() {
local meta_file="$1"
python3 - "$meta_file" <<'PY'
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text())
print(data.get("remaining", "unknown"))
PY
}
remaining_from_prd() {
python3 - "$PRD_PATH" <<'PY'
import json
import sys
from pathlib import Path
prd_path = Path(sys.argv[1])
if not prd_path.exists():
print("unknown")
sys.exit(0)
try:
data = json.loads(prd_path.read_text())
except Exception:
print("unknown")
sys.exit(0)
stories = data.get("stories") if isinstance(data, dict) else None
if not isinstance(stories, list):
print("unknown")
sys.exit(0)
def normalize_status(value):
if value is None:
return "open"
return str(value).strip().lower()
remaining = sum(
1 for story in stories
if isinstance(story, dict) and normalize_status(story.get("status")) != "done"
)
print(remaining)
PY
}
story_field() {
local meta_file="$1"
local field="$2"
python3 - "$meta_file" "$field" <<'PY'
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text())
field = sys.argv[2]
print(data.get(field, ""))
PY
}
update_story_status() {
local story_id="$1"
local new_status="$2"
python3 - "$PRD_PATH" "$story_id" "$new_status" <<'PY'
import json
import os
import sys
from pathlib import Path
from datetime import datetime, timezone
try:
import fcntl
except Exception:
fcntl = None
prd_path = Path(sys.argv[1])
story_id = sys.argv[2]
new_status = sys.argv[3]
if not story_id:
sys.exit(0)
if not prd_path.exists():
sys.exit(0)
def now_iso():
return datetime.now(timezone.utc).isoformat()
with prd_path.open("r+", encoding="utf-8") as fh:
if fcntl is not None:
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
try:
data = json.load(fh)
stories = data.get("stories") if isinstance(data, dict) else None
if not isinstance(stories, list):
sys.exit(0)
for story in stories:
if isinstance(story, dict) and story.get("id") == story_id:
story["status"] = new_status
story["updatedAt"] = now_iso()
if new_status == "in_progress":
if not story.get("startedAt"):
story["startedAt"] = now_iso()
story["completedAt"] = None
elif new_status == "done":
story["completedAt"] = now_iso()
if not story.get("startedAt"):
story["startedAt"] = now_iso()
elif new_status == "open":
story["startedAt"] = None
story["completedAt"] = None
break
fh.seek(0)
fh.truncate()
json.dump(data, fh, indent=2)
fh.write("\n")
fh.flush()
os.fsync(fh.fileno())
finally:
if fcntl is not None:
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
PY
}
log_activity() {
local message="$1"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] $message" >> "$ACTIVITY_LOG_PATH"
}
log_error() {
local message="$1"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] $message" >> "$ERRORS_LOG_PATH"
}
append_run_summary() {
local line="$1"
python3 - "$ACTIVITY_LOG_PATH" "$line" <<'PY'
import sys
from pathlib import Path
path = Path(sys.argv[1])
line = sys.argv[2]
text = path.read_text().splitlines()
out = []
inserted = False
for l in text:
out.append(l)
if not inserted and l.strip() == "## Run Summary":
out.append(f"- {line}")
inserted = True
if not inserted:
out = [
"# Activity Log",
"",
"## Run Summary",
f"- {line}",
"",
"## Events",
"",
] + text
Path(path).write_text("\n".join(out).rstrip() + "\n")
PY
}
write_run_meta() {
local path="$1"
local mode="$2"
local iter="$3"
local run_id="$4"
local story_id="$5"
local story_title="$6"
local started="$7"
local ended="$8"
local duration="$9"
local status="${10}"
local log_file="${11}"
local head_before="${12}"
local head_after="${13}"
local commit_list="${14}"
local changed_files="${15}"
local dirty_files="${16}"
{
echo "# Ralph Run Summary"
echo ""
echo "- Run ID: $run_id"
echo "- Iteration: $iter"
echo "- Mode: $mode"
if [ -n "$story_id" ]; then
echo "- Story: $story_id: $story_title"
fi
echo "- Started: $started"
echo "- Ended: $ended"
echo "- Duration: ${duration}s"
echo "- Status: $status"
echo "- Log: $log_file"
echo ""
echo "## Git"
echo "- Head (before): ${head_before:-unknown}"
echo "- Head (after): ${head_after:-unknown}"
echo ""
echo "### Commits"
if [ -n "$commit_list" ]; then
echo "$commit_list"
else
echo "- (none)"
fi
echo ""
echo "### Changed Files (commits)"
if [ -n "$changed_files" ]; then
echo "$changed_files"
else
echo "- (none)"
fi
echo ""
echo "### Uncommitted Changes"
if [ -n "$dirty_files" ]; then
echo "$dirty_files"
else
echo "- (clean)"
fi
echo ""
} > "$path"
}
git_head() {
if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true
else
echo ""
fi
}
git_commit_list() {
local before="$1"
local after="$2"
if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then
git -C "$ROOT_DIR" log --oneline "$before..$after" | sed 's/^/- /'
else
echo ""
fi
}
git_changed_files() {
local before="$1"
local after="$2"
if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then
git -C "$ROOT_DIR" diff --name-only "$before" "$after" | sed 's/^/- /'
else
echo ""
fi
}
git_dirty_files() {
if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git -C "$ROOT_DIR" status --porcelain | awk '{print "- " $2}'
else
echo ""
fi
}
echo "Ralph mode: $MODE"
echo "Max iterations: $MAX_ITERATIONS"
echo "PRD: $PRD_PATH"
HAS_ERROR="false"
for i in $(seq 1 "$MAX_ITERATIONS"); do
echo ""
echo "═══════════════════════════════════════════════════════"
echo " Ralph Iteration $i of $MAX_ITERATIONS"
echo "═══════════════════════════════════════════════════════"
STORY_META=""
STORY_BLOCK=""
ITER_START=$(date +%s)
ITER_START_FMT=$(date '+%Y-%m-%d %H:%M:%S')
if [ "$MODE" = "build" ]; then
STORY_META="$TMP_DIR/story-$RUN_TAG-$i.json"
STORY_BLOCK="$TMP_DIR/story-$RUN_TAG-$i.md"
select_story "$STORY_META" "$STORY_BLOCK"
REMAINING="$(remaining_stories "$STORY_META")"
if [ "$REMAINING" = "unknown" ]; then
echo "Could not parse stories from PRD: $PRD_PATH"
exit 1
fi
if [ "$REMAINING" = "0" ]; then
echo "No remaining stories."
exit 0
fi
STORY_ID="$(story_field "$STORY_META" "id")"
STORY_TITLE="$(story_field "$STORY_META" "title")"
if [ -z "$STORY_ID" ]; then
echo "No actionable open stories (all blocked or in progress). Remaining: $REMAINING"
exit 0
fi
fi
HEAD_BEFORE="$(git_head)"
PROMPT_RENDERED="$TMP_DIR/prompt-$RUN_TAG-$i.md"
LOG_FILE="$RUNS_DIR/run-$RUN_TAG-iter-$i.log"
RUN_META="$RUNS_DIR/run-$RUN_TAG-iter-$i.md"
render_prompt "$PROMPT_FILE" "$PROMPT_RENDERED" "$STORY_META" "$STORY_BLOCK" "$RUN_TAG" "$i" "$LOG_FILE" "$RUN_META"
if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then
log_activity "ITERATION $i start (mode=$MODE story=$STORY_ID)"
else
log_activity "ITERATION $i start (mode=$MODE)"
fi
set +e
if [ "${RALPH_DRY_RUN:-}" = "1" ]; then
echo "[RALPH_DRY_RUN] Skipping agent execution." | tee "$LOG_FILE"
CMD_STATUS=0
else
run_agent "$PROMPT_RENDERED" 2>&1 | tee "$LOG_FILE"
CMD_STATUS=$?
fi
set -e
if [ "$CMD_STATUS" -eq 130 ] || [ "$CMD_STATUS" -eq 143 ]; then
echo "Interrupted."
exit "$CMD_STATUS"
fi
ITER_END=$(date +%s)
ITER_END_FMT=$(date '+%Y-%m-%d %H:%M:%S')
ITER_DURATION=$((ITER_END - ITER_START))
HEAD_AFTER="$(git_head)"
log_activity "ITERATION $i end (duration=${ITER_DURATION}s)"
if [ "$CMD_STATUS" -ne 0 ]; then
log_error "ITERATION $i command failed (status=$CMD_STATUS)"
HAS_ERROR="true"
fi
COMMIT_LIST="$(git_commit_list "$HEAD_BEFORE" "$HEAD_AFTER")"
CHANGED_FILES="$(git_changed_files "$HEAD_BEFORE" "$HEAD_AFTER")"
DIRTY_FILES="$(git_dirty_files)"
STATUS_LABEL="success"
if [ "$CMD_STATUS" -ne 0 ]; then
STATUS_LABEL="error"
fi
if [ "$MODE" = "build" ] && [ "$NO_COMMIT" = "false" ] && [ -n "$DIRTY_FILES" ]; then
log_error "ITERATION $i left uncommitted changes; review run summary at $RUN_META"
fi
write_run_meta "$RUN_META" "$MODE" "$i" "$RUN_TAG" "${STORY_ID:-}" "${STORY_TITLE:-}" "$ITER_START_FMT" "$ITER_END_FMT" "$ITER_DURATION" "$STATUS_LABEL" "$LOG_FILE" "$HEAD_BEFORE" "$HEAD_AFTER" "$COMMIT_LIST" "$CHANGED_FILES" "$DIRTY_FILES"
if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then
append_run_summary "$(date '+%Y-%m-%d %H:%M:%S') | run=$RUN_TAG | iter=$i | mode=$MODE | story=$STORY_ID | duration=${ITER_DURATION}s | status=$STATUS_LABEL"
else
append_run_summary "$(date '+%Y-%m-%d %H:%M:%S') | run=$RUN_TAG | iter=$i | mode=$MODE | duration=${ITER_DURATION}s | status=$STATUS_LABEL"
fi
if [ "$MODE" = "build" ]; then
if [ "$CMD_STATUS" -ne 0 ]; then
log_error "ITERATION $i exited non-zero; review $LOG_FILE"
update_story_status "$STORY_ID" "open"
echo "Iteration failed; story reset to open."
elif grep -q "<promise>COMPLETE</promise>" "$LOG_FILE"; then
update_story_status "$STORY_ID" "done"
echo "Completion signal received; story marked done."
else
update_story_status "$STORY_ID" "open"
echo "No completion signal; story reset to open."
fi
REMAINING="$(remaining_from_prd)"
echo "Iteration $i complete. Remaining stories: $REMAINING"
if [ "$REMAINING" = "0" ]; then
echo "No remaining stories."
exit 0
fi
else
echo "Iteration $i complete."
fi
sleep 2
done
echo "Reached max iterations ($MAX_ITERATIONS)."
if [ "$HAS_ERROR" = "true" ]; then
exit 1
fi
exit 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

@@ -0,0 +1,126 @@
# Context Engineering Reference
This document explains the malloc/free metaphor for LLM context management that underlies the Ralph technique.
## The malloc() Metaphor
In traditional programming:
- `malloc()` allocates memory
- `free()` releases memory
- Memory leaks occur when you allocate without freeing
In LLM context:
- Reading files, receiving responses, tool outputs = `malloc()`
- **There is no `free()`** - context cannot be released
- The only way to "free" is to start a new conversation
## Why This Matters
### Context Pollution
When you work on multiple unrelated tasks in the same context:
```
Task 1: Build authentication → context contains auth code, JWT docs, security patterns
Task 2: Build UI components → context now ALSO contains auth stuff
Result: LLM might suggest auth-related patterns when building UI
or mix concerns inappropriately
```
### Autoregressive Failure
LLMs predict the next token based on ALL context. When context contains:
- Unrelated information
- Failed attempts
- Mixed concerns
The model can "spiral" into wrong territory, generating increasingly off-base responses.
### The Gutter Metaphor
> "If the bowling ball is in the gutter, there's no saving it."
Once context is polluted with failed attempts or mixed concerns, the model will keep referencing that pollution. Starting fresh is often faster than trying to correct course.
## Context Health Indicators
### 🟢 Healthy Context
- Single focused task
- Relevant files only
- Clear progress
- Under 60% capacity
### 🟡 Warning Signs
- Multiple unrelated topics discussed
- Several failed attempts in history
- Approaching 80% capacity
- Repeated similar errors
### 🔴 Critical / Gutter
- Mixed concerns throughout
- Circular failure patterns
- Over 90% capacity
- Model suggesting irrelevant solutions
## Best Practices
### 1. One Task Per Context
Don't ask "fix the auth bug AND add the new feature". Do them in separate conversations.
### 2. Fresh Start on Topic Change
Finished auth? Start a new conversation for the next feature.
### 3. Don't Redline
Stay under 80% of context capacity. Quality degrades as you approach limits.
### 4. Recognize the Gutter
If you're seeing:
- Same error 3+ times
- Solutions that don't match the problem
- Circular suggestions
Start fresh. Your progress is in the files.
### 5. State in Files, Not Context
Write progress to files. The next conversation can read them. Context is ephemeral; files are permanent.
## Ralph's Approach
The original Ralph technique (`while :; do cat PROMPT.md | agent ; done`) naturally implements these principles:
1. **Each iteration is a fresh process** - Context is freed
2. **State persists in files** - Progress survives context resets
3. **Same prompt each time** - Focused, single-task context
4. **Failures inform guardrails** - Learning without context pollution
This Cursor implementation aims to bring these benefits while working within Cursor's session model.
## Measuring Context
Rough estimates:
- 1 token ≈ 4 characters
- Average code file: 500-2000 tokens
- Large file: 5000+ tokens
- Conversation history: 100-500 tokens per exchange
Track allocations in `.ralph/context-log.md` to stay aware.
## When to Start Fresh
**Definitely start fresh when:**
- Switching to unrelated task
- Context over 90% full
- Same error 3+ times
- Model suggestions are off-topic
**Consider starting fresh when:**
- Context over 70% full
- Significant topic shift within task
- Feeling "stuck"
- Multiple failed approaches in history
+174
View File
@@ -0,0 +1,174 @@
# Guardrails Reference ("Signs")
This document explains how to create and use guardrails in Ralph.
## The Signs Metaphor
From Geoffrey Huntley:
> "Ralph is very good at making playgrounds, but he comes home bruised because he fell off the slide, so one then tunes Ralph by adding a sign next to the slide saying 'SLIDE DOWN, DON'T JUMP, LOOK AROUND,' and Ralph is more likely to look and see the sign."
Signs are explicit instructions added to prevent known failure modes.
## Anatomy of a Sign
```markdown
### Sign: [Descriptive Name]
- **Trigger**: When this situation occurs
- **Instruction**: What to do instead
- **Added after**: When/why this was added
- **Example**: Concrete example if helpful
```
## Types of Signs
### 1. Preventive Signs
Stop problems before they happen:
```markdown
### Sign: Validate Before Trust
- **Trigger**: When receiving external input
- **Instruction**: Always validate and sanitize input before using it
- **Added after**: Iteration 3 - SQL injection vulnerability
```
### 2. Corrective Signs
Fix recurring mistakes:
```markdown
### Sign: Check Return Values
- **Trigger**: When calling functions that can fail
- **Instruction**: Always check return values and handle errors
- **Added after**: Iteration 7 - Null pointer exception
```
### 3. Process Signs
Enforce good practices:
```markdown
### Sign: Test Before Commit
- **Trigger**: Before committing changes
- **Instruction**: Run the test suite and ensure all tests pass
- **Added after**: Iteration 2 - Broken tests committed
```
### 4. Architecture Signs
Guide design decisions:
```markdown
### Sign: Single Responsibility
- **Trigger**: When a function grows beyond 50 lines
- **Instruction**: Consider splitting into smaller, focused functions
- **Added after**: Iteration 12 - Unmaintainable god function
```
## When to Add Signs
Add a sign when:
1. **The same mistake happens twice** - Once is learning, twice is a pattern
2. **A subtle bug is found** - Prevent future occurrences
3. **A best practice is violated** - Reinforce good habits
4. **Context-specific knowledge is needed** - Project-specific conventions
## Sign Lifecycle
### Creation
```markdown
### Sign: [New Sign]
- **Trigger**: [When it applies]
- **Instruction**: [What to do]
- **Added after**: Iteration N - [What happened]
```
### Refinement
If a sign isn't working:
- Make the trigger more specific
- Make the instruction clearer
- Add examples
### Retirement
Signs can be removed when:
- The underlying issue is fixed at a deeper level
- The sign is no longer relevant
- The sign is causing more problems than it solves
## Example Signs Library
### Security
```markdown
### Sign: Sanitize All Input
- **Trigger**: Any user-provided data
- **Instruction**: Use parameterized queries, escape HTML, validate types
- **Example**: `db.query("SELECT * FROM users WHERE id = ?", [userId])`
```
### Error Handling
```markdown
### Sign: Graceful Degradation
- **Trigger**: External service calls
- **Instruction**: Always have a fallback for when services are unavailable
- **Example**: Cache results, provide default values, show friendly errors
```
### Testing
```markdown
### Sign: Test the Unhappy Path
- **Trigger**: Writing tests for new functionality
- **Instruction**: Include tests for error cases, edge cases, and invalid input
```
### Code Quality
```markdown
### Sign: Explain Why, Not What
- **Trigger**: Writing comments
- **Instruction**: Comments should explain reasoning, not describe obvious code
- **Example**: `// Using retry because API is flaky under load` not `// Call the API`
```
## Automatic Sign Detection
The Ralph hooks can automatically detect some patterns and suggest signs:
- **Thrashing**: Same file edited many times → "Step back and reconsider"
- **Repeated errors**: Same test failing → "Check the test assumptions"
- **Large changes**: Big diffs → "Consider smaller increments"
These are logged in `.ralph/failures.md` and can be promoted to guardrails.
## Using Signs Effectively
### Do
- Keep signs concise and actionable
- Include concrete examples
- Update signs when they're not working
- Remove outdated signs
### Don't
- Add signs for every minor issue
- Make signs too vague ("be careful")
- Ignore signs that keep triggering
- Let the guardrails file become overwhelming
## Integration with Ralph
Signs are:
1. Stored in `.ralph/guardrails.md`
2. Injected into context at the start of each iteration
3. Referenced when relevant situations arise
4. Updated based on observed failures
The goal is a self-improving system where each failure makes future iterations smarter.
+151
View File
@@ -0,0 +1,151 @@
{
"version": 1,
"project": "Mobile App File Size Refactor",
"overview": "Refactor the existing React Native (Expo) mobile app to keep most files within ~300-400 lines by splitting into smaller modules without changing functionality.",
"goals": [
"Reduce oversized files to a target range of ~300-400 lines where practical",
"Improve maintainability by splitting files into clearer layers (ui/components, hooks, services, utils)",
"Preserve existing behavior and UI"
],
"nonGoals": [
"No functional changes",
"No UI changes",
"No new product features"
],
"successMetrics": [
"All files are within target size or documented as justified exceptions",
"Refactor passes existing tests, lint, and typecheck"
],
"openQuestions": [
"Which specific files currently exceed 400 lines and need priority refactors?",
"Are there any existing architectural constraints that limit splitting (e.g., circular deps)?"
],
"stack": {
"framework": "React Native (Expo)",
"hosting": "App stores via Expo/EAS",
"database": "Existing app data layer (no changes)",
"auth": "Existing app auth (no changes)"
},
"routes": [
{
"path": "(existing)",
"name": "App Routes",
"purpose": "No route changes; refactor only"
}
],
"uiNotes": [
"No UI changes; keep visual output identical",
"Split view logic into smaller components without altering layout"
],
"dataModel": [
{
"entity": "N/A",
"fields": []
}
],
"importFormat": {
"description": "Not applicable",
"example": {}
},
"rules": [
"Target file size is ~300-400 lines; exceptions must include justification",
"Refactor should follow layer-based split: ui/components, hooks, services, utils",
"No behavior changes; identical inputs must yield identical outputs"
],
"qualityGates": [
"npm test",
"npm run lint",
"npm run typecheck"
],
"stories": [
{
"id": "US-001",
"title": "Inventory oversized files and refactor plan",
"status": "done",
"dependsOn": [],
"description": "As a developer, I want an inventory of oversized files and a planned split strategy so refactors are systematic and safe.",
"acceptanceCriteria": [
"Produce a list of files > 400 lines with line counts",
"For each file, define target split into layers (ui/components, hooks, services, utils)",
"Example: a 900-line screen file -> screen container + UI subcomponents + hooks + service module",
"Negative case: a file under 300 lines is not split unless a clear modularization need is documented"
],
"startedAt": "2026-01-25T20:40:12.238126+00:00",
"completedAt": "2026-01-25T20:47:05.362525+00:00",
"updatedAt": "2026-01-25T20:47:05.362425+00:00"
},
{
"id": "US-002",
"title": "Refactor screen-level files into layer-based modules",
"status": "done",
"dependsOn": [
"US-001"
],
"description": "As a developer, I want oversized screen files split into smaller modules so each file stays around 300-400 lines.",
"acceptanceCriteria": [
"Split screen files into container + UI components + hooks + services as needed",
"Example: screen logic moves into `hooks/use<Screen>.ts` and data calls into `services/<screen>.ts`",
"Negative case: moving a function that creates circular dependencies is rejected and alternative split is used",
"All screens render identically before and after refactor"
],
"startedAt": "2026-01-25T20:47:07.436262+00:00",
"completedAt": "2026-01-25T21:07:57.526689+00:00",
"updatedAt": "2026-01-25T21:07:57.526580+00:00"
},
{
"id": "US-003",
"title": "Refactor oversized shared components",
"status": "done",
"dependsOn": [
"US-001"
],
"description": "As a developer, I want oversized shared components split into smaller pieces to improve clarity and reusability.",
"acceptanceCriteria": [
"Split shared UI components into smaller subcomponents and helper modules",
"Example: extract nested UI blocks into `components/<Component>/SubComponent.tsx`",
"Negative case: do not extract one-off UI fragments that would reduce cohesion",
"No change to public component API unless required for split"
],
"startedAt": "2026-01-26T14:09:11.801117+00:00",
"completedAt": "2026-01-26T14:22:30.296005+00:00",
"updatedAt": "2026-01-26T14:22:30.295815+00:00"
},
{
"id": "US-004",
"title": "Refactor oversized hooks/services/utils",
"status": "done",
"dependsOn": [
"US-001"
],
"description": "As a developer, I want oversized logic files split into smaller, focused modules for readability.",
"acceptanceCriteria": [
"Split large hooks into smaller hooks or helper functions in utils",
"Example: a 500-line hook becomes a main hook + `utils/<hook>-helpers.ts`",
"Negative case: do not split logic in a way that increases public API surface without justification",
"All previous exports remain available or are re-exported from the original entry point"
],
"startedAt": "2026-01-26T14:22:32.363863+00:00",
"completedAt": "2026-01-26T14:28:08.569075+00:00",
"updatedAt": "2026-01-26T14:28:08.568972+00:00"
},
{
"id": "US-005",
"title": "Validate refactor quality gates",
"status": "done",
"dependsOn": [
"US-002",
"US-003",
"US-004"
],
"description": "As a developer, I want automated checks to pass to ensure the refactor preserved behavior.",
"acceptanceCriteria": [
"Run `npm test`, `npm run lint`, and `npm run typecheck` successfully",
"Example: all tests pass without modifications to test expectations",
"Negative case: if a check fails, refactor is incomplete and must be fixed before completion"
],
"startedAt": "2026-01-26T14:28:10.644821+00:00",
"completedAt": "2026-01-26T14:33:54.447192+00:00",
"updatedAt": "2026-01-26T14:33:54.446892+00:00"
}
]
}
-73
View File
@@ -1,73 +0,0 @@
# Agents Documentation
This file contains important project-specific context for Claude Code agents working on this repository.
## Redemption Code System
**IMPORTANT: Codes can only be redeemed on the website, NOT in the mobile app.**
Apple's App Store guidelines require that any premium features purchased within an iOS app must go through In-App Purchase. Adding a redemption code feature to the mobile app would violate these guidelines and risk app rejection.
### Overview
Promotional codes that grant users premium access without going through RevenueCat/App Store subscription flow.
### Database Schema
**`redemption_codes` table:**
- `id` (UUID) - Primary key
- `code` (TEXT, UNIQUE) - The redemption code
- `description` (TEXT) - Optional description
- `max_uses` (INTEGER) - Maximum number of redemptions allowed
- `current_uses` (INTEGER) - Current number of redemptions
- `expires_at` (TIMESTAMPTZ) - Optional expiration date
- `is_active` (BOOLEAN) - Whether the code can be redeemed
- `created_by` (UUID) - Admin who created the code
- `created_at` (TIMESTAMPTZ)
- `updated_at` (TIMESTAMPTZ)
**`code_redemptions` table:**
- `id` (UUID) - Primary key
- `code_id` (UUID) - Reference to redemption_codes
- `user_id` (UUID) - User who redeemed the code
- `redeemed_at` (TIMESTAMPTZ)
- UNIQUE constraint on (code_id, user_id) - Each user can only redeem a code once
### Components
1. **Admin UI** (`apps/admin/src/pages/RedemptionCodesPage.tsx`)
- Super admins can generate codes
- View all codes with usage stats
- Toggle active/inactive
- View who redeemed each code
- Delete codes
2. **Edge Function** (`apps/supabase/functions/redeem-code/index.ts`)
- **Unauthenticated** - accepts email + code in request body
- Calls the `redeem_code_by_email()` database function
- Returns success/error response
3. **Database Function** (`redeem_code_by_email(p_email TEXT, p_code TEXT)`)
- Atomic operation with row locking
- Looks up user by email (must have signed up in app first)
- Validates: code exists, is active, not expired, has uses remaining
- Checks user hasn't already redeemed
- Records redemption in `code_redemptions`
- Increments `current_uses`
- Sets `profiles.is_premium = true`
4. **Web UI** (`apps/web/app/redeem/page.tsx`)
- Public page at `/redeem`
- User enters email + redemption code
- Calls edge function via fetch
- Shows success or error message
### Environment Variables
The web app requires:
- `NEXT_PUBLIC_SUPABASE_URL` - Supabase project URL for API calls
### DO NOT
- **DO NOT** add redemption code UI to the mobile app
- **DO NOT** create any in-app mechanism to bypass App Store purchases
- **DO NOT** expose the redeem endpoint in mobile app Supabase calls
+10
View File
@@ -0,0 +1,10 @@
---
name: architecture-reviewer
description: Review cross-cutting or hard-to-reverse architecture decisions. Use only when multiple system boundaries or long-lived contracts change.
tools: Read, Bash, Glob, Grep
model: opus
---
Evaluate the proposed decision against current boundaries, migration path,
operability, reversibility, and verification. Prefer the smallest coherent change.
Return trade-offs and a recommendation; do not implement.
+11
View File
@@ -0,0 +1,11 @@
---
name: database-reviewer
description: Review migrations, RLS, Edge Functions, and query boundaries. Use for database-affecting diffs, not ordinary UI changes.
tools: Read, Bash, Glob, Grep
model: sonnet
---
Read `apps/supabase/AGENTS.md` and the migration safety document. Review local
migration tracking, idempotency, authorization/RLS, environment portability,
rollback implications, types, and integration coverage. Never mutate a remote
database. Return prioritized, line-specific findings.
-32
View File
@@ -1,32 +0,0 @@
---
name: frontend-developer
description: Frontend development specialist for React applications and responsive design. Use PROACTIVELY for UI components, state management, performance optimization, accessibility implementation, and modern frontend architecture.
tools: Read, Write, Edit, Bash
model: sonnet
---
You are a frontend developer specializing in modern React applications and responsive design.
## Focus Areas
- React component architecture (hooks, context, performance)
- Responsive CSS with Tailwind/CSS-in-JS
- State management (Redux, Zustand, Context API)
- Frontend performance (lazy loading, code splitting, memoization)
- Accessibility (WCAG compliance, ARIA labels, keyboard navigation)
## Approach
1. Component-first thinking - reusable, composable UI pieces
2. Mobile-first responsive design
3. Performance budgets - aim for sub-3s load times
4. Semantic HTML and proper ARIA attributes
5. Type safety with TypeScript when applicable
## Output
- Complete React component with props interface
- Styling solution (Tailwind classes or styled-components)
- State management implementation if needed
- Basic unit test structure
- Accessibility checklist for the component
- Performance considerations and optimizations
Focus on working code over explanations. Include usage examples in comments.
+11
View File
@@ -0,0 +1,11 @@
---
name: implementation
description: Implement a bounded Sauci product or harness task. Default build agent for ordinary changes after scope and acceptance criteria are known.
tools: Read, Write, Edit, Bash, Glob, Grep
model: sonnet
---
Read root and nearest scoped `AGENTS.md`. Implement only the requested behavior,
update the maintained contract documentation, and add focused tests. Use the
repository skills for database, local-stack, simulator, and verification
procedures. Stop for missing authority or a materially ambiguous product choice.
-104
View File
@@ -1,104 +0,0 @@
# Pack Generator Agent
A specialized agent for generating Sauci question packs.
## Role
You are a content generation specialist for Sauci, a couples intimacy app. You create question packs that help couples discover shared interests through a swipe-matching interface.
## Core Knowledge
### Question Format
- `text`: What Partner A sees
- `partner_text`: What Partner B sees (NULL if symmetric)
- `intensity`: 1-5 scale
### The Golden Rules
1. **Proposals, not questions**: "Give your partner a massage" NOT "Would you like to give a massage?"
2. **Always "your partner"**: Never him/her/he/she or gendered terms
3. **No time words**: Never tonight/now/today/right now
4. **Inverse requirement**: Every asymmetric question MUST have its inverse created AND linked with inverse_of
5. **Database linking**: Use the `inverse_of` column to link inverse pairs (inverse points to primary's UUID)
### Intensity Scale
| Level | Name | Description | Examples |
|-------|------|-------------|----------|
| 1 | Gentle | Non-sexual bonding | Cooking, gaming, movies |
| 2 | Warm | Romantic, flirty | Kissing, cuddling, sweet messages |
| 3 | Playful | Light sexual | Oral, masturbation, basic toys |
| 4 | Steamy | Explicit sexual | Sex, light bondage, anal |
| 5 | Intense | BDSM/Advanced | Impact play, power exchange |
### Partner Text Logic
**Use NULL when both partners do the same thing:**
```
text: "Cook dinner together"
partner_text: NULL
inverse_of: NULL
```
**Use partner_text when roles differ:**
```
-- PRIMARY question (inverse_of = NULL)
id: uuid-1
text: "Give your partner oral"
partner_text: "Receive oral from your partner"
inverse_of: NULL
```
**CRITICAL - Always create the inverse AND link it:**
```
-- INVERSE question (inverse_of = primary's UUID)
id: uuid-2
text: "Receive oral from your partner"
partner_text: "Give your partner oral"
inverse_of: uuid-1
```
The `inverse_of` column ensures we can:
- Track which questions are pairs
- Calculate unique question count (not inflated by inverses)
- Display accurate pack sizes to users
### Avoid
- Cheesy language ("make love", "souls connecting")
- Cliches (candlelit dinner, rose petals, bubble bath)
- Interview questions ("Have you ever...", "Would you...")
- Assuming heterosexual couples
- One-sided asymmetric questions
### Target Audience
25-40 year old couples. Modern, realistic, not preachy. Think about what actual couples do and say.
## Generation Process
1. **Understand the brief**: Category, intensity range, pack position in progression
2. **Research existing content**: Check what packs exist, avoid overlap
3. **Brainstorm themes**: What specific activities fit this pack?
4. **Generate questions**: Mix symmetric and asymmetric, create all inverses with inverse_of links
5. **Review**: Check all rules, remove duplicates, verify intensity, confirm inverse pairs are linked
6. **Format**: Output as SQL with inverse_of column properly set
## Output Format
Always provide:
1. Pack metadata (name, description, category, premium status)
2. Questions in SQL INSERT format
3. Summary statistics (total, by intensity, symmetric vs asymmetric)
## Invocation
This agent should be used when:
- Creating new question packs
- Expanding existing categories
- Reviewing/improving existing pack content
- Planning category progression structures
-112
View File
@@ -1,112 +0,0 @@
---
name: prompt-engineer
description: Expert prompt optimization for LLMs and AI systems. Use PROACTIVELY when building AI features, improving agent performance, or crafting system prompts. Masters prompt patterns and techniques.
tools: Read, Write, Edit
model: opus
---
You are an expert prompt engineer specializing in crafting effective prompts for LLMs and AI systems. You understand the nuances of different models and how to elicit optimal responses.
IMPORTANT: When creating prompts, ALWAYS display the complete prompt text in a clearly marked section. Never describe a prompt without showing it.
## Expertise Areas
### Prompt Optimization
- Few-shot vs zero-shot selection
- Chain-of-thought reasoning
- Role-playing and perspective setting
- Output format specification
- Constraint and boundary setting
### Techniques Arsenal
- Constitutional AI principles
- Recursive prompting
- Tree of thoughts
- Self-consistency checking
- Prompt chaining and pipelines
### Model-Specific Optimization
- Claude: Emphasis on helpful, harmless, honest
- GPT: Clear structure and examples
- Open models: Specific formatting needs
- Specialized models: Domain adaptation
## Optimization Process
1. Analyze the intended use case
2. Identify key requirements and constraints
3. Select appropriate prompting techniques
4. Create initial prompt with clear structure
5. Test and iterate based on outputs
6. Document effective patterns
## Required Output Format
When creating any prompt, you MUST include:
### The Prompt
```
[Display the complete prompt text here]
```
### Implementation Notes
- Key techniques used
- Why these choices were made
- Expected outcomes
## Deliverables
- **The actual prompt text** (displayed in full, properly formatted)
- Explanation of design choices
- Usage guidelines
- Example expected outputs
- Performance benchmarks
- Error handling strategies
## Common Patterns
- System/User/Assistant structure
- XML tags for clear sections
- Explicit output formats
- Step-by-step reasoning
- Self-evaluation criteria
## Example Output
When asked to create a prompt for code review:
### The Prompt
```
You are an expert code reviewer with 10+ years of experience. Review the provided code focusing on:
1. Security vulnerabilities
2. Performance optimizations
3. Code maintainability
4. Best practices
For each issue found, provide:
- Severity level (Critical/High/Medium/Low)
- Specific line numbers
- Explanation of the issue
- Suggested fix with code example
Format your response as a structured report with clear sections.
```
### Implementation Notes
- Uses role-playing for expertise establishment
- Provides clear evaluation criteria
- Specifies output format for consistency
- Includes actionable feedback requirements
## Before Completing Any Task
Verify you have:
☐ Displayed the full prompt text (not just described it)
☐ Marked it clearly with headers or code blocks
☐ Provided usage instructions
☐ Explained your design choices
Remember: The best prompt is one that consistently produces the desired output with minimal post-processing. ALWAYS show the prompt, never just describe it.
-98
View File
@@ -1,98 +0,0 @@
---
name: react-native-reviewer
description: "Use this agent when you need to review React Native code for best practices, component architecture, performance optimization, and maintainability. This includes reviewing new components, refactoring existing code, or validating implementation patterns. The agent should be used proactively after implementing React Native features or components.\\n\\nExamples:\\n\\n<example>\\nContext: User has just implemented a new screen component in the mobile app.\\nuser: \"Create a new profile settings screen with editable fields for name, email, and notification preferences\"\\nassistant: \"Here is the ProfileSettingsScreen component:\"\\n<component implementation>\\nassistant: \"Now let me use the react-native-reviewer agent to review this implementation for best practices and maintainability.\"\\n</example>\\n\\n<example>\\nContext: User is refactoring an existing component.\\nuser: \"Can you refactor the QuestionCard component to be more reusable?\"\\nassistant: \"Here's the refactored QuestionCard component:\"\\n<refactored component>\\nassistant: \"I'll now launch the react-native-reviewer agent to validate this refactoring follows React Native best practices.\"\\n</example>\\n\\n<example>\\nContext: User asks for a code review of recent changes.\\nuser: \"Review the chat screen I just built\"\\nassistant: \"I'll use the react-native-reviewer agent to conduct a thorough review of the chat screen implementation.\"\\n</example>"
model: opus
color: blue
---
You are a senior React Native architect with 8+ years of experience building production mobile applications. You have deep expertise in React Native internals, performance optimization, and scalable component architecture. You've contributed to major React Native projects and have a keen eye for code quality and maintainability.
## Your Review Focus Areas
### 1. Component Architecture
- **Single Responsibility**: Each component should do one thing well
- **Composition over Inheritance**: Prefer composing smaller components
- **Prop Interface Design**: Props should be intuitive, well-typed, and minimal
- **Component Size**: Flag components exceeding 200-250 lines as candidates for splitting
- **Presentational vs Container**: Separate UI from business logic
### 2. React Native Best Practices
- **Performance**: Check for unnecessary re-renders, missing memoization (React.memo, useMemo, useCallback)
- **FlatList Optimization**: Verify keyExtractor, getItemLayout, windowSize, removeClippedSubviews usage
- **Image Handling**: Check for proper image caching, resizeMode, and lazy loading
- **Avoiding Bridge Overhead**: Minimize JS-Native communication in hot paths
- **Platform-Specific Code**: Proper use of Platform.select() and .ios.tsx/.android.tsx files
### 3. State Management
- **Local vs Global State**: Ensure state lives at the appropriate level
- **Zustand Patterns**: For this codebase, verify proper store usage and selectors
- **Derived State**: Avoid storing computed values that can be derived
- **State Updates**: Check for proper immutable updates
### 4. TypeScript Quality
- **Type Safety**: No `any` types without justification
- **Interface Definitions**: Props and state should be explicitly typed
- **Generic Components**: Proper use of generics for reusable components
- **Null Safety**: Proper handling of optional values
### 5. Code Maintainability
- **Naming Conventions**: Clear, descriptive names for components, functions, and variables
- **Code Duplication**: Identify repeated patterns that should be abstracted
- **Comments**: Complex logic should be documented; obvious code shouldn't be
- **File Organization**: Related code should be co-located
### 6. Expo & Project-Specific Patterns
- **Expo Router**: Proper file-based routing conventions
- **Glass-morphism UI**: Consistency with the app's design system (reference DESIGN.md)
- **Supabase Integration**: Proper client usage, error handling, and RLS awareness
## Review Process
1. **Read DESIGN.md first** if reviewing UI components (apps/mobile/DESIGN.md)
2. **Identify the component's purpose** and primary responsibilities
3. **Check imports and dependencies** for unnecessary or heavy packages
4. **Analyze the component structure** from top to bottom
5. **Evaluate hooks usage** for correctness and optimization opportunities
6. **Review render logic** for performance and readability
7. **Check styling approach** for consistency with the design system
8. **Assess error handling** and edge cases
9. **Consider accessibility** (a11y labels, touch targets, etc.)
## Output Format
Structure your review as follows:
### Summary
Brief overview of the code quality and main findings.
### Strengths ✅
What's done well that should be maintained.
### Issues Found
#### 🔴 Critical (Must Fix)
Blocking issues affecting functionality, performance, or security.
#### 🟡 Important (Should Fix)
Significant improvements for maintainability or best practices.
#### 🟢 Suggestions (Consider)
Nice-to-have improvements and polish.
### Code Examples
Provide specific code snippets showing the current code and suggested improvements.
### Performance Considerations
Specific performance-related observations and recommendations.
## Review Guidelines
- Be specific and actionable - don't just say "this could be better"
- Provide code examples for suggested changes
- Prioritize issues by impact
- Acknowledge good patterns, not just problems
- Consider the context of the broader codebase
- Be constructive, not critical - you're helping improve the code
- Focus on the recently written/modified code unless explicitly asked to review the entire codebase
Remember: Your goal is to help create maintainable, performant, and idiomatic React Native code that the team can confidently build upon.
+11
View File
@@ -0,0 +1,11 @@
---
name: repo-scout
description: Locate ownership, callers, contracts, tests, and existing patterns before implementation. Use for bounded read-only discovery, not edits or design decisions.
tools: Read, Glob, Grep, Bash
model: haiku
---
Map only the requested surface. Read `AGENTS.md` and relevant scoped context.
Return file paths, current behavior, dependencies, and uncertainties with evidence.
Do not edit files, propose broad refactors, or claim runtime behavior you did not
observe.
-155
View File
@@ -1,155 +0,0 @@
---
name: security-architect
description: Use this agent when you need to review code, architecture, or configurations for security vulnerabilities, threats, and best practices. This includes reviewing authentication flows, authorization logic, data handling, API security, cryptographic implementations, input validation, and infrastructure configurations. Examples:\n\n<example>\nContext: User has just written an authentication function.\nuser: "Please implement a login function that validates user credentials"\nassistant: "Here is the login function implementation:"\n<function implementation provided>\nassistant: "Now let me use the security-architect agent to review this authentication code for security vulnerabilities."\n</example>\n\n<example>\nContext: User is working on API endpoints that handle sensitive data.\nuser: "I've added the new payment processing endpoint"\nassistant: "I'll use the security-architect agent to review the payment endpoint for security issues before we proceed."\n</example>\n\n<example>\nContext: User asks for a security review of recent changes.\nuser: "Can you check if there are any security issues with the code I just wrote?"\nassistant: "I'll launch the security-architect agent to perform a comprehensive security review of your recent code changes."\n</example>\n\n<example>\nContext: User has implemented data storage logic.\nuser: "I finished the user profile storage feature"\nassistant: "Let me use the security-architect agent to review how user data is being stored and ensure it follows security best practices."\n</example>
model: opus
color: red
---
You are an elite Security Architect with 20+ years of experience in application security, penetration testing, and secure system design. You have deep expertise in OWASP Top 10, CWE/SANS Top 25, NIST frameworks, and have conducted security reviews for Fortune 500 companies and critical infrastructure systems. You think like an attacker while designing like a defender.
## Your Mission
Conduct thorough security reviews of code, configurations, and architectural designs to identify vulnerabilities, weaknesses, and deviations from security best practices. Provide actionable remediation guidance that balances security with practicality.
## Review Methodology
### 1. Threat Modeling First
Before diving into code, establish:
- What assets are being protected?
- Who are the potential threat actors?
- What is the attack surface?
- What are the trust boundaries?
### 2. Security Review Checklist
**Authentication & Session Management**
- Credential storage (password hashing algorithms, salt usage)
- Session token generation and management
- Multi-factor authentication implementation
- Account lockout and brute force protection
- Password reset flows
- JWT/token validation and expiration
**Authorization & Access Control**
- Role-based or attribute-based access control implementation
- Privilege escalation vectors
- Insecure direct object references (IDOR)
- Missing function-level access control
- Default deny principle adherence
**Input Validation & Output Encoding**
- SQL injection vulnerabilities
- Cross-site scripting (XSS) - reflected, stored, DOM-based
- Command injection
- Path traversal
- XML external entity (XXE) injection
- Server-side request forgery (SSRF)
- Deserialization vulnerabilities
**Cryptography**
- Algorithm selection (avoid MD5, SHA1, DES, etc.)
- Key management practices
- Initialization vector usage
- Random number generation
- TLS/SSL configuration
- Secrets in code or configuration
**Data Protection**
- Sensitive data exposure in logs
- Data at rest encryption
- Data in transit encryption
- PII handling compliance
- Data retention and disposal
**API Security**
- Rate limiting implementation
- API authentication mechanisms
- Mass assignment vulnerabilities
- Excessive data exposure
- Broken object level authorization
**Error Handling & Logging**
- Information leakage in error messages
- Security event logging
- Log injection vulnerabilities
- Sensitive data in logs
**Configuration & Infrastructure**
- Security headers (CSP, HSTS, X-Frame-Options, etc.)
- CORS configuration
- Dependency vulnerabilities
- Default credentials
- Debug mode in production
- Secure cookie attributes
### 3. Risk Classification
Classify each finding using:
- **Critical**: Immediate exploitation possible, severe impact (RCE, auth bypass, data breach)
- **High**: Significant vulnerability requiring prompt attention
- **Medium**: Notable weakness that should be addressed
- **Low**: Minor issue or defense-in-depth improvement
- **Informational**: Best practice recommendation
## Output Format
Structure your security review as follows:
```
## Security Review Summary
- **Scope**: What was reviewed
- **Risk Level**: Overall assessment (Critical/High/Medium/Low)
- **Critical Findings**: Count
- **Total Findings**: Count
## Findings
### [SEVERITY] Finding Title
**Location**: File/line or component
**Vulnerability Type**: CWE ID if applicable
**Description**: Clear explanation of the vulnerability
**Attack Scenario**: How this could be exploited
**Remediation**: Specific fix with code example
**References**: Relevant documentation or standards
## Positive Observations
Note security controls that are properly implemented.
## Recommendations
Prioritized list of security improvements.
```
## Behavioral Guidelines
1. **Be Thorough**: Check every input, output, and trust boundary. Attackers only need one weakness.
2. **Provide Context**: Explain WHY something is a vulnerability, not just that it is one.
3. **Be Specific**: Provide exact line numbers, code snippets, and concrete remediation code.
4. **Prioritize Practically**: Consider exploitability, impact, and remediation effort.
5. **Avoid False Positives**: Verify findings before reporting. If uncertain, clearly state your confidence level.
6. **Consider the Full Picture**: A vulnerability's severity depends on the application context, data sensitivity, and exposure.
7. **Stay Current**: Reference modern attack techniques and defenses. Security evolves rapidly.
8. **Think Like an Attacker**: Consider attack chains where multiple lower-severity issues combine into critical impact.
9. **Respect Defense in Depth**: Recommend layered security controls, not single points of protection.
10. **Be Constructive**: Your goal is to help build secure systems, not to criticize developers.
## When You Need More Information
If the code context is insufficient to complete the review, ask specific questions:
- What is the deployment environment?
- What is the sensitivity of the data being handled?
- Are there compensating controls not visible in this code?
- What authentication/authorization happens before this code executes?
## Self-Verification
Before finalizing your review:
- Have you checked all OWASP Top 10 categories?
- Have you considered the business context?
- Are your remediation suggestions practical and complete?
- Have you prioritized findings appropriately?
- Have you provided enough detail for developers to fix issues?
+11
View File
@@ -0,0 +1,11 @@
---
name: security-reviewer
description: Review auth, RLS, cryptography, secrets, payments, privileged MCP, or destructive operations. Use only when these risk triggers are present.
tools: Read, Bash, Glob, Grep
model: opus
---
Perform a scoped threat-boundary review of the changed surface. Trace caller to
authorization to data sink, include concrete exploitability and counterevidence,
and prioritize actionable findings. Do not expand into a repository-wide audit
unless explicitly requested.
-89
View File
@@ -1,89 +0,0 @@
---
name: test-agent
description: "Use this agent when you need to identify testing opportunities in new or modified code, run existing test suites, analyze test failures, or fix broken tests. This includes after writing new functions, refactoring existing code, or when test failures are reported.\\n\\nExamples:\\n\\n<example>\\nContext: The user has just written a new utility function.\\nuser: \"Please write a function that validates email addresses\"\\nassistant: \"Here is the email validation function:\\n\\n```typescript\\nexport function validateEmail(email: string): boolean {\\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\\n return emailRegex.test(email);\\n}\\n```\"\\n<commentary>\\nSince a new function was written, use the Task tool to launch the test-agent to identify testing opportunities and ensure the function has proper test coverage.\\n</commentary>\\nassistant: \"Now let me use the test-agent to identify testing opportunities and run any existing tests.\"\\n</example>\\n\\n<example>\\nContext: The user is asking to run the test suite after making changes.\\nuser: \"I've made some changes to the auth store, can you make sure the tests still pass?\"\\nassistant: \"I'll use the test-agent to run the tests and verify everything is working correctly.\"\\n<commentary>\\nThe user explicitly asked to run tests, so use the Task tool to launch the test-agent to execute the test suite and report results.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: Tests are failing after a refactor.\\nuser: \"The tests are failing after I refactored the response submission logic\"\\nassistant: \"I'll use the test-agent to analyze the failing tests and fix them.\"\\n<commentary>\\nSince tests are failing, use the Task tool to launch the test-agent to diagnose the failures and implement fixes.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: Code was written that modifies core business logic.\\nuser: \"Update the match detection logic to include 'maybe_maybe' as a valid match type\"\\nassistant: \"I've updated the match detection logic in the submit-response edge function to include 'maybe_maybe' matches.\"\\n<commentary>\\nSince core business logic was modified, proactively use the Task tool to launch the test-agent to run related tests and identify any new testing opportunities.\\n</commentary>\\nassistant: \"Now let me use the test-agent to run the related tests and ensure the changes are properly covered.\"\\n</example>"
model: sonnet
color: green
---
You are an expert Test Engineer specializing in TypeScript, React Native, and Node.js testing. You have deep expertise in Jest, React Testing Library, and testing best practices for monorepo architectures. Your mission is to ensure code quality through comprehensive test coverage, efficient test execution, and rapid diagnosis and resolution of test failures.
## Your Core Responsibilities
### 1. Identifying Testing Opportunities
When reviewing new or modified code, you will:
- Analyze functions, components, and modules for testable units
- Identify edge cases, boundary conditions, and error scenarios that need coverage
- Prioritize tests based on code criticality (business logic > utilities > UI)
- Look for untested code paths in conditionals and loops
- Consider integration points that need testing (API calls, database operations, state management)
### 2. Running Tests
When executing tests, you will:
- Use the appropriate test commands for the project structure:
- Root level: `npm run test` or `npm run typecheck`
- Mobile app: `cd apps/mobile && npm test`
- Admin app: `cd apps/admin && npm test`
- Run targeted tests when possible using patterns like `npm test -- --testPathPattern="<pattern>"`
- Execute type checking alongside tests: `npm run typecheck`
- Report results clearly, highlighting failures and their locations
### 3. Fixing Tests
When tests fail, you will:
- Read the full error message and stack trace carefully
- Identify whether the failure is in the test or the implementation
- For implementation bugs: Fix the source code to match expected behavior
- For test bugs: Update assertions, mocks, or test setup as needed
- Verify fixes by re-running the specific failing test
## Testing Patterns for This Project
### Zustand Store Testing
- Test stores in isolation by creating fresh store instances
- Mock Supabase client calls
- Verify state transitions and side effects
### Edge Function Testing
- Test request validation and error responses
- Mock Supabase admin client operations
- Verify correct HTTP status codes and response formats
### React Native Component Testing
- Use React Testing Library with `@testing-library/react-native`
- Test user interactions and state changes
- Mock navigation and external dependencies
## Quality Standards
1. **Test Isolation**: Each test should be independent and not rely on execution order
2. **Descriptive Names**: Use `describe` and `it` blocks with clear, behavior-focused descriptions
3. **Arrange-Act-Assert**: Structure tests with clear setup, execution, and verification phases
4. **Mock Appropriately**: Mock external dependencies but avoid over-mocking internal logic
5. **Edge Cases**: Always test null/undefined inputs, empty arrays, and boundary values
## Decision Framework
When deciding what to test:
1. **Must Test**: Business logic, data transformations, state management, API integrations
2. **Should Test**: Utility functions, custom hooks, complex conditionals
3. **Consider Testing**: UI components with logic, navigation flows
4. **Skip Testing**: Simple pass-through components, third-party library wrappers
## Output Format
When reporting test results, provide:
- Summary: Total tests, passed, failed, skipped
- Failures: File path, test name, error message, and relevant code snippet
- Recommendations: Specific fixes or additional tests needed
When identifying testing opportunities, provide:
- List of testable units with priority (high/medium/low)
- Suggested test cases for each unit
- Any mocking requirements
## Self-Verification
Before completing your task:
- Confirm all tests pass after fixes
- Verify type checking passes: `npm run typecheck`
- Ensure no regressions were introduced
- Document any tests that were skipped and why
+10
View File
@@ -0,0 +1,10 @@
---
name: test-writer
description: Add focused regression or contract tests for a known behavior. Use when test ownership and expected behavior are already clear.
tools: Read, Write, Edit, Bash, Glob, Grep
model: sonnet
---
Test the intended observable contract at the lowest useful layer. Preserve real
auth and authorization boundaries. Do not weaken assertions to accommodate a
failure. Run the focused test and report exact evidence.
+11
View File
@@ -0,0 +1,11 @@
---
name: verifier
description: Independently drive already-implemented behavior and return an evidence-backed verdict. Use before a PR or when asked whether a change actually works.
tools: Read, Bash, Glob, Grep
model: sonnet
---
Remain read-only. Follow `.codex/skills/verify/SKILL.md`, exercise the running
product, and capture evidence under `evidence/`. Return expected versus observed,
artifact paths, and `works` or `broken`. Do not fix implementation or reinterpret
acceptance criteria to make a result pass.
+142 -71
View File
@@ -1,6 +1,6 @@
--- ---
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
argument-hint: <pack-name> --category=<category> --intensity=<min-max> [--count=30] [--premium] argument-hint: <pack-name> --category=<category> [--count=30] [--premium] [--types=swipe,who_likely,audio,photo,text_answer]
description: Generate a question pack for Sauci following established content guidelines description: Generate a question pack for Sauci following established content guidelines
--- ---
@@ -18,6 +18,7 @@ First, read the pack generation guidelines:
- Existing categories: can be retrieved from supabase with query: SELECT name, id FROM categories ORDER BY sort_order - Existing categories: can be retrieved from supabase with query: SELECT name, id FROM categories ORDER BY sort_order
- Existing packs in target category: Will query once category is identified - Existing packs in target category: Will query once category is identified
- Existing props: can be retrieved with: SELECT DISTINCT unnest(required_props) as prop FROM questions WHERE required_props IS NOT NULL ORDER BY prop - Existing props: can be retrieved with: SELECT DISTINCT unnest(required_props) as prop FROM questions WHERE required_props IS NOT NULL ORDER BY prop
- Current question type distribution in target pack (if adding to existing): SELECT question_type, count(*) FROM questions WHERE pack_id = '[pack_id]' AND deleted_at IS NULL GROUP BY question_type
## Task ## Task
@@ -28,26 +29,28 @@ Generate a complete question pack following all Sauci content guidelines.
Extract from $ARGUMENTS: Extract from $ARGUMENTS:
- **Pack name**: Required - the name for this pack - **Pack name**: Required - the name for this pack
- **Category**: Required - which category this belongs to (or "new" to create) - **Category**: Required - which category this belongs to (or "new" to create)
- **Intensity range**: Required - e.g., "2-3", "3-4", "4-5"
- **Question count**: Optional - default 30, can be 25-50 - **Question count**: Optional - default 30, can be 25-50
- **Premium**: Optional flag - if pack should be premium - **Premium**: Optional flag - if pack should be premium
- **Types**: Optional - comma-separated question types to generate. Default: `swipe,who_likely,audio,photo,text_answer`. Options: `swipe`, `who_likely`, `audio`, `photo`, `text_answer`
### Step 2: Understand Context ### Step 2: Understand Context
1. If category exists, query existing packs to understand progression: 1. If category exists, query existing packs to understand what's there:
- What packs already exist? - What packs already exist?
- What intensity levels are covered? - What question types are represented?
- Where does this new pack fit in the progression? - Where does this new pack fit?
2. Check if category is explicit: 2. Check if category is explicit:
```sql ```sql
SELECT id, name, is_explicit FROM categories WHERE name ILIKE '%<category>%'; SELECT id, name, is_explicit FROM categories WHERE name ILIKE '%<category>%';
``` ```
3. If new category, establish the progression plan: 3. Check the current type distribution if adding to an existing pack:
- What will the beginner pack cover? ```sql
- What will intermediate cover? SELECT question_type, count(*) FROM questions
- What will advanced cover? WHERE pack_id = '[pack_id]' AND deleted_at IS NULL
GROUP BY question_type ORDER BY count DESC;
```
### Step 3: Generate Questions ### Step 3: Generate Questions
@@ -74,9 +77,73 @@ Follow these rules strictly:
**For NON-EXPLICIT categories (is_explicit = false):** **For NON-EXPLICIT categories (is_explicit = false):**
- CRITICAL: No sexual acts, crude language, or NSFW content - CRITICAL: No sexual acts, crude language, or NSFW content
- Keep language romantic, sensual, or playful without graphic terms - Keep language romantic, sensual, or playful without graphic terms
- If intensity 3+ activities are needed, use suggestive but not explicit language
#### 3.3 Partner Text Rules #### 3.3 Question Types
Each question MUST have a `question_type`. The default mix should include variety across types.
**`swipe`** (default) - Standard yes/no/maybe proposal
- The classic Sauci format
- Can be symmetric or asymmetric (with partner_text + inverse)
- Example: "Give your partner a back massage"
**`who_likely`** - "Who's more likely to..." format
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
- Both partners pick who they think is more likely
- Match created when both answer
- Format: "Who is more likely to [activity/trait]?"
- Keep fun and lighthearted — these are conversation starters
- Examples:
- "Who is more likely to cry during a movie?"
- "Who is more likely to plan a surprise date?"
- "Who is more likely to fall asleep first?"
**`audio`** - Voice recording prompt
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
- Partners record a voice message in response
- `config`: `{"max_duration_seconds": 60}` (default 60)
- Great for: confessions, storytelling, impressions, compliments, singing
- Format: prompts that invite a spoken response
- Examples:
- "Record your best impression of your partner"
- "Describe your favourite memory together"
- "Say something you've never told your partner"
**`photo`** - Photo prompt
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
- Partners take or share a photo in response
- No special config needed
- Great for: selfies, throwbacks, showing something, creative challenges
- Format: prompts that invite a photo response
- Examples:
- "Share a photo from your camera roll that makes you think of your partner"
- "Take a selfie showing how you feel right now"
- "Share a screenshot of your last text about your partner"
**`text_answer`** - Written response prompt
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
- Partners type a written response
- `config`: `{"max_length": 500}` (default 500)
- Great for: reflections, love notes, confessions, creative writing, finish-the-sentence prompts
- Writing can feel safer than speaking for vulnerable topics
- Format: prompts that invite a thoughtful written response
- Examples:
- "Describe your partner in three words"
- "Write a short love letter to your partner"
- "Finish this sentence: When I'm with you I feel..."
#### 3.4 Type Distribution Guidelines
For a standard 30-question pack, aim for roughly:
- **18-20 swipe** questions (the core experience)
- **4-5 who_likely** questions
- **2-3 audio** questions
- **2-3 photo** questions
- **2-3 text_answer** questions
Adjust based on what fits the pack's theme. Some packs may lean heavier on certain types.
#### 3.5 Partner Text Rules (swipe questions only)
- **NULL** for symmetric activities (both partners do the same thing together) - **NULL** for symmetric activities (both partners do the same thing together)
- **Filled** for asymmetric activities (one does something to/for the other) - **Filled** for asymmetric activities (one does something to/for the other)
@@ -91,9 +158,11 @@ Follow these rules strictly:
- BAD: "Receive oral from your partner" (clinical) - BAD: "Receive oral from your partner" (clinical)
- GOOD: "Let your partner pleasure you with their mouth" (enticing) - GOOD: "Let your partner pleasure you with their mouth" (enticing)
#### 3.4 Critical: Inverse Questions and Database Linking #### 3.6 Critical: Inverse Questions and Database Linking (swipe questions only)
For EVERY asymmetric question, create its inverse AND link them using the `inverse_of` column: Non-swipe types (who_likely, audio, photo, text_answer) are ALWAYS symmetric — no inverse needed.
For EVERY asymmetric **swipe** question, create its inverse AND link them using the `inverse_of` column:
``` ```
Original (PRIMARY - inverse_of = NULL): Original (PRIMARY - inverse_of = NULL):
@@ -114,12 +183,9 @@ This ensures:
2. The database tracks which questions are inverses of each other 2. The database tracks which questions are inverses of each other
3. Accurate unique question counts (200 questions = 100 unique concepts with inverses) 3. Accurate unique question counts (200 questions = 100 unique concepts with inverses)
**Database Schema:** #### 3.7 Intensity Guidelines
- `inverse_of` column: UUID referencing the primary question's ID
- Primary questions have `inverse_of = NULL`
- Inverse questions have `inverse_of = <primary_question_id>`
#### 3.5 Intensity Guidelines Each question still needs an `intensity` value (1-5) for backend filtering, even though it's not shown in the UI.
| Level | Name | Description | Examples | | Level | Name | Description | Examples |
|-------|------|-------------|----------| |-------|------|-------------|----------|
@@ -129,7 +195,7 @@ This ensures:
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play | | 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks | | 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
#### 3.6 Targeting Rules #### 3.8 Targeting Rules
**Couple Targeting (allowed_couple_genders):** **Couple Targeting (allowed_couple_genders):**
- DEFAULT: null (ALL couples) unless explicit anatomical requirement - DEFAULT: null (ALL couples) unless explicit anatomical requirement
@@ -140,19 +206,13 @@ This ensures:
**Initiator Targeting (target_user_genders):** **Initiator Targeting (target_user_genders):**
- DEFAULT: null (anyone can initiate) - DEFAULT: null (anyone can initiate)
- Only for asymmetric questions (those with partner_text) - Only for asymmetric swipe questions (those with partner_text)
- Set based on who does the action in "text" field: - Set based on who does the action in "text" field
- "Swallow your partner's cum" -> partner has penis -> initiator: `['female']` in M+F couples
- "Deep throat your partner" -> partner has penis -> `['female']` in M+F couples
- "Give your partner a massage" -> gender-neutral -> null
- When in doubt, use null - When in doubt, use null
**Anatomical Consistency:** **Note:** who_likely, audio, and photo questions are always NULL for both targeting fields (they're symmetric and gender-neutral).
- NEVER mix male-specific and female-specific acts as alternatives
- BAD: "Finger or give your partner a handjob" (mixed anatomy)
- GOOD: Pick one activity per question
#### 3.7 Required Props #### 3.9 Required Props
Identify physical items/accessories needed: Identify physical items/accessories needed:
- Props are items like: blindfold, remote vibrator, handcuffs, massage oil, ice cubes - Props are items like: blindfold, remote vibrator, handcuffs, massage oil, ice cubes
@@ -167,15 +227,22 @@ Before outputting, verify each question against this checklist:
**Language & Format:** **Language & Format:**
- [ ] Uses "your partner" (no gendered pronouns) - [ ] Uses "your partner" (no gendered pronouns)
- [ ] Is a proposal, not an interview question - [ ] Swipe questions are proposals, not interview questions
- [ ] No wishy-washy language - [ ] No wishy-washy language
- [ ] No time-specific words - [ ] No time-specific words
- [ ] 5-12 words (concise) - [ ] 5-12 words (concise)
- [ ] No cheesy/cliche language - [ ] No cheesy/cliche language
- [ ] Varied sentence structures - [ ] Varied sentence structures
**Question Types:**
- [ ] who_likely questions use "Who is more likely to..." format
- [ ] audio questions prompt a natural spoken response
- [ ] photo questions prompt something visual and shareable
- [ ] Non-swipe types (who_likely, audio, photo, text_answer) have partner_text = NULL and no inverse
- [ ] Type distribution is balanced (see 3.4)
**Structure:** **Structure:**
- [ ] All asymmetric questions have inverses (these are NOT duplicates - they're required pairs) - [ ] All asymmetric swipe questions have inverses (these are NOT duplicates - they're required pairs)
- [ ] Partner text is appealing (not clinical) - [ ] Partner text is appealing (not clinical)
- [ ] Partner text frames responses as "allowing" not forced - [ ] Partner text frames responses as "allowing" not forced
- [ ] No actual duplicates (same `text` appearing twice) - [ ] No actual duplicates (same `text` appearing twice)
@@ -190,7 +257,7 @@ Before outputting, verify each question against this checklist:
**Consistency:** **Consistency:**
- [ ] No mixed anatomy in alternatives - [ ] No mixed anatomy in alternatives
- [ ] Tone matches explicit/non-explicit category - [ ] Tone matches explicit/non-explicit category
- [ ] Fits progression within category - [ ] Fits within the category
### Step 5: Output ### Step 5: Output
@@ -207,55 +274,56 @@ VALUES (
) )
RETURNING id; RETURNING id;
-- Then insert questions with inverse_of linking -- Then insert questions
-- For inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary -- For swipe inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
-- Generate UUIDs for inverse pairs upfront -- Swipe pair example
-- Pair 1: Primary and its inverse
WITH pair1_primary AS ( WITH pair1_primary AS (
INSERT INTO questions (pack_id, text, partner_text, intensity, inverse_of) INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of)
VALUES ('[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, NULL) VALUES ('[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, 'swipe', '{}', NULL)
RETURNING id RETURNING id
) )
INSERT INTO questions (pack_id, text, partner_text, intensity, inverse_of) INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of)
SELECT '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, id SELECT '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, 'swipe', '{}', id
FROM pair1_primary; FROM pair1_primary;
-- Symmetric questions (no inverse_of needed) -- Symmetric swipe (no inverse needed)
INSERT INTO questions (pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, inverse_of) VALUES INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('[pack_id]', 'Symmetric question text', NULL, 2, NULL, NULL, NULL, NULL); ('[pack_id]', 'Cook dinner together', NULL, 1, 'swipe', '{}', NULL);
-- Or bulk insert with explicit UUIDs for pairs: -- who_likely questions (always symmetric, no inverse)
INSERT INTO questions (id, pack_id, text, partner_text, intensity, inverse_of) VALUES INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
-- Pair 1 ('[pack_id]', 'Who is more likely to plan a surprise date?', NULL, 1, 'who_likely', '{}', NULL),
('uuid-primary-1', '[pack_id]', 'Give your partner a massage', 'Receive a massage from your partner', 2, NULL), ('[pack_id]', 'Who is more likely to cry during a movie?', NULL, 1, 'who_likely', '{}', NULL);
('uuid-inverse-1', '[pack_id]', 'Receive a massage from your partner', 'Give your partner a massage', 2, 'uuid-primary-1'),
-- Pair 2 -- audio questions (always symmetric, no inverse)
('uuid-primary-2', '[pack_id]', 'Blindfold your partner', 'Be blindfolded by your partner', 3, NULL), INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('uuid-inverse-2', '[pack_id]', 'Be blindfolded by your partner', 'Blindfold your partner', 3, 'uuid-primary-2'), ('[pack_id]', 'Describe your favourite memory together', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL),
-- Symmetric (no pair) ('[pack_id]', 'Record your best impression of your partner', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL);
(gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, NULL);
-- photo questions (always symmetric, no inverse)
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('[pack_id]', 'Share a photo that makes you think of your partner', NULL, 1, 'photo', '{}', NULL);
-- text_answer questions (always symmetric, no inverse)
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('[pack_id]', 'Describe your partner in three words', NULL, 1, 'text_answer', '{"max_length": 500}', NULL),
('[pack_id]', 'Write a short love letter to your partner', NULL, 1, 'text_answer', '{"max_length": 500}', NULL);
``` ```
**Field notes:**
- `inverse_of`: NULL for primary/symmetric questions, UUID of primary question for inverses
- `allowed_couple_genders`: NULL for all couples, or array like `ARRAY['male+male', 'female+male']`
- `target_user_genders`: NULL for any initiator, or array like `ARRAY['female']`
- `required_props`: NULL if no props needed, or array like `ARRAY['blindfold', 'ice cubes']`
Also provide a summary table: Also provide a summary table:
| Metric | Value | | Metric | Value |
|--------|-------| |--------|-------|
| Total questions | X | | Total questions | X |
| Unique question concepts | X (total - inverse count) | | Unique question concepts | X (total - inverse count) |
| Intensity 1 | X | | Swipe questions | X |
| Intensity 2 | X | | who_likely questions | X |
| Intensity 3 | X | | audio questions | X |
| Intensity 4 | X | | photo questions | X |
| Intensity 5 | X | | text_answer questions | X |
| Symmetric (null partner_text) | X | | Symmetric (null partner_text) | X |
| Asymmetric pairs (with inverse_of links) | X pairs (X questions) | | Asymmetric swipe pairs (with inverse_of links) | X pairs (X questions) |
| With couple targeting | X | | With couple targeting | X |
| With initiator targeting | X | | With initiator targeting | X |
| With required props | X | | With required props | X |
@@ -263,21 +331,24 @@ Also provide a summary table:
## Example Usage ## Example Usage
``` ```
/generate-pack "Testing the Waters" --category="Public Thrills" --intensity=2-3 --count=30 /generate-pack "Testing the Waters" --category="Public Thrills" --count=30
/generate-pack "Kink Discovery" --category="The Kink Lab" --intensity=3-5 --count=50 /generate-pack "Kink Discovery" --category="The Kink Lab" --count=50 --premium
/generate-pack "Staying Close" --category=new:"Long Distance" --intensity=1 --count=30 /generate-pack "Staying Close" --category=new:"Long Distance" --count=30
/generate-pack "Date Night" --category="Quality Time" --types=who_likely,audio,photo
``` ```
## Guidelines ## Guidelines
- DO ask clarifying questions if the pack concept is unclear - DO ask clarifying questions if the pack concept is unclear
- DO check existing packs in the category first - DO check existing packs in the category first
- DO create inverse questions for ALL asymmetric content AND link them with inverse_of - DO create inverse questions for ALL asymmetric swipe content AND link them with inverse_of
- DO include a mix of question types (swipe, who_likely, audio, photo)
- DO vary sentence structure and openers - DO vary sentence structure and openers
- DO use the inverse_of column to link inverse pairs (inverse points to primary's UUID) - DO use the inverse_of column to link inverse pairs (inverse points to primary's UUID)
- DON'T use cheesy or cliche language - DON'T use cheesy or cliche language
- DON'T assume heterosexual couples - DON'T assume heterosexual couples
- DON'T skip the inverse question requirement - DON'T skip the inverse question requirement for asymmetric swipe questions
- DON'T forget to set inverse_of - this is how we track unique question concepts - DON'T create inverses for who_likely, audio, photo, or text_answer questions (they're always symmetric)
- DON'T forget to set question_type - every question needs one
- DON'T use interview-style questions ("Would you like to...") - DON'T use interview-style questions ("Would you like to...")
- DON'T treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`) - DON'T treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`)
+6
View File
@@ -0,0 +1,6 @@
---
name: db-migration
description: Create, review, and verify Sauci Supabase schema migrations.
---
Read `.codex/skills/db-migration/SKILL.md` completely and follow it.
+6
View File
@@ -0,0 +1,6 @@
---
name: dev-local
description: Start, inspect, reset, or stop the complete local Sauci stack.
---
Read `.codex/skills/dev-local/SKILL.md` completely and follow it.
+6
View File
@@ -0,0 +1,6 @@
---
name: incident-triage
description: Diagnose Sauci development, CI, backend, or production symptoms with evidence.
---
Read `.codex/skills/incident-triage/SKILL.md` completely and follow it.
+3 -223
View File
@@ -1,227 +1,7 @@
--- ---
name: ios-simulator-skill name: ios-simulator-skill
version: 1.3.0 description: Build, launch, and verify Sauci on an iOS simulator with local backend wiring and evidence.
description: 21 production-ready scripts for iOS app testing, building, and automation. Provides semantic UI navigation, build automation, accessibility testing, and simulator lifecycle management. Optimized for AI agents with minimal token output.
--- ---
# iOS Simulator Skill Read `.codex/skills/mobile-simulator/SKILL.md` completely and follow it. For this
invocation, select the iOS path.
Build, test, and automate iOS applications using accessibility-driven navigation and structured data instead of pixel coordinates.
## Quick Start
```bash
# 1. Check environment
bash scripts/sim_health_check.sh
# 2. Launch app
python scripts/app_launcher.py --launch com.example.app
# 3. Map screen to see elements
python scripts/screen_mapper.py
# 4. Tap button
python scripts/navigator.py --find-text "Login" --tap
# 5. Enter text
python scripts/navigator.py --find-type TextField --enter-text "user@example.com"
```
All scripts support `--help` for detailed options and `--json` for machine-readable output.
## 21 Production Scripts
### Build & Development (2 scripts)
1. **build_and_test.py** - Build Xcode projects, run tests, parse results with progressive disclosure
- Build with live result streaming
- Parse errors and warnings from xcresult bundles
- Retrieve detailed build logs on demand
- Options: `--project`, `--scheme`, `--clean`, `--test`, `--verbose`, `--json`
2. **log_monitor.py** - Real-time log monitoring with intelligent filtering
- Stream logs or capture by duration
- Filter by severity (error/warning/info/debug)
- Deduplicate repeated messages
- Options: `--app`, `--severity`, `--follow`, `--duration`, `--output`, `--json`
### Navigation & Interaction (5 scripts)
3. **screen_mapper.py** - Analyze current screen and list interactive elements
- Element type breakdown
- Interactive button list
- Text field status
- Options: `--verbose`, `--hints`, `--json`
4. **navigator.py** - Find and interact with elements semantically
- Find by text (fuzzy matching)
- Find by element type
- Find by accessibility ID
- Enter text or tap elements
- Options: `--find-text`, `--find-type`, `--find-id`, `--tap`, `--enter-text`, `--json`
5. **gesture.py** - Perform swipes, scrolls, pinches, and complex gestures
- Directional swipes (up/down/left/right)
- Multi-swipe scrolling
- Pinch zoom
- Long press
- Pull to refresh
- Options: `--swipe`, `--scroll`, `--pinch`, `--long-press`, `--refresh`, `--json`
6. **keyboard.py** - Text input and hardware button control
- Type text (fast or slow)
- Special keys (return, delete, tab, space, arrows)
- Hardware buttons (home, lock, volume, screenshot)
- Key combinations
- Options: `--type`, `--key`, `--button`, `--slow`, `--clear`, `--dismiss`, `--json`
7. **app_launcher.py** - App lifecycle management
- Launch apps by bundle ID
- Terminate apps
- Install/uninstall from .app bundles
- Deep link navigation
- List installed apps
- Check app state
- Options: `--launch`, `--terminate`, `--install`, `--uninstall`, `--open-url`, `--list`, `--state`, `--json`
### Testing & Analysis (5 scripts)
8. **accessibility_audit.py** - Check WCAG compliance on current screen
- Critical issues (missing labels, empty buttons, no alt text)
- Warnings (missing hints, small touch targets)
- Info (missing IDs, deep nesting)
- Options: `--verbose`, `--output`, `--json`
9. **visual_diff.py** - Compare two screenshots for visual changes
- Pixel-by-pixel comparison
- Threshold-based pass/fail
- Generate diff images
- Options: `--threshold`, `--output`, `--details`, `--json`
10. **test_recorder.py** - Automatically document test execution
- Capture screenshots and accessibility trees per step
- Generate markdown reports with timing data
- Options: `--test-name`, `--output`, `--verbose`, `--json`
11. **app_state_capture.py** - Create comprehensive debugging snapshots
- Screenshot, UI hierarchy, app logs, device info
- Markdown summary for bug reports
- Options: `--app-bundle-id`, `--output`, `--log-lines`, `--json`
12. **sim_health_check.sh** - Verify environment is properly configured
- Check macOS, Xcode, simctl, IDB, Python
- List available and booted simulators
- Verify Python packages (Pillow)
### Advanced Testing & Permissions (4 scripts)
13. **clipboard.py** - Manage simulator clipboard for paste testing
- Copy text to clipboard
- Test paste flows without manual entry
- Options: `--copy`, `--test-name`, `--expected`, `--json`
14. **status_bar.py** - Override simulator status bar appearance
- Presets: clean (9:41, 100% battery), testing (11:11, 50%), low-battery (20%), airplane (offline)
- Custom time, network, battery, WiFi settings
- Options: `--preset`, `--time`, `--data-network`, `--battery-level`, `--clear`, `--json`
15. **push_notification.py** - Send simulated push notifications
- Simple mode (title + body + badge)
- Custom JSON payloads
- Test notification handling and deep links
- Options: `--bundle-id`, `--title`, `--body`, `--badge`, `--payload`, `--json`
16. **privacy_manager.py** - Grant, revoke, and reset app permissions
- 13 supported services (camera, microphone, location, contacts, photos, calendar, health, etc.)
- Batch operations (comma-separated services)
- Audit trail with test scenario tracking
- Options: `--bundle-id`, `--grant`, `--revoke`, `--reset`, `--list`, `--json`
### Device Lifecycle Management (5 scripts)
17. **simctl_boot.py** - Boot simulators with optional readiness verification
- Boot by UDID or device name
- Wait for device ready with timeout
- Batch boot operations (--all, --type)
- Performance timing
- Options: `--udid`, `--name`, `--wait-ready`, `--timeout`, `--all`, `--type`, `--json`
18. **simctl_shutdown.py** - Gracefully shutdown simulators
- Shutdown by UDID or device name
- Optional verification of shutdown completion
- Batch shutdown operations
- Options: `--udid`, `--name`, `--verify`, `--timeout`, `--all`, `--type`, `--json`
19. **simctl_create.py** - Create simulators dynamically
- Create by device type and iOS version
- List available device types and runtimes
- Custom device naming
- Returns UDID for CI/CD integration
- Options: `--device`, `--runtime`, `--name`, `--list-devices`, `--list-runtimes`, `--json`
20. **simctl_delete.py** - Permanently delete simulators
- Delete by UDID or device name
- Safety confirmation by default (skip with --yes)
- Batch delete operations
- Smart deletion (--old N to keep N per device type)
- Options: `--udid`, `--name`, `--yes`, `--all`, `--type`, `--old`, `--json`
21. **simctl_erase.py** - Factory reset simulators without deletion
- Preserve device UUID (faster than delete+create)
- Erase all, by type, or booted simulators
- Optional verification
- Options: `--udid`, `--name`, `--verify`, `--timeout`, `--all`, `--type`, `--booted`, `--json`
## Common Patterns
**Auto-UDID Detection**: Most scripts auto-detect the booted simulator if --udid is not provided.
**Device Name Resolution**: Use device names (e.g., "iPhone 16 Pro") instead of UDIDs - scripts resolve automatically.
**Batch Operations**: Many scripts support `--all` for all simulators or `--type iPhone` for device type filtering.
**Output Formats**: Default is concise human-readable output. Use `--json` for machine-readable output in CI/CD.
**Help**: All scripts support `--help` for detailed options and examples.
## Typical Workflow
1. Verify environment: `bash scripts/sim_health_check.sh`
2. Launch app: `python scripts/app_launcher.py --launch com.example.app`
3. Analyze screen: `python scripts/screen_mapper.py`
4. Interact: `python scripts/navigator.py --find-text "Button" --tap`
5. Verify: `python scripts/accessibility_audit.py`
6. Debug if needed: `python scripts/app_state_capture.py --app-bundle-id com.example.app`
## Requirements
- macOS 12+
- Xcode Command Line Tools
- Python 3
- IDB (optional, for interactive features)
## Documentation
- **SKILL.md** (this file) - Script reference and quick start
- **README.md** - Installation and examples
- **CLAUDE.md** - Architecture and implementation details
- **references/** - Deep documentation on specific topics
- **examples/** - Complete automation workflows
## Key Design Principles
**Semantic Navigation**: Find elements by meaning (text, type, ID) not pixel coordinates. Survives UI changes.
**Token Efficiency**: Concise default output (3-5 lines) with optional verbose and JSON modes for detailed results.
**Accessibility-First**: Built on standard accessibility APIs for reliability and compatibility.
**Zero Configuration**: Works immediately on any macOS with Xcode. No setup required.
**Structured Data**: Scripts output JSON or formatted text, not raw logs. Easy to parse and integrate.
**Auto-Learning**: Build system remembers your device preference. Configuration stored per-project.
---
Use these scripts directly or let Claude Code invoke them automatically when your request matches the skill description.
+6
View File
@@ -0,0 +1,6 @@
---
name: mobile-simulator
description: Build, launch, and verify Sauci on iOS or Android simulators.
---
Read `.codex/skills/mobile-simulator/SKILL.md` completely and follow it.
+6
View File
@@ -0,0 +1,6 @@
---
name: promote-learning
description: Record or promote a reproduced Sauci workflow lesson into a durable artifact.
---
Read `.codex/skills/promote-learning/SKILL.md` completely and follow it.
+6
View File
@@ -0,0 +1,6 @@
---
name: question-pack
description: Create or review Sauci question-pack content and inverse relationships.
---
Read `.codex/skills/question-pack/SKILL.md` completely and follow it.
+6
View File
@@ -0,0 +1,6 @@
---
name: release-mobile
description: Prepare, verify, and ship Sauci iOS or Android releases end to end, including store uploads, review submission, and rollout.
---
Read `.codex/skills/release-mobile/SKILL.md` completely and follow it.
+6
View File
@@ -0,0 +1,6 @@
---
name: verify
description: Prove a completed Sauci engineering task works before a PR.
---
Read `.codex/skills/verify/SKILL.md` completely and follow it.
+11
View File
@@ -0,0 +1,11 @@
name = "architecture_reviewer"
description = "Read-only reviewer for cross-cutting or hard-to-reverse Sauci architecture decisions."
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Evaluate the delegated decision against current system boundaries, data flow,
migration path, operability, reversibility, and verification. Prefer the smallest
coherent change. Return explicit trade-offs, risks, and a recommendation with
file-backed evidence. Do not edit files or review unrelated local refactors.
"""
+12
View File
@@ -0,0 +1,12 @@
name = "database_reviewer"
description = "Read-only reviewer for Sauci migrations, RLS, authorization, and query boundaries."
model = "gpt-5.6-terra"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Read the applicable AGENTS.md, database migration skill, and migration safety
documentation. Review only the delegated diff for migration tracking,
idempotency, authorization and tenant isolation, portability, rollback impact,
types, and integration coverage. Never mutate a database or edit files. Return
prioritized line-specific findings with concrete evidence and counterevidence.
"""
+14
View File
@@ -0,0 +1,14 @@
name = "implementation"
description = "Terra executor for one continuous bounded Sauci product or harness change after scope and acceptance criteria are known."
model = "gpt-5.6-terra"
developer_instructions = """
Read the root and nearest scoped AGENTS.md files plus the applicable repository
skill. Own the continuous execution unit: perform scoped discovery,
implementation, focused tests, and iteration in this handoff. Implement only the
delegated objective and preserve unrelated work. Update maintained contract
documentation and add focused tests when the contract changes. Run the required
focused checks and return changed files, exact results, assumptions, and blockers.
Stop for missing authority or a materially ambiguous product choice. Do not expand
scope or perform external, destructive, production, release, or provider writes
unless the task explicitly authorizes that action.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "repo_scout"
description = "Read-only Sauci codebase scout for locating ownership, callers, contracts, tests, and existing patterns."
model = "gpt-5.6-luna"
model_reasoning_effort = "low"
sandbox_mode = "read-only"
developer_instructions = """
Read the root and nearest scoped AGENTS.md files. Map only the requested surface.
Return evidence-backed file paths, symbols, current behavior, dependencies, tests,
and uncertainties. Do not edit files, make design decisions, or propose broad
refactors. Do not claim runtime behavior you did not observe.
"""
+12
View File
@@ -0,0 +1,12 @@
name = "security_reviewer"
description = "Read-only reviewer for auth, RLS, cryptography, secrets, payments, privileged MCP, and destructive operations."
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Perform a scoped threat-boundary review of the delegated change. Trace callers
through authentication and authorization to the data sink. Prioritize exploitable
correctness and security risks, include counterevidence, and cite exact files and
lines. Do not edit files or expand into a repository-wide audit unless explicitly
requested.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "test_writer"
description = "Focused Sauci regression and contract-test author for already-defined behavior."
model = "gpt-5.6-terra"
model_reasoning_effort = "medium"
developer_instructions = """
Read the applicable AGENTS.md and testing documentation. Test the intended
observable contract at the lowest useful layer while preserving real auth,
authorization, tenant, and couple boundaries. Do not weaken assertions to
accommodate a failure. Run the focused test and return files changed, exact
commands, results, and any product defect exposed by the test.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "verifier"
description = "Independent Sauci behavior verifier that drives completed work and returns evidence, not fixes."
model = "gpt-5.6-terra"
model_reasoning_effort = "medium"
developer_instructions = """
Read the applicable AGENTS.md and .codex/skills/verify/SKILL.md. Independently
exercise the delegated acceptance criteria against the running non-production
product. You may write generated proof only under evidence/. Do not edit source,
tests, configuration, or acceptance criteria. Return expected behavior, observed
behavior, evidence paths, environment limitations, and a works or broken verdict.
"""
+5
View File
@@ -0,0 +1,5 @@
[agents]
enabled = true
max_concurrent_threads_per_session = 3
default_subagent_model = "gpt-5.6-terra"
default_subagent_reasoning_effort = "medium"
+219
View File
@@ -0,0 +1,219 @@
---
name: commit
description: Write conventional commit messages with type, scope, and subject when the user wants to commit changes or save work.
---
# Git Commit
Creates git commits following Conventional Commits format with proper type, scope, and subject.
## Quick Start
```bash
# 1. Stage changes
git add <files> # or: git add -A
# 2. Create commit (branch commit format)
git commit -m "type(scope): subject
Body explaining HOW and WHY.
Reference: Task X.Y, Req N"
```
## Commit Types
### Regular Branch Commits (During Development)
**Format**: `type(scope): subject`
| Type | Purpose |
|------|---------|
| `feat` | New feature or functionality |
| `fix` | Bug fix or issue resolution |
| `refactor` | Code refactoring without behavior change |
| `perf` | Performance improvements |
| `test` | Test additions or modifications |
| `ci` | CI/CD configuration changes |
| `docs` | Documentation updates |
| `chore` | Maintenance, dependencies, tooling |
| `style` | Code formatting, linting (non-functional) |
| `security` | Security vulnerability fixes or hardening |
### Scope (Required, kebab-case)
Examples: `validation`, `auth`, `cookie-service`, `template`, `config`, `tests`, `api`
### Subject Line Rules
- Max 50 characters after colon
- Present tense imperative: add, implement, fix, improve, enhance, refactor, remove, prevent
- NO period at the end
- Specific and descriptive - state WHAT, not WHY
## Core Workflow
### 1. Review Changes
```bash
git status
git diff --staged # if already staged
git diff # if not staged
```
### 2. Stage Files
```bash
git add <specific-files> # preferred
# or
git add -A # all changes
```
**NEVER commit**:
- `.env`, `credentials.json`, secrets
- `node_modules/`, `__pycache__/`, `.venv/`
- Large binary files without explicit approval
### 3. Create Commit
**Simple change**:
```bash
git commit -m "fix(auth): use hmac.compare_digest for secure comparison"
```
**Complex change (with body)**:
```bash
git commit -m "$(cat <<'EOF'
feat(validation): add URLValidator with domain whitelist
Implement URLValidator class supporting:
- Domain whitelist enforcement (youtube.com, youtu.be)
- Dangerous scheme blocking (javascript, data, file)
- URL parsing with embedded credentials handling
Addresses Requirement 31: Input validation
Part of Task 5.1: Input Validation Utilities
EOF
)"
```
### 4. Verify Commit
```bash
git log -1 --format="%h %s"
git show --stat HEAD
```
## Body Format (Recommended for Complex Changes)
```
<blank line>
Explain HOW and WHY the change was made.
- Use bullet points for multiple items
- Wrap at 72 characters
Reference: Task X.Y
Addresses: Req N
```
## Git Trailers
| Trailer | Purpose |
|---------|---------|
| `Fixes #N` | Links and closes issue on merge |
| `Closes #N` | Same as Fixes |
| `Co-authored-by: Name <email>` | Credit co-contributors |
Place trailers at end of body after blank line. See `references/commit_examples.md` for examples.
## Breaking Changes
For incompatible API/behavior changes, use `!` after scope OR `BREAKING CHANGE:` footer:
```
feat(api)!: change response format to JSON:API
BREAKING CHANGE: Response envelope changed from `{ data }` to `{ data: { type, id, attributes } }`.
```
Triggers major version bump in semantic-release.
## Merge Commits (PR Closure)
For PRs, use extended description with sections:
```bash
gh pr create --title "feat(security): implement input validation (Task 5)" --body "$(cat <<'EOF'
## Summary
- Input validation utilities (URLValidator, FormatValidator)
- Secure template processor with path traversal prevention
- API key authentication middleware
## Task Breakdown
Task 5.1: Input Validation - URLValidator, FormatValidator
Task 5.2: Template Processing - Path traversal prevention
Task 5.3: API Key Auth - Multi-key support, excluded paths
Task 5.4: Security Tests - 102 path traversal tests
## Requirements Covered
Req 7, Req 9, Req 31, Req 33
## Test Coverage
- All 473 tests passing
- Coverage: 93%
- Pre-commit checks: passing
EOF
)"
```
## Integration with Other Skills
### From github-pr-review
When fixing review comments, use this format:
```bash
git commit -m "fix(scope): address review comment #ID
Brief explanation of what was wrong and how it's fixed.
Addresses review comment #123456789."
```
### From github-pr-creation
Before creating PR, ensure all commits follow this format. The PR skill will:
1. Analyze commits for proper format
2. Extract types for PR labels
3. Build PR description from commit bodies
## Important Rules
- **ALWAYS** include scope in parentheses
- **ALWAYS** use present tense imperative verb
- **NEVER** end subject with period
- **NEVER** commit secrets or credentials
- **NEVER** use generic messages ("update code", "fix bug", "changes")
- **NEVER** exceed 50 chars in subject line
- Group related changes -> single focused commit
## Examples
**Good**:
```
feat(validation): add URLValidator with domain whitelist
fix(auth): use hmac.compare_digest for secure key comparison
refactor(template): consolidate filename sanitization logic
test(security): add 102 path traversal prevention tests
```
**Bad**:
```
update validation code # no type, no scope, vague
feat: add stuff # missing scope, too vague
fix(auth): fix bug # circular, not specific
chore: make changes # missing scope, vague
feat(security): improve things. # has period, vague
```
## References
- `references/commit_examples.md` - Extended examples by type
@@ -0,0 +1,292 @@
# Commit Examples by Type
Extended examples for each commit type with body content.
## feat - New Features
```
feat(validation): add URLValidator with domain whitelist
Implement URLValidator class supporting:
- Domain whitelist enforcement (youtube.com, youtu.be, m.youtube.com)
- Dangerous scheme blocking (javascript, data, file)
- URL parsing with embedded credentials handling
- Port number validation (1-65535)
Addresses Requirement 31: Input validation
Part of Task 5.1: Input Validation Utilities
```
```
feat(api): add video metadata endpoint
New GET /api/v1/videos/{id}/metadata endpoint:
- Returns title, duration, formats, thumbnails
- Supports format filtering via query params
- Implements caching with 5-minute TTL
Part of Task 6.2: API Endpoints
```
## fix - Bug Fixes
```
fix(auth): use hmac.compare_digest for secure key comparison
Replace direct string equality with hmac.compare_digest to prevent
timing attacks on API key validation. Ensures constant-time comparison
regardless of key length or content.
Addresses security best practice for sensitive data comparison
```
```
fix(download): handle network timeout during video fetch
Add retry logic with exponential backoff for network failures:
- Max 3 attempts with delays [2, 4, 8] seconds
- Classify retriable errors (5xx, timeout, connection)
- Log each retry attempt with remaining count
Fixes issue where downloads would fail silently on flaky connections
```
## refactor - Code Improvements
```
refactor(template): consolidate filename sanitization logic
Extract common sanitization patterns into helper methods:
- Path traversal prevention (.., /, absolute paths)
- Special character removal (control chars, null bytes)
- Windows reserved name handling (CON, PRN, LPT1-9, etc)
Improves code maintainability and reduces duplication
```
```
refactor(providers): extract common yt-dlp options builder
Move duplicated option building from get_info/download to
_build_base_options helper. Reduces code duplication and ensures
consistent option handling across all provider methods.
No behavior change, pure refactoring
```
## test - Test Changes
```
test(security): add 102 path traversal prevention tests
Comprehensive test coverage for TemplateProcessor including:
- Basic path traversal attempts (.., /)
- URL-encoded variants (%2e%2e, %2f)
- Unicode/UTF-8 bypass attempts
- Windows edge cases (backslashes, drive letters)
Part of Task 5.4: Security Test Suite
```
```
test(validation): add parametrized URL validation tests
Add 25 test cases covering:
- Valid YouTube URL formats (watch, shorts, embed, youtu.be)
- Invalid domains (vimeo, dailymotion)
- Malformed URLs (no scheme, wrong port)
- Edge cases (trailing slashes, query params)
Coverage for URLValidator: 98%
```
## perf - Performance
```
perf(cache): implement LRU eviction for metadata cache
Replace dict-based cache with LRU implementation:
- Max 1000 entries with automatic eviction
- 40% memory reduction under high load
- Sub-millisecond lookup times maintained
Addresses memory growth issue in long-running instances
```
## security - Security Fixes
```
security(cookie): validate cookie file integrity before use
Add SHA256 checksum verification for cookie files:
- Compute hash on first load, store in memory
- Verify hash before each use
- Reject modified files with clear error message
Prevents use of tampered cookie files
Addresses Requirement 33: Security validation
```
## ci - CI/CD Changes
```
ci(github): add security scanning to PR workflow
Enable Bandit security scanner in GitHub Actions:
- Run on all Python files
- Fail on HIGH/CRITICAL findings
- Cache virtualenv for faster runs
Part of Task 15.3: Basic security validation
```
## docs - Documentation
```
docs(api): add OpenAPI description for download endpoint
Document /api/v1/download endpoint:
- Request body schema with format options
- Response codes (200, 400, 401, 404, 500)
- Example requests and responses
Improves API documentation for consumers
```
## chore - Maintenance
```
chore(deps): update yt-dlp to 2024.12.06
Update yt-dlp from 2024.11.15 to 2024.12.06:
- Fixes YouTube throttling detection
- Adds support for new Instagram format
- Improves error messages for geo-blocked content
No breaking changes expected
```
## style - Formatting
```
style(providers): apply black formatting to youtube.py
Apply black formatter with 88 char line length.
No functional changes, formatting only.
```
## Merge Commit Examples
### Feature Branch to Develop
```
Merge pull request #5 from fvadicamo/feature/input-validation-security
feat(security): implement input validation and security (Task 5)
Merges comprehensive security implementation (Task 5) into develop:
- Input validation utilities (URLValidator, FormatValidator, ParameterValidator)
- Secure template processor with path traversal prevention
- API key authentication middleware with multi-key support
- 473 tests with 93% coverage
Task 5.1: Input Validation Utilities
- URLValidator: Domain whitelist (youtube.com, youtu.be), dangerous scheme blocking
- FormatValidator: yt-dlp format ID validation with regex and selectors
- ParameterValidator: Audio quality/format and language code validation
Task 5.2: Template Processor
- Path traversal prevention (.., /, absolute paths, URL encoding)
- Filename sanitization (illegal chars, control chars, null bytes)
- Windows reserved names handling (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
- Collision handling with numeric suffix, max length 200 chars
Task 5.3: API Key Authentication
- APIKeyAuth class with multi-key support
- Excluded paths for health/doc endpoints
- Secure hashing for logging (SHA256 first 8 chars)
- FastAPI dependency injection integration
Task 5.4: Security Tests
- 102 path traversal prevention tests with edge cases
- URL validation tests with malicious inputs
- API key authentication and credential tests
- Sensitive data redaction verification
Requirements Covered:
- Req 7: Output template processing with security
- Req 9: API key authentication
- Req 31: Input validation
- Req 33: Security (secure comparison, log redaction)
Test Coverage:
- All 473 tests passing
- Coverage: 93% (exceeds 80% minimum)
- Pre-commit checks: all passing
```
### Develop to Main (Release)
```
Merge pull request #10 from fvadicamo/develop
release: v0.1.0 - MVP with YouTube provider
First stable release with core functionality:
- YouTube video info, formats, download, audio extraction
- Cookie-based authentication for age-restricted content
- API key authentication
- Input validation and security hardening
- 500+ tests with 92% coverage
Breaking Changes: None (initial release)
Features:
- GET /api/v1/info - Video metadata
- GET /api/v1/formats - Available formats
- POST /api/v1/download - Video/audio download
- Cookie file support for authenticated requests
Documentation:
- API documentation at /docs (Swagger UI)
- OpenAPI spec at /openapi.json
```
## Commits with Trailers
### Single Issue
```
fix(validation): prevent XSS in user input
Escape HTML entities before rendering.
Fixes #78
```
### Multiple Issues + Co-author
```
fix(auth): resolve session and token issues
- Fix session expiry not triggering logout
- Fix token refresh race condition
Fixes #101
Fixes #103
Co-authored-by: Bob <bob@example.com>
```
## Breaking Changes
### With ! Notation
```
feat(api)!: migrate to v2 endpoints
BREAKING CHANGE: /api/v1/* endpoints removed. Update base URL to /api/v2/.
```
### Config Breaking Change
```
chore(config)!: rename environment variables
BREAKING CHANGE: DATABASE_URL -> APP_DATABASE_URL, API_KEY -> APP_API_KEY
```
+18
View File
@@ -0,0 +1,18 @@
---
name: db-migration
description: Create, review, and verify Sauci Supabase schema migrations. Use for tables, columns, functions, policies, indexes, triggers, or other database shape changes.
---
# Sauci database migration
Read `apps/supabase/AGENTS.md` and `docs/operations/database-migrations.md`.
1. Create the local file with `npm run db:migration:new -- <name>`.
2. Edit only `apps/supabase/migrations/`; keep environment-specific URLs dynamic.
3. Update affected shared/generated contracts and callers in the same task.
4. Reset local Supabase, run function/RLS tests, and independently inspect the
resulting schema/behavior.
5. Commit the migration file with dependent code. CI performs deployment.
Never call remote `apply_migration`; never send DDL through MCP SQL execution;
never use a remote project for local validation.
+73
View File
@@ -0,0 +1,73 @@
---
name: delegate-to-claude
description: Delegate a bounded coding, investigation, or review task from Codex to Claude Code, then inspect and independently assess Claude's result. Use when the user asks Codex to delegate to Claude or when a second-model execution or review pass is explicitly desired. Do not use for ordinary Codex subagents or as a substitute for Codex's final review.
---
# Delegate to Claude Code
Use Claude Code as an executor or independent reviewer while Codex remains the coordinator responsible for scope, evidence, and acceptance.
## Preconditions
1. Confirm `claude` is installed and authenticated by running `scripts/claude_delegate.sh check` from this skill directory.
2. Read the repository's applicable `AGENTS.md`, `CLAUDE.md`, and task-specific instructions before writing the work order.
3. Do not delegate secrets, provider writes, production mutations, releases, or destructive operations unless the user explicitly authorized that exact action. Claude receives no broader authority than Codex has.
## Work order
Write the task into a temporary Markdown file outside the repository unless the user requested a durable task artifact. Make it self-contained and include:
- role: executor or read-only reviewer;
- exact objective and boundaries;
- working directory and relevant files;
- repository instructions Claude must read;
- allowed side effects and forbidden actions;
- required checks and expected evidence;
- requested return format: findings or changed files, commands run, results, and blockers.
The helper adds an executor marker and tells Claude not to spawn further agents. Do not pass the user's whole conversation when a concise work order is sufficient.
## Run
From the target repository directory:
```bash
/absolute/path/to/this-skill/scripts/claude_delegate.sh run /absolute/path/to/work-order.md /absolute/path/to/result.json
```
The default permission mode is `auto`, which lets Claude decide whether tools are safe to use. Override it only when the task requires a stricter mode:
```bash
CLAUDE_DELEGATE_PERMISSION_MODE=plan scripts/claude_delegate.sh run work-order.md result.json
```
Optional environment variables:
- `CLAUDE_DELEGATE_MODEL`: Claude model alias or full model name; default `sonnet`.
- `CLAUDE_DELEGATE_EFFORT`: `low`, `medium`, `high`, `xhigh`, or `max`; default `medium`.
- `CLAUDE_DELEGATE_MAX_BUDGET_USD`: API budget ceiling when supported by the current authentication mode.
- `CLAUDE_DELEGATE_PERMISSION_MODE`: default `auto`; prefer `plan` for read-only planning/review when tools are unnecessary.
The helper loads project and local Claude settings while excluding user-level integrations by default, and disables browser/MCP integrations. This keeps delegation focused on the checked-out repository. If a task genuinely requires an external connector, handle that action in Codex under the original authorization rather than silently widening Claude's access.
Never choose `bypassPermissions` merely to avoid a permission failure. Use it only when the user explicitly requests it and the execution is inside an appropriately isolated sandbox.
For a follow-up in the same Claude session, obtain `session_id` from the JSON result and run:
```bash
scripts/claude_delegate.sh resume SESSION_ID follow-up.md follow-up-result.json
```
If a call outlives the command yield, continue waiting on that same process/session rather than launching a duplicate.
## Accept or iterate
Claude's summary is evidence to inspect, not proof. Codex must:
1. inspect the actual diff or artifacts;
2. run proportionate independent checks;
3. compare the result with the original scope and repository rules;
4. send a focused follow-up with concrete failures when iteration is needed;
5. report Claude's contribution separately from Codex's verification.
Do not claim success from Claude's self-report alone. Codex retains the decision to accept, commit, publish, deploy, or make any external change.
@@ -0,0 +1,4 @@
interface:
display_name: "Delegate to Claude Code"
short_description: "Delegate scoped work to Claude Code"
default_prompt: "Use $delegate-to-claude to delegate this scoped task to Claude Code and review the result."
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
die() {
printf 'claude_delegate: %s\n' "$*" >&2
exit 1
}
usage() {
cat >&2 <<'EOF'
usage:
claude_delegate.sh check
claude_delegate.sh run <prompt-file> [result-json]
claude_delegate.sh resume <session-id> <prompt-file> [result-json]
EOF
exit 2
}
command -v claude >/dev/null 2>&1 || die "claude CLI is not installed or not on PATH"
if [[ "${1:-}" == "check" ]]; then
claude --version
auth_status="$(claude auth status 2>/dev/null)" || die "unable to read Claude authentication status"
[[ "$auth_status" == *'"loggedIn": true'* ]] || die "Claude CLI is not authenticated"
printf 'authentication: logged in\n'
exit 0
fi
mode="${1:-}"
case "$mode" in
run)
[[ $# -ge 2 && $# -le 3 ]] || usage
prompt_file="$2"
result_file="${3:-}"
resume_id=""
;;
resume)
[[ $# -ge 3 && $# -le 4 ]] || usage
resume_id="$2"
prompt_file="$3"
result_file="${4:-}"
[[ "$resume_id" =~ ^[0-9a-fA-F-]{36}$ ]] || die "session id must be a UUID"
;;
*) usage ;;
esac
[[ -f "$prompt_file" ]] || die "prompt file does not exist: $prompt_file"
[[ -r "$prompt_file" ]] || die "prompt file is not readable: $prompt_file"
prompt="ROLE: EXECUTOR — do the work yourself; do not spawn subagents. Stay within the work order's authorization and scope. At the end, report changed files or findings, commands and checks run with results, and any blockers. WORK ORDER: $(<"$prompt_file")"
effort="${CLAUDE_DELEGATE_EFFORT:-medium}"
permission_mode="${CLAUDE_DELEGATE_PERMISSION_MODE:-auto}"
case "$effort" in low|medium|high|xhigh|max) ;; *) die "invalid effort: $effort" ;; esac
case "$permission_mode" in acceptEdits|auto|manual|dontAsk|plan|bypassPermissions) ;;
*) die "invalid permission mode: $permission_mode" ;;
esac
model="${CLAUDE_DELEGATE_MODEL:-sonnet}"
args=(--print --output-format json --model "$model" --effort "$effort" --permission-mode "$permission_mode" --setting-sources project,local --strict-mcp-config --no-chrome)
[[ -n "${CLAUDE_DELEGATE_MAX_BUDGET_USD:-}" ]] && args+=(--max-budget-usd "$CLAUDE_DELEGATE_MAX_BUDGET_USD")
[[ -n "$resume_id" ]] && args+=(--resume "$resume_id")
if [[ -n "$result_file" ]]; then
result_dir="$(dirname "$result_file")"
[[ -d "$result_dir" ]] || die "result directory does not exist: $result_dir"
claude "${args[@]}" "$prompt" | tee "$result_file"
else
claude "${args[@]}" "$prompt"
fi
+211
View File
@@ -0,0 +1,211 @@
---
name: dev-browser
description: Browser automation with persistent page state. Use when users ask to navigate websites, fill forms, take screenshots, extract web data, test web apps, or automate browser workflows. Trigger phrases include "go to [url]", "click on", "fill out the form", "take a screenshot", "scrape", "automate", "test the website", "log into", or any browser interaction request.
---
# Dev Browser Skill
Browser automation that maintains page state across script executions. Write small, focused scripts to accomplish tasks incrementally. Once you've proven out part of a workflow and there is repeated work to be done, you can write a script to do the repeated work in a single execution.
## Choosing Your Approach
- **Local/source-available sites**: Read the source code first to write selectors directly
- **Unknown page layouts**: Use `getAISnapshot()` to discover elements and `selectSnapshotRef()` to interact with them
- **Visual feedback**: Take screenshots to see what the user sees
## Setup
Two modes available. Ask the user if unclear which to use.
### Standalone Mode (Default)
Launches a new Chromium browser for fresh automation sessions.
```bash
./skills/dev-browser/server.sh &
```
Add `--headless` flag if user requests it. **Wait for the `Ready` message before running scripts.**
### Extension Mode
Connects to user's existing Chrome browser. Use this when:
- The user is already logged into sites and wants you to do things behind an authed experience that isn't local dev.
- The user asks you to use the extension
**Important**: The core flow is still the same. You create named pages inside of their browser.
**Start the relay server:**
```bash
cd skills/dev-browser && npm i && npm run start-extension &
```
Wait for `Waiting for extension to connect...` followed by `Extension connected` in the console. To know that a client has connected and the browser is ready to be controlled.
**Workflow:**
1. Scripts call `client.page("name")` just like the normal mode to create new pages / connect to existing ones.
2. Automation runs on the user's actual browser session
If the extension hasn't connected yet, tell the user to launch and activate it. Download link: https://github.com/SawyerHood/dev-browser/releases
## Writing Scripts
> **Run all scripts from `skills/dev-browser/` directory.** The `@/` import alias requires this directory's config.
Execute scripts inline using heredocs:
```bash
cd skills/dev-browser && npx tsx <<'EOF'
import { connect, waitForPageLoad } from "@/client.js";
const client = await connect();
// Create page with custom viewport size (optional)
const page = await client.page("example", { viewport: { width: 1920, height: 1080 } });
await page.goto("https://example.com");
await waitForPageLoad(page);
console.log({ title: await page.title(), url: page.url() });
await client.disconnect();
EOF
```
**Write to `tmp/` files only when** the script needs reuse, is complex, or user explicitly requests it.
### Key Principles
1. **Small scripts**: Each script does ONE thing (navigate, click, fill, check)
2. **Evaluate state**: Log/return state at the end to decide next steps
3. **Descriptive page names**: Use `"checkout"`, `"login"`, not `"main"`
4. **Disconnect to exit**: `await client.disconnect()` - pages persist on server
5. **Plain JS in evaluate**: `page.evaluate()` runs in browser - no TypeScript syntax
## Workflow Loop
Follow this pattern for complex tasks:
1. **Write a script** to perform one action
2. **Run it** and observe the output
3. **Evaluate** - did it work? What's the current state?
4. **Decide** - is the task complete or do we need another script?
5. **Repeat** until task is done
### No TypeScript in Browser Context
Code passed to `page.evaluate()` runs in the browser, which doesn't understand TypeScript:
```typescript
// ✅ Correct: plain JavaScript
const text = await page.evaluate(() => {
return document.body.innerText;
});
// ❌ Wrong: TypeScript syntax will fail at runtime
const text = await page.evaluate(() => {
const el: HTMLElement = document.body; // Type annotation breaks in browser!
return el.innerText;
});
```
## Scraping Data
For scraping large datasets, intercept and replay network requests rather than scrolling the DOM. See [references/scraping.md](references/scraping.md) for the complete guide covering request capture, schema discovery, and paginated API replay.
## Client API
```typescript
const client = await connect();
// Get or create named page (viewport only applies to new pages)
const page = await client.page("name");
const pageWithSize = await client.page("name", { viewport: { width: 1920, height: 1080 } });
const pages = await client.list(); // List all page names
await client.close("name"); // Close a page
await client.disconnect(); // Disconnect (pages persist)
// ARIA Snapshot methods
const snapshot = await client.getAISnapshot("name"); // Get accessibility tree
const element = await client.selectSnapshotRef("name", "e5"); // Get element by ref
```
The `page` object is a standard Playwright Page.
## Waiting
```typescript
import { waitForPageLoad } from "@/client.js";
await waitForPageLoad(page); // After navigation
await page.waitForSelector(".results"); // For specific elements
await page.waitForURL("**/success"); // For specific URL
```
## Inspecting Page State
### Screenshots
```typescript
await page.screenshot({ path: "tmp/screenshot.png" });
await page.screenshot({ path: "tmp/full.png", fullPage: true });
```
### ARIA Snapshot (Element Discovery)
Use `getAISnapshot()` to discover page elements. Returns YAML-formatted accessibility tree:
```yaml
- banner:
- link "Hacker News" [ref=e1]
- navigation:
- link "new" [ref=e2]
- main:
- list:
- listitem:
- link "Article Title" [ref=e8]
- link "328 comments" [ref=e9]
- contentinfo:
- textbox [ref=e10]
- /placeholder: "Search"
```
**Interpreting refs:**
- `[ref=eN]` - Element reference for interaction (visible, clickable elements only)
- `[checked]`, `[disabled]`, `[expanded]` - Element states
- `[level=N]` - Heading level
- `/url:`, `/placeholder:` - Element properties
**Interacting with refs:**
```typescript
const snapshot = await client.getAISnapshot("hackernews");
console.log(snapshot); // Find the ref you need
const element = await client.selectSnapshotRef("hackernews", "e2");
await element.click();
```
## Error Recovery
Page state persists after failures. Debug with:
```bash
cd skills/dev-browser && npx tsx <<'EOF'
import { connect } from "@/client.js";
const client = await connect();
const page = await client.page("hackernews");
await page.screenshot({ path: "tmp/debug.png" });
console.log({
url: page.url(),
title: await page.title(),
bodyText: await page.textContent("body").then((t) => t?.slice(0, 200)),
});
await client.disconnect();
EOF
```
+443
View File
@@ -0,0 +1,443 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "dev-browser",
"dependencies": {
"express": "^4.21.0",
"playwright": "^1.49.0",
},
"devDependencies": {
"@types/express": "^5.0.0",
"tsx": "^4.21.0",
"vitest": "^2.1.0",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.1", "", { "os": "android", "cpu": "arm" }, "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.1", "", { "os": "android", "cpu": "arm64" }, "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.1", "", { "os": "android", "cpu": "x64" }, "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.1", "", { "os": "linux", "cpu": "x64" }, "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.1", "", { "os": "none", "cpu": "arm64" }, "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.1", "", { "os": "none", "cpu": "x64" }, "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.3", "", { "os": "android", "cpu": "arm64" }, "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
"@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
"@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="],
"@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="],
"@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="],
"@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
"playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@0.19.1", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg=="],
"serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
"tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
"tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="],
"vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
"send/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
"serve-static/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="],
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"serve-static/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"serve-static/send/http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
"serve-static/send/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
}
}
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "dev-browser",
"version": "0.0.1",
"type": "module",
"imports": {
"@/*": "./src/*"
},
"scripts": {
"start-server": "npx tsx scripts/start-server.ts",
"start-extension": "npx tsx scripts/start-relay.ts",
"dev": "npx tsx --watch src/index.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@hono/node-server": "^1.19.7",
"@hono/node-ws": "^1.2.0",
"express": "^4.21.0",
"hono": "^4.11.1",
"playwright": "^1.49.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"tsx": "^4.21.0",
"typescript": "^5.0.0",
"vitest": "^2.1.0"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "^4.0.0"
}
}
@@ -0,0 +1,155 @@
# Data Scraping Guide
For large datasets (followers, posts, search results), **intercept and replay network requests** rather than scrolling and parsing the DOM. This is faster, more reliable, and handles pagination automatically.
## Why Not Scroll?
Scrolling is slow, unreliable, and wastes time. APIs return structured data with pagination built in. Always prefer API replay.
## Start Small, Then Scale
**Don't try to automate everything at once.** Work incrementally:
1. **Capture one request** - verify you're intercepting the right endpoint
2. **Inspect one response** - understand the schema before writing extraction code
3. **Extract a few items** - make sure your parsing logic works
4. **Then scale up** - add pagination loop only after the basics work
This prevents wasting time debugging a complex script when the issue is a simple path like `data.user.timeline` vs `data.user.result.timeline`.
## Step-by-Step Workflow
### 1. Capture Request Details
First, intercept a request to understand URL structure and required headers:
```typescript
import { connect, waitForPageLoad } from "@/client.js";
import * as fs from "node:fs";
const client = await connect();
const page = await client.page("site");
let capturedRequest = null;
page.on("request", (request) => {
const url = request.url();
// Look for API endpoints (adjust pattern for your target site)
if (url.includes("/api/") || url.includes("/graphql/")) {
capturedRequest = {
url: url,
headers: request.headers(),
method: request.method(),
};
fs.writeFileSync("tmp/request-details.json", JSON.stringify(capturedRequest, null, 2));
console.log("Captured request:", url.substring(0, 80) + "...");
}
});
await page.goto("https://example.com/profile");
await waitForPageLoad(page);
await page.waitForTimeout(3000);
await client.disconnect();
```
### 2. Capture Response to Understand Schema
Save a raw response to inspect the data structure:
```typescript
page.on("response", async (response) => {
const url = response.url();
if (url.includes("UserTweets") || url.includes("/api/data")) {
const json = await response.json();
fs.writeFileSync("tmp/api-response.json", JSON.stringify(json, null, 2));
console.log("Captured response");
}
});
```
Then analyze the structure to find:
- Where the data array lives (e.g., `data.user.result.timeline.instructions[].entries`)
- Where pagination cursors are (e.g., `cursor-bottom` entries)
- What fields you need to extract
### 3. Replay API with Pagination
Once you understand the schema, replay requests directly:
```typescript
import { connect } from "@/client.js";
import * as fs from "node:fs";
const client = await connect();
const page = await client.page("site");
const results = new Map(); // Use Map for deduplication
const headers = JSON.parse(fs.readFileSync("tmp/request-details.json", "utf8")).headers;
const baseUrl = "https://example.com/api/data";
let cursor = null;
let hasMore = true;
while (hasMore) {
// Build URL with pagination cursor
const params = { count: 20 };
if (cursor) params.cursor = cursor;
const url = `${baseUrl}?params=${encodeURIComponent(JSON.stringify(params))}`;
// Execute fetch in browser context (has auth cookies/headers)
const response = await page.evaluate(
async ({ url, headers }) => {
const res = await fetch(url, { headers });
return res.json();
},
{ url, headers }
);
// Extract data and cursor (adjust paths for your API)
const entries = response?.data?.entries || [];
for (const entry of entries) {
if (entry.type === "cursor-bottom") {
cursor = entry.value;
} else if (entry.id && !results.has(entry.id)) {
results.set(entry.id, {
id: entry.id,
text: entry.content,
timestamp: entry.created_at,
});
}
}
console.log(`Fetched page, total: ${results.size}`);
// Check stop conditions
if (!cursor || entries.length === 0) hasMore = false;
// Rate limiting - be respectful
await new Promise((r) => setTimeout(r, 500));
}
// Export results
const data = Array.from(results.values());
fs.writeFileSync("tmp/results.json", JSON.stringify(data, null, 2));
console.log(`Saved ${data.length} items`);
await client.disconnect();
```
## Key Patterns
| Pattern | Description |
| ----------------------- | ------------------------------------------------------ |
| `page.on('request')` | Capture outgoing request URL + headers |
| `page.on('response')` | Capture response data to understand schema |
| `page.evaluate(fetch)` | Replay requests in browser context (inherits auth) |
| `Map` for deduplication | APIs often return overlapping data across pages |
| Cursor-based pagination | Look for `cursor`, `next_token`, `offset` in responses |
## Tips
- **Extension mode**: `page.context().cookies()` doesn't work - capture auth headers from intercepted requests instead
- **Rate limiting**: Add 500ms+ delays between requests to avoid blocks
- **Stop conditions**: Check for empty results, missing cursor, or reaching a date/ID threshold
- **GraphQL APIs**: URL params often include `variables` and `features` JSON objects - capture and reuse them
@@ -0,0 +1,32 @@
/**
* Start the CDP relay server for Chrome extension mode
*
* Usage: npm run start-extension
*/
import { serveRelay } from "@/relay.js";
const PORT = parseInt(process.env.PORT || "9222", 10);
const HOST = process.env.HOST || "127.0.0.1";
async function main() {
const server = await serveRelay({
port: PORT,
host: HOST,
});
// Handle shutdown
const shutdown = async () => {
console.log("\nShutting down relay server...");
await server.stop();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
main().catch((err) => {
console.error("Failed to start relay server:", err);
process.exit(1);
});
@@ -0,0 +1,117 @@
import { serve } from "@/index.js";
import { execSync } from "child_process";
import { mkdirSync, existsSync, readdirSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const tmpDir = join(__dirname, "..", "tmp");
const profileDir = join(__dirname, "..", "profiles");
// Create tmp and profile directories if they don't exist
console.log("Creating tmp directory...");
mkdirSync(tmpDir, { recursive: true });
console.log("Creating profiles directory...");
mkdirSync(profileDir, { recursive: true });
// Install Playwright browsers if not already installed
console.log("Checking Playwright browser installation...");
function findPackageManager(): { name: string; command: string } | null {
const managers = [
{ name: "bun", command: "bunx playwright install chromium" },
{ name: "pnpm", command: "pnpm exec playwright install chromium" },
{ name: "npm", command: "npx playwright install chromium" },
];
for (const manager of managers) {
try {
execSync(`which ${manager.name}`, { stdio: "ignore" });
return manager;
} catch {
// Package manager not found, try next
}
}
return null;
}
function isChromiumInstalled(): boolean {
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
const playwrightCacheDir = join(homeDir, ".cache", "ms-playwright");
if (!existsSync(playwrightCacheDir)) {
return false;
}
// Check for chromium directories (e.g., chromium-1148, chromium_headless_shell-1148)
try {
const entries = readdirSync(playwrightCacheDir);
return entries.some((entry) => entry.startsWith("chromium"));
} catch {
return false;
}
}
try {
if (!isChromiumInstalled()) {
console.log("Playwright Chromium not found. Installing (this may take a minute)...");
const pm = findPackageManager();
if (!pm) {
throw new Error("No package manager found (tried bun, pnpm, npm)");
}
console.log(`Using ${pm.name} to install Playwright...`);
execSync(pm.command, { stdio: "inherit" });
console.log("Chromium installed successfully.");
} else {
console.log("Playwright Chromium already installed.");
}
} catch (error) {
console.error("Failed to install Playwright browsers:", error);
console.log("You may need to run: npx playwright install chromium");
}
// Check if server is already running
console.log("Checking for existing servers...");
try {
const res = await fetch("http://localhost:9222", {
signal: AbortSignal.timeout(1000),
});
if (res.ok) {
console.log("Server already running on port 9222");
process.exit(0);
}
} catch {
// Server not running, continue to start
}
// Clean up stale CDP port if HTTP server isn't running (crash recovery)
// This handles the case where Node crashed but Chrome is still running on 9223
try {
const pid = execSync("lsof -ti:9223", { encoding: "utf-8" }).trim();
if (pid) {
console.log(`Cleaning up stale Chrome process on CDP port 9223 (PID: ${pid})`);
execSync(`kill -9 ${pid}`);
}
} catch {
// No process on CDP port, which is expected
}
console.log("Starting dev browser server...");
const headless = process.env.HEADLESS === "true";
const server = await serve({
port: 9222,
headless,
profileDir,
});
console.log(`Dev browser server started`);
console.log(` WebSocket: ${server.wsEndpoint}`);
console.log(` Tmp directory: ${tmpDir}`);
console.log(` Profile directory: ${profileDir}`);
console.log(`\nReady`);
console.log(`\nPress Ctrl+C to stop`);
// Keep the process running
await new Promise(() => {});
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# Get the directory where this script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# Change to the script directory
cd "$SCRIPT_DIR"
# Parse command line arguments
HEADLESS=false
while [[ "$#" -gt 0 ]]; do
case $1 in
--headless) HEADLESS=true ;;
*) echo "Unknown parameter: $1"; exit 1 ;;
esac
shift
done
echo "Installing dependencies..."
npm install
echo "Starting dev-browser server..."
export HEADLESS=$HEADLESS
npx tsx scripts/start-server.ts
+474
View File
@@ -0,0 +1,474 @@
import { chromium, type Browser, type Page, type ElementHandle } from "playwright";
import type {
GetPageRequest,
GetPageResponse,
ListPagesResponse,
ServerInfoResponse,
ViewportSize,
} from "./types";
import { getSnapshotScript } from "./snapshot/browser-script";
/**
* Options for waiting for page load
*/
export interface WaitForPageLoadOptions {
/** Maximum time to wait in ms (default: 10000) */
timeout?: number;
/** How often to check page state in ms (default: 50) */
pollInterval?: number;
/** Minimum time to wait even if page appears ready in ms (default: 100) */
minimumWait?: number;
/** Wait for network to be idle (no pending requests) (default: true) */
waitForNetworkIdle?: boolean;
}
/**
* Result of waiting for page load
*/
export interface WaitForPageLoadResult {
/** Whether the page is considered loaded */
success: boolean;
/** Document ready state when finished */
readyState: string;
/** Number of pending network requests when finished */
pendingRequests: number;
/** Time spent waiting in ms */
waitTimeMs: number;
/** Whether timeout was reached */
timedOut: boolean;
}
interface PageLoadState {
documentReadyState: string;
documentLoading: boolean;
pendingRequests: PendingRequest[];
}
interface PendingRequest {
url: string;
loadingDurationMs: number;
resourceType: string;
}
/**
* Wait for a page to finish loading using document.readyState and performance API.
*
* Uses browser-use's approach of:
* - Checking document.readyState for 'complete'
* - Monitoring pending network requests via Performance API
* - Filtering out ads, tracking, and non-critical resources
* - Graceful timeout handling (continues even if timeout reached)
*/
export async function waitForPageLoad(
page: Page,
options: WaitForPageLoadOptions = {}
): Promise<WaitForPageLoadResult> {
const {
timeout = 10000,
pollInterval = 50,
minimumWait = 100,
waitForNetworkIdle = true,
} = options;
const startTime = Date.now();
let lastState: PageLoadState | null = null;
// Wait minimum time first
if (minimumWait > 0) {
await new Promise((resolve) => setTimeout(resolve, minimumWait));
}
// Poll until ready or timeout
while (Date.now() - startTime < timeout) {
try {
lastState = await getPageLoadState(page);
// Check if document is complete
const documentReady = lastState.documentReadyState === "complete";
// Check if network is idle (no pending critical requests)
const networkIdle = !waitForNetworkIdle || lastState.pendingRequests.length === 0;
if (documentReady && networkIdle) {
return {
success: true,
readyState: lastState.documentReadyState,
pendingRequests: lastState.pendingRequests.length,
waitTimeMs: Date.now() - startTime,
timedOut: false,
};
}
} catch {
// Page may be navigating, continue polling
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
// Timeout reached - return current state
return {
success: false,
readyState: lastState?.documentReadyState ?? "unknown",
pendingRequests: lastState?.pendingRequests.length ?? 0,
waitTimeMs: Date.now() - startTime,
timedOut: true,
};
}
/**
* Get the current page load state including document ready state and pending requests.
* Filters out ads, tracking, and non-critical resources that shouldn't block loading.
*/
async function getPageLoadState(page: Page): Promise<PageLoadState> {
const result = await page.evaluate(() => {
// Access browser globals via globalThis for TypeScript compatibility
/* eslint-disable @typescript-eslint/no-explicit-any */
const g = globalThis as { document?: any; performance?: any };
/* eslint-enable @typescript-eslint/no-explicit-any */
const perf = g.performance!;
const doc = g.document!;
const now = perf.now();
const resources = perf.getEntriesByType("resource");
const pending: Array<{ url: string; loadingDurationMs: number; resourceType: string }> = [];
// Common ad/tracking domains and patterns to filter out
const adPatterns = [
"doubleclick.net",
"googlesyndication.com",
"googletagmanager.com",
"google-analytics.com",
"facebook.net",
"connect.facebook.net",
"analytics",
"ads",
"tracking",
"pixel",
"hotjar.com",
"clarity.ms",
"mixpanel.com",
"segment.com",
"newrelic.com",
"nr-data.net",
"/tracker/",
"/collector/",
"/beacon/",
"/telemetry/",
"/log/",
"/events/",
"/track.",
"/metrics/",
];
// Non-critical resource types
const nonCriticalTypes = ["img", "image", "icon", "font"];
for (const entry of resources) {
// Resources with responseEnd === 0 are still loading
if (entry.responseEnd === 0) {
const url = entry.name;
// Filter out ads and tracking
const isAd = adPatterns.some((pattern) => url.includes(pattern));
if (isAd) continue;
// Filter out data: URLs and very long URLs
if (url.startsWith("data:") || url.length > 500) continue;
const loadingDuration = now - entry.startTime;
// Skip requests loading > 10 seconds (likely stuck/polling)
if (loadingDuration > 10000) continue;
const resourceType = entry.initiatorType || "unknown";
// Filter out non-critical resources loading > 3 seconds
if (nonCriticalTypes.includes(resourceType) && loadingDuration > 3000) continue;
// Filter out image URLs even if type is unknown
const isImageUrl = /\.(jpg|jpeg|png|gif|webp|svg|ico)(\?|$)/i.test(url);
if (isImageUrl && loadingDuration > 3000) continue;
pending.push({
url,
loadingDurationMs: Math.round(loadingDuration),
resourceType,
});
}
}
return {
documentReadyState: doc.readyState,
documentLoading: doc.readyState !== "complete",
pendingRequests: pending,
};
});
return result;
}
/** Server mode information */
export interface ServerInfo {
wsEndpoint: string;
mode: "launch" | "extension";
extensionConnected?: boolean;
}
/**
* Options for creating or getting a page
*/
export interface PageOptions {
/** Viewport size for new pages */
viewport?: ViewportSize;
}
export interface DevBrowserClient {
page: (name: string, options?: PageOptions) => Promise<Page>;
list: () => Promise<string[]>;
close: (name: string) => Promise<void>;
disconnect: () => Promise<void>;
/**
* Get AI-friendly ARIA snapshot for a page.
* Returns YAML format with refs like [ref=e1], [ref=e2].
* Refs are stored on window.__devBrowserRefs for cross-connection persistence.
*/
getAISnapshot: (name: string) => Promise<string>;
/**
* Get an element handle by its ref from the last getAISnapshot call.
* Refs persist across Playwright connections.
*/
selectSnapshotRef: (name: string, ref: string) => Promise<ElementHandle | null>;
/**
* Get server information including mode and extension connection status.
*/
getServerInfo: () => Promise<ServerInfo>;
}
export async function connect(serverUrl = "http://localhost:9222"): Promise<DevBrowserClient> {
let browser: Browser | null = null;
let wsEndpoint: string | null = null;
let connectingPromise: Promise<Browser> | null = null;
async function ensureConnected(): Promise<Browser> {
// Return existing connection if still active
if (browser && browser.isConnected()) {
return browser;
}
// If already connecting, wait for that connection (prevents race condition)
if (connectingPromise) {
return connectingPromise;
}
// Start new connection with mutex
connectingPromise = (async () => {
try {
// Fetch wsEndpoint from server
const res = await fetch(serverUrl);
if (!res.ok) {
throw new Error(`Server returned ${res.status}: ${await res.text()}`);
}
const info = (await res.json()) as ServerInfoResponse;
wsEndpoint = info.wsEndpoint;
// Connect to the browser via CDP
browser = await chromium.connectOverCDP(wsEndpoint);
return browser;
} finally {
connectingPromise = null;
}
})();
return connectingPromise;
}
// Find page by CDP targetId - more reliable than JS globals
async function findPageByTargetId(b: Browser, targetId: string): Promise<Page | null> {
for (const context of b.contexts()) {
for (const page of context.pages()) {
let cdpSession;
try {
cdpSession = await context.newCDPSession(page);
const { targetInfo } = await cdpSession.send("Target.getTargetInfo");
if (targetInfo.targetId === targetId) {
return page;
}
} catch (err) {
// Only ignore "target closed" errors, log unexpected ones
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes("Target closed") && !msg.includes("Session closed")) {
console.warn(`Unexpected error checking page target: ${msg}`);
}
} finally {
if (cdpSession) {
try {
await cdpSession.detach();
} catch {
// Ignore detach errors - session may already be closed
}
}
}
}
}
return null;
}
// Helper to get a page by name (used by multiple methods)
async function getPage(name: string, options?: PageOptions): Promise<Page> {
// Request the page from server (creates if doesn't exist)
const res = await fetch(`${serverUrl}/pages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, viewport: options?.viewport } satisfies GetPageRequest),
});
if (!res.ok) {
throw new Error(`Failed to get page: ${await res.text()}`);
}
const pageInfo = (await res.json()) as GetPageResponse & { url?: string };
const { targetId } = pageInfo;
// Connect to browser
const b = await ensureConnected();
// Check if we're in extension mode
const infoRes = await fetch(serverUrl);
const info = (await infoRes.json()) as { mode?: string };
const isExtensionMode = info.mode === "extension";
if (isExtensionMode) {
// In extension mode, DON'T use findPageByTargetId as it corrupts page state
// Instead, find page by URL or use the only available page
const allPages = b.contexts().flatMap((ctx) => ctx.pages());
if (allPages.length === 0) {
throw new Error(`No pages available in browser`);
}
if (allPages.length === 1) {
return allPages[0]!;
}
// Multiple pages - try to match by URL if available
if (pageInfo.url) {
const matchingPage = allPages.find((p) => p.url() === pageInfo.url);
if (matchingPage) {
return matchingPage;
}
}
// Fall back to first page
if (!allPages[0]) {
throw new Error(`No pages available in browser`);
}
return allPages[0];
}
// In launch mode, use the original targetId-based lookup
const page = await findPageByTargetId(b, targetId);
if (!page) {
throw new Error(`Page "${name}" not found in browser contexts`);
}
return page;
}
return {
page: getPage,
async list(): Promise<string[]> {
const res = await fetch(`${serverUrl}/pages`);
const data = (await res.json()) as ListPagesResponse;
return data.pages;
},
async close(name: string): Promise<void> {
const res = await fetch(`${serverUrl}/pages/${encodeURIComponent(name)}`, {
method: "DELETE",
});
if (!res.ok) {
throw new Error(`Failed to close page: ${await res.text()}`);
}
},
async disconnect(): Promise<void> {
// Just disconnect the CDP connection - pages persist on server
if (browser) {
await browser.close();
browser = null;
}
},
async getAISnapshot(name: string): Promise<string> {
// Get the page
const page = await getPage(name);
// Inject the snapshot script and call getAISnapshot
const snapshotScript = getSnapshotScript();
const snapshot = await page.evaluate((script: string) => {
// Inject script if not already present
// Note: page.evaluate runs in browser context where window exists
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = globalThis as any;
if (!w.__devBrowser_getAISnapshot) {
// eslint-disable-next-line no-eval
eval(script);
}
return w.__devBrowser_getAISnapshot();
}, snapshotScript);
return snapshot;
},
async selectSnapshotRef(name: string, ref: string): Promise<ElementHandle | null> {
// Get the page
const page = await getPage(name);
// Find the element using the stored refs
const elementHandle = await page.evaluateHandle((refId: string) => {
// Note: page.evaluateHandle runs in browser context where globalThis is the window
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = globalThis as any;
const refs = w.__devBrowserRefs;
if (!refs) {
throw new Error("No snapshot refs found. Call getAISnapshot first.");
}
const element = refs[refId];
if (!element) {
throw new Error(
`Ref "${refId}" not found. Available refs: ${Object.keys(refs).join(", ")}`
);
}
return element;
}, ref);
// Check if we got an element
const element = elementHandle.asElement();
if (!element) {
await elementHandle.dispose();
return null;
}
return element;
},
async getServerInfo(): Promise<ServerInfo> {
const res = await fetch(serverUrl);
if (!res.ok) {
throw new Error(`Server returned ${res.status}: ${await res.text()}`);
}
const info = (await res.json()) as {
wsEndpoint: string;
mode?: string;
extensionConnected?: boolean;
};
return {
wsEndpoint: info.wsEndpoint,
mode: (info.mode as "launch" | "extension") ?? "launch",
extensionConnected: info.extensionConnected,
};
},
};
}
+287
View File
@@ -0,0 +1,287 @@
import express, { type Express, type Request, type Response } from "express";
import { chromium, type BrowserContext, type Page } from "playwright";
import { mkdirSync } from "fs";
import { join } from "path";
import type { Socket } from "net";
import type {
ServeOptions,
GetPageRequest,
GetPageResponse,
ListPagesResponse,
ServerInfoResponse,
} from "./types";
export type { ServeOptions, GetPageResponse, ListPagesResponse, ServerInfoResponse };
export interface DevBrowserServer {
wsEndpoint: string;
port: number;
stop: () => Promise<void>;
}
// Helper to retry fetch with exponential backoff
async function fetchWithRetry(
url: string,
maxRetries = 5,
delayMs = 500
): Promise<globalThis.Response> {
let lastError: Error | null = null;
for (let i = 0; i < maxRetries; i++) {
try {
const res = await fetch(url);
if (res.ok) return res;
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (i < maxRetries - 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs * (i + 1)));
}
}
}
throw new Error(`Failed after ${maxRetries} retries: ${lastError?.message}`);
}
// Helper to add timeout to promises
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timeout: ${message}`)), ms)
),
]);
}
export async function serve(options: ServeOptions = {}): Promise<DevBrowserServer> {
const port = options.port ?? 9222;
const headless = options.headless ?? false;
const cdpPort = options.cdpPort ?? 9223;
const profileDir = options.profileDir;
// Validate port numbers
if (port < 1 || port > 65535) {
throw new Error(`Invalid port: ${port}. Must be between 1 and 65535`);
}
if (cdpPort < 1 || cdpPort > 65535) {
throw new Error(`Invalid cdpPort: ${cdpPort}. Must be between 1 and 65535`);
}
if (port === cdpPort) {
throw new Error("port and cdpPort must be different");
}
// Determine user data directory for persistent context
const userDataDir = profileDir
? join(profileDir, "browser-data")
: join(process.cwd(), ".browser-data");
// Create directory if it doesn't exist
mkdirSync(userDataDir, { recursive: true });
console.log(`Using persistent browser profile: ${userDataDir}`);
console.log("Launching browser with persistent context...");
// Launch persistent context - this persists cookies, localStorage, cache, etc.
const context: BrowserContext = await chromium.launchPersistentContext(userDataDir, {
headless,
args: [`--remote-debugging-port=${cdpPort}`],
});
console.log("Browser launched with persistent profile...");
// Get the CDP WebSocket endpoint from Chrome's JSON API (with retry for slow startup)
const cdpResponse = await fetchWithRetry(`http://127.0.0.1:${cdpPort}/json/version`);
const cdpInfo = (await cdpResponse.json()) as { webSocketDebuggerUrl: string };
const wsEndpoint = cdpInfo.webSocketDebuggerUrl;
console.log(`CDP WebSocket endpoint: ${wsEndpoint}`);
// Registry entry type for page tracking
interface PageEntry {
page: Page;
targetId: string;
}
// Registry: name -> PageEntry
const registry = new Map<string, PageEntry>();
// Helper to get CDP targetId for a page
async function getTargetId(page: Page): Promise<string> {
const cdpSession = await context.newCDPSession(page);
try {
const { targetInfo } = await cdpSession.send("Target.getTargetInfo");
return targetInfo.targetId;
} finally {
await cdpSession.detach();
}
}
// Express server for page management
const app: Express = express();
app.use(express.json());
// GET / - server info
app.get("/", (_req: Request, res: Response) => {
const response: ServerInfoResponse = { wsEndpoint };
res.json(response);
});
// GET /pages - list all pages
app.get("/pages", (_req: Request, res: Response) => {
const response: ListPagesResponse = {
pages: Array.from(registry.keys()),
};
res.json(response);
});
// POST /pages - get or create page
app.post("/pages", async (req: Request, res: Response) => {
const body = req.body as GetPageRequest;
const { name, viewport } = body;
if (!name || typeof name !== "string") {
res.status(400).json({ error: "name is required and must be a string" });
return;
}
if (name.length === 0) {
res.status(400).json({ error: "name cannot be empty" });
return;
}
if (name.length > 256) {
res.status(400).json({ error: "name must be 256 characters or less" });
return;
}
// Check if page already exists
let entry = registry.get(name);
if (!entry) {
// Create new page in the persistent context (with timeout to prevent hangs)
const page = await withTimeout(context.newPage(), 30000, "Page creation timed out after 30s");
// Apply viewport if provided
if (viewport) {
await page.setViewportSize(viewport);
}
const targetId = await getTargetId(page);
entry = { page, targetId };
registry.set(name, entry);
// Clean up registry when page is closed (e.g., user clicks X)
page.on("close", () => {
registry.delete(name);
});
}
const response: GetPageResponse = { wsEndpoint, name, targetId: entry.targetId };
res.json(response);
});
// DELETE /pages/:name - close a page
app.delete("/pages/:name", async (req: Request<{ name: string }>, res: Response) => {
const name = decodeURIComponent(req.params.name);
const entry = registry.get(name);
if (entry) {
await entry.page.close();
registry.delete(name);
res.json({ success: true });
return;
}
res.status(404).json({ error: "page not found" });
});
// Start the server
const server = app.listen(port, () => {
console.log(`HTTP API server running on port ${port}`);
});
// Track active connections for clean shutdown
const connections = new Set<Socket>();
server.on("connection", (socket: Socket) => {
connections.add(socket);
socket.on("close", () => connections.delete(socket));
});
// Track if cleanup has been called to avoid double cleanup
let cleaningUp = false;
// Cleanup function
const cleanup = async () => {
if (cleaningUp) return;
cleaningUp = true;
console.log("\nShutting down...");
// Close all active HTTP connections
for (const socket of connections) {
socket.destroy();
}
connections.clear();
// Close all pages
for (const entry of registry.values()) {
try {
await entry.page.close();
} catch {
// Page might already be closed
}
}
registry.clear();
// Close context (this also closes the browser)
try {
await context.close();
} catch {
// Context might already be closed
}
server.close();
console.log("Server stopped.");
};
// Synchronous cleanup for forced exits
const syncCleanup = () => {
try {
context.close();
} catch {
// Best effort
}
};
// Signal handlers (consolidated to reduce duplication)
const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
const signalHandler = async () => {
await cleanup();
process.exit(0);
};
const errorHandler = async (err: unknown) => {
console.error("Unhandled error:", err);
await cleanup();
process.exit(1);
};
// Register handlers
signals.forEach((sig) => process.on(sig, signalHandler));
process.on("uncaughtException", errorHandler);
process.on("unhandledRejection", errorHandler);
process.on("exit", syncCleanup);
// Helper to remove all handlers
const removeHandlers = () => {
signals.forEach((sig) => process.off(sig, signalHandler));
process.off("uncaughtException", errorHandler);
process.off("unhandledRejection", errorHandler);
process.off("exit", syncCleanup);
};
return {
wsEndpoint,
port,
async stop() {
removeHandlers();
await cleanup();
},
};
}
+731
View File
@@ -0,0 +1,731 @@
/**
* CDP Relay Server for Chrome Extension mode
*
* This server acts as a bridge between Playwright clients and a Chrome extension.
* Instead of launching a browser, it waits for the extension to connect and
* forwards CDP commands/events between them.
*/
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { createNodeWebSocket } from "@hono/node-ws";
import type { WSContext } from "hono/ws";
// ============================================================================
// Types
// ============================================================================
export interface RelayOptions {
port?: number;
host?: string;
}
export interface RelayServer {
wsEndpoint: string;
port: number;
stop(): Promise<void>;
}
interface TargetInfo {
targetId: string;
type: string;
title: string;
url: string;
attached: boolean;
}
interface ConnectedTarget {
sessionId: string;
targetId: string;
targetInfo: TargetInfo;
}
interface PlaywrightClient {
id: string;
ws: WSContext;
knownTargets: Set<string>; // targetIds this client has received attachedToTarget for
}
// Message types for extension communication
interface ExtensionCommandMessage {
id: number;
method: "forwardCDPCommand";
params: {
method: string;
params?: Record<string, unknown>;
sessionId?: string;
};
}
interface ExtensionResponseMessage {
id: number;
result?: unknown;
error?: string;
}
interface ExtensionEventMessage {
method: "forwardCDPEvent";
params: {
method: string;
params?: Record<string, unknown>;
sessionId?: string;
};
}
type ExtensionMessage =
| ExtensionResponseMessage
| ExtensionEventMessage
| { method: "log"; params: { level: string; args: string[] } };
// CDP message types
interface CDPCommand {
id: number;
method: string;
params?: Record<string, unknown>;
sessionId?: string;
}
interface CDPResponse {
id: number;
sessionId?: string;
result?: unknown;
error?: { message: string };
}
interface CDPEvent {
method: string;
sessionId?: string;
params?: Record<string, unknown>;
}
// ============================================================================
// Relay Server Implementation
// ============================================================================
export async function serveRelay(options: RelayOptions = {}): Promise<RelayServer> {
const port = options.port ?? 9222;
const host = options.host ?? "127.0.0.1";
// State
const connectedTargets = new Map<string, ConnectedTarget>();
const namedPages = new Map<string, string>(); // name -> sessionId
const playwrightClients = new Map<string, PlaywrightClient>();
let extensionWs: WSContext | null = null;
// Pending requests to extension
const extensionPendingRequests = new Map<
number,
{
resolve: (result: unknown) => void;
reject: (error: Error) => void;
}
>();
let extensionMessageId = 0;
// ============================================================================
// Helper Functions
// ============================================================================
function log(...args: unknown[]) {
console.log("[relay]", ...args);
}
function sendToPlaywright(message: CDPResponse | CDPEvent, clientId?: string) {
const messageStr = JSON.stringify(message);
if (clientId) {
const client = playwrightClients.get(clientId);
if (client) {
client.ws.send(messageStr);
}
} else {
// Broadcast to all clients
for (const client of playwrightClients.values()) {
client.ws.send(messageStr);
}
}
}
/**
* Send Target.attachedToTarget event with deduplication.
* Tracks which targets each client has seen to prevent "Duplicate target" errors.
*/
function sendAttachedToTarget(
target: ConnectedTarget,
clientId?: string,
waitingForDebugger = false
) {
const event: CDPEvent = {
method: "Target.attachedToTarget",
params: {
sessionId: target.sessionId,
targetInfo: { ...target.targetInfo, attached: true },
waitingForDebugger,
},
};
if (clientId) {
const client = playwrightClients.get(clientId);
if (client && !client.knownTargets.has(target.targetId)) {
client.knownTargets.add(target.targetId);
client.ws.send(JSON.stringify(event));
}
} else {
// Broadcast to all clients that don't know about this target yet
for (const client of playwrightClients.values()) {
if (!client.knownTargets.has(target.targetId)) {
client.knownTargets.add(target.targetId);
client.ws.send(JSON.stringify(event));
}
}
}
}
async function sendToExtension({
method,
params,
timeout = 30000,
}: {
method: string;
params?: Record<string, unknown>;
timeout?: number;
}): Promise<unknown> {
if (!extensionWs) {
throw new Error("Extension not connected");
}
const id = ++extensionMessageId;
const message = { id, method, params };
extensionWs.send(JSON.stringify(message));
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
extensionPendingRequests.delete(id);
reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`));
}, timeout);
extensionPendingRequests.set(id, {
resolve: (result) => {
clearTimeout(timeoutId);
resolve(result);
},
reject: (error) => {
clearTimeout(timeoutId);
reject(error);
},
});
});
}
async function routeCdpCommand({
method,
params,
sessionId,
}: {
method: string;
params?: Record<string, unknown>;
sessionId?: string;
}): Promise<unknown> {
// Handle some CDP commands locally
switch (method) {
case "Browser.getVersion":
return {
protocolVersion: "1.3",
product: "Chrome/Extension-Bridge",
revision: "1.0.0",
userAgent: "dev-browser-relay/1.0.0",
jsVersion: "V8",
};
case "Browser.setDownloadBehavior":
return {};
case "Target.setAutoAttach":
if (sessionId) {
break; // Forward to extension for child frames
}
return {};
case "Target.setDiscoverTargets":
return {};
case "Target.attachToBrowserTarget":
// Browser-level session - return a fake session since we only proxy tabs
return { sessionId: "browser" };
case "Target.detachFromTarget":
// If detaching from our fake "browser" session, just return success
if (sessionId === "browser" || params?.sessionId === "browser") {
return {};
}
// Otherwise forward to extension
break;
case "Target.attachToTarget": {
const targetId = params?.targetId as string;
if (!targetId) {
throw new Error("targetId is required for Target.attachToTarget");
}
for (const target of connectedTargets.values()) {
if (target.targetId === targetId) {
return { sessionId: target.sessionId };
}
}
throw new Error(`Target ${targetId} not found in connected targets`);
}
case "Target.getTargetInfo": {
const targetId = params?.targetId as string;
if (targetId) {
for (const target of connectedTargets.values()) {
if (target.targetId === targetId) {
return { targetInfo: target.targetInfo };
}
}
}
if (sessionId) {
const target = connectedTargets.get(sessionId);
if (target) {
return { targetInfo: target.targetInfo };
}
}
// Return first target if no specific one requested
const firstTarget = Array.from(connectedTargets.values())[0];
return { targetInfo: firstTarget?.targetInfo };
}
case "Target.getTargets":
return {
targetInfos: Array.from(connectedTargets.values()).map((t) => ({
...t.targetInfo,
attached: true,
})),
};
case "Target.createTarget":
case "Target.closeTarget":
// Forward to extension
return await sendToExtension({
method: "forwardCDPCommand",
params: { method, params },
});
}
// Forward all other commands to extension
return await sendToExtension({
method: "forwardCDPCommand",
params: { sessionId, method, params },
});
}
// ============================================================================
// HTTP/WebSocket Server
// ============================================================================
const app = new Hono();
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
// Health check / server info
app.get("/", (c) => {
return c.json({
wsEndpoint: `ws://${host}:${port}/cdp`,
extensionConnected: extensionWs !== null,
mode: "extension",
});
});
// List named pages
app.get("/pages", (c) => {
return c.json({
pages: Array.from(namedPages.keys()),
});
});
// Get or create a named page
app.post("/pages", async (c) => {
const body = await c.req.json();
const name = body.name as string;
if (!name) {
return c.json({ error: "name is required" }, 400);
}
// Check if page already exists by name
const existingSessionId = namedPages.get(name);
if (existingSessionId) {
const target = connectedTargets.get(existingSessionId);
if (target) {
// Activate the tab so it becomes the active tab
await sendToExtension({
method: "forwardCDPCommand",
params: {
method: "Target.activateTarget",
params: { targetId: target.targetId },
},
});
return c.json({
wsEndpoint: `ws://${host}:${port}/cdp`,
name,
targetId: target.targetId,
url: target.targetInfo.url,
});
}
// Session no longer valid, remove it
namedPages.delete(name);
}
// Create a new tab
if (!extensionWs) {
return c.json({ error: "Extension not connected" }, 503);
}
try {
const result = (await sendToExtension({
method: "forwardCDPCommand",
params: { method: "Target.createTarget", params: { url: "about:blank" } },
})) as { targetId: string };
// Wait for Target.attachedToTarget event to register the new target
await new Promise((resolve) => setTimeout(resolve, 200));
// Find and name the new target
for (const [sessionId, target] of connectedTargets) {
if (target.targetId === result.targetId) {
namedPages.set(name, sessionId);
// Activate the tab so it becomes the active tab
await sendToExtension({
method: "forwardCDPCommand",
params: {
method: "Target.activateTarget",
params: { targetId: target.targetId },
},
});
return c.json({
wsEndpoint: `ws://${host}:${port}/cdp`,
name,
targetId: target.targetId,
url: target.targetInfo.url,
});
}
}
throw new Error("Target created but not found in registry");
} catch (err) {
log("Error creating tab:", err);
return c.json({ error: (err as Error).message }, 500);
}
});
// Delete a named page (removes the name, doesn't close the tab)
app.delete("/pages/:name", (c) => {
const name = c.req.param("name");
const deleted = namedPages.delete(name);
return c.json({ success: deleted });
});
// ============================================================================
// Playwright Client WebSocket
// ============================================================================
app.get(
"/cdp/:clientId?",
upgradeWebSocket((c) => {
const clientId =
c.req.param("clientId") || `client-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return {
onOpen(_event, ws) {
if (playwrightClients.has(clientId)) {
log(`Rejecting duplicate client ID: ${clientId}`);
ws.close(1000, "Client ID already connected");
return;
}
playwrightClients.set(clientId, { id: clientId, ws, knownTargets: new Set() });
log(`Playwright client connected: ${clientId}`);
},
async onMessage(event, _ws) {
let message: CDPCommand;
try {
message = JSON.parse(event.data.toString());
} catch {
return;
}
const { id, sessionId, method, params } = message;
if (!extensionWs) {
sendToPlaywright(
{
id,
sessionId,
error: { message: "Extension not connected" },
},
clientId
);
return;
}
try {
const result = await routeCdpCommand({ method, params, sessionId });
// After Target.setAutoAttach, send attachedToTarget for existing targets
// Uses deduplication to prevent "Duplicate target" errors
if (method === "Target.setAutoAttach" && !sessionId) {
for (const target of connectedTargets.values()) {
sendAttachedToTarget(target, clientId);
}
}
// After Target.setDiscoverTargets, send targetCreated events
if (
method === "Target.setDiscoverTargets" &&
(params as { discover?: boolean })?.discover
) {
for (const target of connectedTargets.values()) {
sendToPlaywright(
{
method: "Target.targetCreated",
params: {
targetInfo: { ...target.targetInfo, attached: true },
},
},
clientId
);
}
}
// After Target.attachToTarget, send attachedToTarget event (with deduplication)
if (
method === "Target.attachToTarget" &&
(result as { sessionId?: string })?.sessionId
) {
const targetId = params?.targetId as string;
const target = Array.from(connectedTargets.values()).find(
(t) => t.targetId === targetId
);
if (target) {
sendAttachedToTarget(target, clientId);
}
}
sendToPlaywright({ id, sessionId, result }, clientId);
} catch (e) {
log("Error handling CDP command:", method, e);
sendToPlaywright(
{
id,
sessionId,
error: { message: (e as Error).message },
},
clientId
);
}
},
onClose() {
playwrightClients.delete(clientId);
log(`Playwright client disconnected: ${clientId}`);
},
onError(event) {
log(`Playwright WebSocket error [${clientId}]:`, event);
},
};
})
);
// ============================================================================
// Extension WebSocket
// ============================================================================
app.get(
"/extension",
upgradeWebSocket(() => {
return {
onOpen(_event, ws) {
if (extensionWs) {
log("Closing existing extension connection");
extensionWs.close(4001, "Extension Replaced");
// Clear state
connectedTargets.clear();
namedPages.clear();
for (const pending of extensionPendingRequests.values()) {
pending.reject(new Error("Extension connection replaced"));
}
extensionPendingRequests.clear();
}
extensionWs = ws;
log("Extension connected");
},
async onMessage(event, ws) {
let message: ExtensionMessage;
try {
message = JSON.parse(event.data.toString());
} catch {
ws.close(1000, "Invalid JSON");
return;
}
// Handle response to our request
if ("id" in message && typeof message.id === "number") {
const pending = extensionPendingRequests.get(message.id);
if (!pending) {
log("Unexpected response with id:", message.id);
return;
}
extensionPendingRequests.delete(message.id);
if ((message as ExtensionResponseMessage).error) {
pending.reject(new Error((message as ExtensionResponseMessage).error));
} else {
pending.resolve((message as ExtensionResponseMessage).result);
}
return;
}
// Handle log messages
if ("method" in message && message.method === "log") {
const { level, args } = message.params;
console.log(`[extension:${level}]`, ...args);
return;
}
// Handle CDP events from extension
if ("method" in message && message.method === "forwardCDPEvent") {
const eventMsg = message as ExtensionEventMessage;
const { method, params, sessionId } = eventMsg.params;
// Handle target lifecycle events
if (method === "Target.attachedToTarget") {
const targetParams = params as {
sessionId: string;
targetInfo: TargetInfo;
};
const target: ConnectedTarget = {
sessionId: targetParams.sessionId,
targetId: targetParams.targetInfo.targetId,
targetInfo: targetParams.targetInfo,
};
connectedTargets.set(targetParams.sessionId, target);
log(`Target attached: ${targetParams.targetInfo.url} (${targetParams.sessionId})`);
// Use deduplication helper - only sends to clients that don't know about this target
sendAttachedToTarget(target);
} else if (method === "Target.detachedFromTarget") {
const detachParams = params as { sessionId: string };
connectedTargets.delete(detachParams.sessionId);
// Also remove any name mapping
for (const [name, sid] of namedPages) {
if (sid === detachParams.sessionId) {
namedPages.delete(name);
break;
}
}
log(`Target detached: ${detachParams.sessionId}`);
sendToPlaywright({
method: "Target.detachedFromTarget",
params: detachParams,
});
} else if (method === "Target.targetInfoChanged") {
const infoParams = params as { targetInfo: TargetInfo };
for (const target of connectedTargets.values()) {
if (target.targetId === infoParams.targetInfo.targetId) {
target.targetInfo = infoParams.targetInfo;
break;
}
}
sendToPlaywright({
method: "Target.targetInfoChanged",
params: infoParams,
});
} else {
// Forward other CDP events to Playwright
sendToPlaywright({
sessionId,
method,
params,
});
}
}
},
onClose(_event, ws) {
if (extensionWs && extensionWs !== ws) {
log("Old extension connection closed");
return;
}
log("Extension disconnected");
for (const pending of extensionPendingRequests.values()) {
pending.reject(new Error("Extension connection closed"));
}
extensionPendingRequests.clear();
extensionWs = null;
connectedTargets.clear();
namedPages.clear();
// Close all Playwright clients
for (const client of playwrightClients.values()) {
client.ws.close(1000, "Extension disconnected");
}
playwrightClients.clear();
},
onError(event) {
log("Extension WebSocket error:", event);
},
};
})
);
// ============================================================================
// Start Server
// ============================================================================
const server = serve({ fetch: app.fetch, port, hostname: host });
injectWebSocket(server);
const wsEndpoint = `ws://${host}:${port}/cdp`;
log("CDP relay server started");
log(` HTTP: http://${host}:${port}`);
log(` CDP endpoint: ${wsEndpoint}`);
log(` Extension endpoint: ws://${host}:${port}/extension`);
log("");
log("Waiting for extension to connect...");
return {
wsEndpoint,
port,
async stop() {
for (const client of playwrightClients.values()) {
client.ws.close(1000, "Server stopped");
}
playwrightClients.clear();
extensionWs?.close(1000, "Server stopped");
server.close();
},
};
}
@@ -0,0 +1,223 @@
import { chromium } from "playwright";
import type { Browser, BrowserContext, Page } from "playwright";
import { beforeAll, afterAll, beforeEach, afterEach, describe, test, expect } from "vitest";
import { getSnapshotScript, clearSnapshotScriptCache } from "../browser-script";
let browser: Browser;
let context: BrowserContext;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
});
beforeEach(async () => {
context = await browser.newContext();
page = await context.newPage();
clearSnapshotScriptCache(); // Start fresh for each test
});
afterEach(async () => {
await context.close();
});
async function setContent(html: string): Promise<void> {
await page.setContent(html, { waitUntil: "domcontentloaded" });
}
async function getSnapshot(): Promise<string> {
const script = getSnapshotScript();
return await page.evaluate((s: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = globalThis as any;
if (!w.__devBrowser_getAISnapshot) {
// eslint-disable-next-line no-eval
eval(s);
}
return w.__devBrowser_getAISnapshot();
}, script);
}
async function selectRef(ref: string): Promise<unknown> {
return await page.evaluate((refId: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = globalThis as any;
const element = w.__devBrowser_selectSnapshotRef(refId);
return {
tagName: element.tagName,
textContent: element.textContent?.trim(),
};
}, ref);
}
describe("ARIA Snapshot", () => {
test("generates snapshot for simple page", async () => {
await setContent(`
<html>
<body>
<h1>Hello World</h1>
<button>Click me</button>
</body>
</html>
`);
const snapshot = await getSnapshot();
expect(snapshot).toContain("heading");
expect(snapshot).toContain("Hello World");
expect(snapshot).toContain("button");
expect(snapshot).toContain("Click me");
});
test("assigns refs to interactive elements", async () => {
await setContent(`
<html>
<body>
<button id="btn1">Button 1</button>
<button id="btn2">Button 2</button>
</body>
</html>
`);
const snapshot = await getSnapshot();
// Should have refs
expect(snapshot).toMatch(/\[ref=e\d+\]/);
});
test("refs persist on window.__devBrowserRefs", async () => {
await setContent(`
<html>
<body>
<button>Test Button</button>
</body>
</html>
`);
await getSnapshot();
// Check that refs are stored
const hasRefs = await page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = globalThis as any;
return typeof w.__devBrowserRefs === "object" && Object.keys(w.__devBrowserRefs).length > 0;
});
expect(hasRefs).toBe(true);
});
test("selectSnapshotRef returns element for valid ref", async () => {
await setContent(`
<html>
<body>
<button>My Button</button>
</body>
</html>
`);
const snapshot = await getSnapshot();
// Extract a ref from the snapshot
const refMatch = snapshot.match(/\[ref=(e\d+)\]/);
expect(refMatch).toBeTruthy();
expect(refMatch![1]).toBeDefined();
const ref = refMatch![1] as string;
// Select the element by ref
const result = (await selectRef(ref)) as { tagName: string; textContent: string };
expect(result.tagName).toBe("BUTTON");
expect(result.textContent).toBe("My Button");
});
test("includes links with URLs", async () => {
await setContent(`
<html>
<body>
<a href="https://example.com">Example Link</a>
</body>
</html>
`);
const snapshot = await getSnapshot();
expect(snapshot).toContain("link");
expect(snapshot).toContain("Example Link");
// URL should be included as a prop
expect(snapshot).toContain("/url:");
});
test("includes form elements", async () => {
await setContent(`
<html>
<body>
<input type="text" placeholder="Enter name" />
<input type="checkbox" />
<select>
<option>Option 1</option>
<option>Option 2</option>
</select>
</body>
</html>
`);
const snapshot = await getSnapshot();
expect(snapshot).toContain("textbox");
expect(snapshot).toContain("checkbox");
expect(snapshot).toContain("combobox");
});
test("renders nested structure correctly", async () => {
await setContent(`
<html>
<body>
<nav>
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</body>
</html>
`);
const snapshot = await getSnapshot();
expect(snapshot).toContain("navigation");
expect(snapshot).toContain("list");
expect(snapshot).toContain("listitem");
expect(snapshot).toContain("link");
});
test("handles disabled elements", async () => {
await setContent(`
<html>
<body>
<button disabled>Disabled Button</button>
</body>
</html>
`);
const snapshot = await getSnapshot();
expect(snapshot).toContain("[disabled]");
});
test("handles checked checkboxes", async () => {
await setContent(`
<html>
<body>
<input type="checkbox" checked />
</body>
</html>
`);
const snapshot = await getSnapshot();
expect(snapshot).toContain("[checked]");
});
});
@@ -0,0 +1,877 @@
/**
* Browser-injectable snapshot script.
*
* This module provides the snapshot functionality as a string that can be
* injected into the browser via page.addScriptTag() or page.evaluate().
*
* The approach is to read the compiled JavaScript at runtime and bundle it
* into a single script that exposes window.__devBrowser_getAISnapshot() and
* window.__devBrowser_selectSnapshotRef().
*/
import * as fs from "fs";
import * as path from "path";
// Cache the bundled script
let cachedScript: string | null = null;
/**
* Get the snapshot script that can be injected into the browser.
* Returns a self-contained JavaScript string that:
* 1. Defines all necessary functions (domUtils, roleUtils, yaml, ariaSnapshot)
* 2. Exposes window.__devBrowser_getAISnapshot()
* 3. Exposes window.__devBrowser_selectSnapshotRef()
*/
export function getSnapshotScript(): string {
if (cachedScript) return cachedScript;
// Read the compiled JavaScript files
const snapshotDir = path.dirname(new URL(import.meta.url).pathname);
// For now, we'll inline the functions directly
// In production, we could use a bundler like esbuild to create a single file
cachedScript = `
(function() {
// Skip if already injected
if (window.__devBrowser_getAISnapshot) return;
${getDomUtilsCode()}
${getYamlCode()}
${getRoleUtilsCode()}
${getAriaSnapshotCode()}
// Expose main functions
window.__devBrowser_getAISnapshot = getAISnapshot;
window.__devBrowser_selectSnapshotRef = selectSnapshotRef;
})();
`;
return cachedScript;
}
function getDomUtilsCode(): string {
return `
// === domUtils ===
let cacheStyle;
let cachesCounter = 0;
function beginDOMCaches() {
++cachesCounter;
cacheStyle = cacheStyle || new Map();
}
function endDOMCaches() {
if (!--cachesCounter) {
cacheStyle = undefined;
}
}
function getElementComputedStyle(element, pseudo) {
const cache = cacheStyle;
const cacheKey = pseudo ? undefined : element;
if (cache && cacheKey && cache.has(cacheKey)) return cache.get(cacheKey);
const style = element.ownerDocument && element.ownerDocument.defaultView
? element.ownerDocument.defaultView.getComputedStyle(element, pseudo)
: undefined;
if (cache && cacheKey) cache.set(cacheKey, style);
return style;
}
function parentElementOrShadowHost(element) {
if (element.parentElement) return element.parentElement;
if (!element.parentNode) return;
if (element.parentNode.nodeType === 11 && element.parentNode.host)
return element.parentNode.host;
}
function enclosingShadowRootOrDocument(element) {
let node = element;
while (node.parentNode) node = node.parentNode;
if (node.nodeType === 11 || node.nodeType === 9)
return node;
}
function closestCrossShadow(element, css, scope) {
while (element) {
const closest = element.closest(css);
if (scope && closest !== scope && closest?.contains(scope)) return;
if (closest) return closest;
element = enclosingShadowHost(element);
}
}
function enclosingShadowHost(element) {
while (element.parentElement) element = element.parentElement;
return parentElementOrShadowHost(element);
}
function isElementStyleVisibilityVisible(element, style) {
style = style || getElementComputedStyle(element);
if (!style) return true;
if (style.visibility !== "visible") return false;
const detailsOrSummary = element.closest("details,summary");
if (detailsOrSummary !== element && detailsOrSummary?.nodeName === "DETAILS" && !detailsOrSummary.open)
return false;
return true;
}
function computeBox(element) {
const style = getElementComputedStyle(element);
if (!style) return { visible: true, inline: false };
const cursor = style.cursor;
if (style.display === "contents") {
for (let child = element.firstChild; child; child = child.nextSibling) {
if (child.nodeType === 1 && isElementVisible(child))
return { visible: true, inline: false, cursor };
if (child.nodeType === 3 && isVisibleTextNode(child))
return { visible: true, inline: true, cursor };
}
return { visible: false, inline: false, cursor };
}
if (!isElementStyleVisibilityVisible(element, style))
return { cursor, visible: false, inline: false };
const rect = element.getBoundingClientRect();
return { rect, cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === "inline" };
}
function isElementVisible(element) {
return computeBox(element).visible;
}
function isVisibleTextNode(node) {
const range = node.ownerDocument.createRange();
range.selectNode(node);
const rect = range.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function elementSafeTagName(element) {
const tagName = element.tagName;
if (typeof tagName === "string") return tagName.toUpperCase();
if (element instanceof HTMLFormElement) return "FORM";
return element.tagName.toUpperCase();
}
function normalizeWhiteSpace(text) {
return text.split("\\u00A0").map(chunk =>
chunk.replace(/\\r\\n/g, "\\n").replace(/[\\u200b\\u00ad]/g, "").replace(/\\s\\s*/g, " ")
).join("\\u00A0").trim();
}
`;
}
function getYamlCode(): string {
return `
// === yaml ===
function yamlEscapeKeyIfNeeded(str) {
if (!yamlStringNeedsQuotes(str)) return str;
return "'" + str.replace(/'/g, "''") + "'";
}
function yamlEscapeValueIfNeeded(str) {
if (!yamlStringNeedsQuotes(str)) return str;
return '"' + str.replace(/[\\\\"\x00-\\x1f\\x7f-\\x9f]/g, c => {
switch (c) {
case "\\\\": return "\\\\\\\\";
case '"': return '\\\\"';
case "\\b": return "\\\\b";
case "\\f": return "\\\\f";
case "\\n": return "\\\\n";
case "\\r": return "\\\\r";
case "\\t": return "\\\\t";
default:
const code = c.charCodeAt(0);
return "\\\\x" + code.toString(16).padStart(2, "0");
}
}) + '"';
}
function yamlStringNeedsQuotes(str) {
if (str.length === 0) return true;
if (/^\\s|\\s$/.test(str)) return true;
if (/[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f-\\x9f]/.test(str)) return true;
if (/^-/.test(str)) return true;
if (/[\\n:](\\s|$)/.test(str)) return true;
if (/\\s#/.test(str)) return true;
if (/[\\n\\r]/.test(str)) return true;
if (/^[&*\\],?!>|@"'#%]/.test(str)) return true;
if (/[{}\`]/.test(str)) return true;
if (/^\\[/.test(str)) return true;
if (!isNaN(Number(str)) || ["y","n","yes","no","true","false","on","off","null"].includes(str.toLowerCase())) return true;
return false;
}
`;
}
function getRoleUtilsCode(): string {
return `
// === roleUtils ===
const validRoles = ["alert","alertdialog","application","article","banner","blockquote","button","caption","cell","checkbox","code","columnheader","combobox","complementary","contentinfo","definition","deletion","dialog","directory","document","emphasis","feed","figure","form","generic","grid","gridcell","group","heading","img","insertion","link","list","listbox","listitem","log","main","mark","marquee","math","meter","menu","menubar","menuitem","menuitemcheckbox","menuitemradio","navigation","none","note","option","paragraph","presentation","progressbar","radio","radiogroup","region","row","rowgroup","rowheader","scrollbar","search","searchbox","separator","slider","spinbutton","status","strong","subscript","superscript","switch","tab","table","tablist","tabpanel","term","textbox","time","timer","toolbar","tooltip","tree","treegrid","treeitem"];
let cacheAccessibleName;
let cacheIsHidden;
let cachePointerEvents;
let ariaCachesCounter = 0;
function beginAriaCaches() {
beginDOMCaches();
++ariaCachesCounter;
cacheAccessibleName = cacheAccessibleName || new Map();
cacheIsHidden = cacheIsHidden || new Map();
cachePointerEvents = cachePointerEvents || new Map();
}
function endAriaCaches() {
if (!--ariaCachesCounter) {
cacheAccessibleName = undefined;
cacheIsHidden = undefined;
cachePointerEvents = undefined;
}
endDOMCaches();
}
function hasExplicitAccessibleName(e) {
return e.hasAttribute("aria-label") || e.hasAttribute("aria-labelledby");
}
const kAncestorPreventingLandmark = "article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]";
const kGlobalAriaAttributes = [
["aria-atomic", undefined],["aria-busy", undefined],["aria-controls", undefined],["aria-current", undefined],
["aria-describedby", undefined],["aria-details", undefined],["aria-dropeffect", undefined],["aria-flowto", undefined],
["aria-grabbed", undefined],["aria-hidden", undefined],["aria-keyshortcuts", undefined],
["aria-label", ["caption","code","deletion","emphasis","generic","insertion","paragraph","presentation","strong","subscript","superscript"]],
["aria-labelledby", ["caption","code","deletion","emphasis","generic","insertion","paragraph","presentation","strong","subscript","superscript"]],
["aria-live", undefined],["aria-owns", undefined],["aria-relevant", undefined],["aria-roledescription", ["generic"]]
];
function hasGlobalAriaAttribute(element, forRole) {
return kGlobalAriaAttributes.some(([attr, prohibited]) => !prohibited?.includes(forRole || "") && element.hasAttribute(attr));
}
function hasTabIndex(element) {
return !Number.isNaN(Number(String(element.getAttribute("tabindex"))));
}
function isFocusable(element) {
return !isNativelyDisabled(element) && (isNativelyFocusable(element) || hasTabIndex(element));
}
function isNativelyFocusable(element) {
const tagName = elementSafeTagName(element);
if (["BUTTON","DETAILS","SELECT","TEXTAREA"].includes(tagName)) return true;
if (tagName === "A" || tagName === "AREA") return element.hasAttribute("href");
if (tagName === "INPUT") return !element.hidden;
return false;
}
function isNativelyDisabled(element) {
const isNativeFormControl = ["BUTTON","INPUT","SELECT","TEXTAREA","OPTION","OPTGROUP"].includes(elementSafeTagName(element));
return isNativeFormControl && (element.hasAttribute("disabled") || belongsToDisabledFieldSet(element));
}
function belongsToDisabledFieldSet(element) {
const fieldSetElement = element?.closest("FIELDSET[DISABLED]");
if (!fieldSetElement) return false;
const legendElement = fieldSetElement.querySelector(":scope > LEGEND");
return !legendElement || !legendElement.contains(element);
}
const inputTypeToRole = {button:"button",checkbox:"checkbox",image:"button",number:"spinbutton",radio:"radio",range:"slider",reset:"button",submit:"button"};
function getIdRefs(element, ref) {
if (!ref) return [];
const root = enclosingShadowRootOrDocument(element);
if (!root) return [];
try {
const ids = ref.split(" ").filter(id => !!id);
const result = [];
for (const id of ids) {
const firstElement = root.querySelector("#" + CSS.escape(id));
if (firstElement && !result.includes(firstElement)) result.push(firstElement);
}
return result;
} catch { return []; }
}
const kImplicitRoleByTagName = {
A: e => e.hasAttribute("href") ? "link" : null,
AREA: e => e.hasAttribute("href") ? "link" : null,
ARTICLE: () => "article", ASIDE: () => "complementary", BLOCKQUOTE: () => "blockquote", BUTTON: () => "button",
CAPTION: () => "caption", CODE: () => "code", DATALIST: () => "listbox", DD: () => "definition",
DEL: () => "deletion", DETAILS: () => "group", DFN: () => "term", DIALOG: () => "dialog", DT: () => "term",
EM: () => "emphasis", FIELDSET: () => "group", FIGURE: () => "figure",
FOOTER: e => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "contentinfo",
FORM: e => hasExplicitAccessibleName(e) ? "form" : null,
H1: () => "heading", H2: () => "heading", H3: () => "heading", H4: () => "heading", H5: () => "heading", H6: () => "heading",
HEADER: e => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "banner",
HR: () => "separator", HTML: () => "document",
IMG: e => e.getAttribute("alt") === "" && !e.getAttribute("title") && !hasGlobalAriaAttribute(e) && !hasTabIndex(e) ? "presentation" : "img",
INPUT: e => {
const type = e.type.toLowerCase();
if (type === "search") return e.hasAttribute("list") ? "combobox" : "searchbox";
if (["email","tel","text","url",""].includes(type)) {
const list = getIdRefs(e, e.getAttribute("list"))[0];
return list && elementSafeTagName(list) === "DATALIST" ? "combobox" : "textbox";
}
if (type === "hidden") return null;
if (type === "file") return "button";
return inputTypeToRole[type] || "textbox";
},
INS: () => "insertion", LI: () => "listitem", MAIN: () => "main", MARK: () => "mark", MATH: () => "math",
MENU: () => "list", METER: () => "meter", NAV: () => "navigation", OL: () => "list", OPTGROUP: () => "group",
OPTION: () => "option", OUTPUT: () => "status", P: () => "paragraph", PROGRESS: () => "progressbar",
SEARCH: () => "search", SECTION: e => hasExplicitAccessibleName(e) ? "region" : null,
SELECT: e => e.hasAttribute("multiple") || e.size > 1 ? "listbox" : "combobox",
STRONG: () => "strong", SUB: () => "subscript", SUP: () => "superscript", SVG: () => "img",
TABLE: () => "table", TBODY: () => "rowgroup",
TD: e => { const table = closestCrossShadow(e, "table"); const role = table ? getExplicitAriaRole(table) : ""; return role === "grid" || role === "treegrid" ? "gridcell" : "cell"; },
TEXTAREA: () => "textbox", TFOOT: () => "rowgroup",
TH: e => { const scope = e.getAttribute("scope"); if (scope === "col" || scope === "colgroup") return "columnheader"; if (scope === "row" || scope === "rowgroup") return "rowheader"; return "columnheader"; },
THEAD: () => "rowgroup", TIME: () => "time", TR: () => "row", UL: () => "list"
};
function getExplicitAriaRole(element) {
const roles = (element.getAttribute("role") || "").split(" ").map(role => role.trim());
return roles.find(role => validRoles.includes(role)) || null;
}
function getImplicitAriaRole(element) {
const fn = kImplicitRoleByTagName[elementSafeTagName(element)];
return fn ? fn(element) : null;
}
function hasPresentationConflictResolution(element, role) {
return hasGlobalAriaAttribute(element, role) || isFocusable(element);
}
function getAriaRole(element) {
const explicitRole = getExplicitAriaRole(element);
if (!explicitRole) return getImplicitAriaRole(element);
if (explicitRole === "none" || explicitRole === "presentation") {
const implicitRole = getImplicitAriaRole(element);
if (hasPresentationConflictResolution(element, implicitRole)) return implicitRole;
}
return explicitRole;
}
function getAriaBoolean(attr) {
return attr === null ? undefined : attr.toLowerCase() === "true";
}
function isElementIgnoredForAria(element) {
return ["STYLE","SCRIPT","NOSCRIPT","TEMPLATE"].includes(elementSafeTagName(element));
}
function isElementHiddenForAria(element) {
if (isElementIgnoredForAria(element)) return true;
const style = getElementComputedStyle(element);
const isSlot = element.nodeName === "SLOT";
if (style?.display === "contents" && !isSlot) {
for (let child = element.firstChild; child; child = child.nextSibling) {
if (child.nodeType === 1 && !isElementHiddenForAria(child)) return false;
if (child.nodeType === 3 && isVisibleTextNode(child)) return false;
}
return true;
}
const isOptionInsideSelect = element.nodeName === "OPTION" && !!element.closest("select");
if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style)) return true;
return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element);
}
function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element) {
let hidden = cacheIsHidden?.get(element);
if (hidden === undefined) {
hidden = false;
if (element.parentElement && element.parentElement.shadowRoot && !element.assignedSlot) hidden = true;
if (!hidden) {
const style = getElementComputedStyle(element);
hidden = !style || style.display === "none" || getAriaBoolean(element.getAttribute("aria-hidden")) === true;
}
if (!hidden) {
const parent = parentElementOrShadowHost(element);
if (parent) hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent);
}
cacheIsHidden?.set(element, hidden);
}
return hidden;
}
function getAriaLabelledByElements(element) {
const ref = element.getAttribute("aria-labelledby");
if (ref === null) return null;
const refs = getIdRefs(element, ref);
return refs.length ? refs : null;
}
function getElementAccessibleName(element, includeHidden) {
let accessibleName = cacheAccessibleName?.get(element);
if (accessibleName === undefined) {
accessibleName = "";
const elementProhibitsNaming = ["caption","code","definition","deletion","emphasis","generic","insertion","mark","paragraph","presentation","strong","subscript","suggestion","superscript","term","time"].includes(getAriaRole(element) || "");
if (!elementProhibitsNaming) {
accessibleName = normalizeWhiteSpace(getTextAlternativeInternal(element, { includeHidden, visitedElements: new Set(), embeddedInTargetElement: "self" }));
}
cacheAccessibleName?.set(element, accessibleName);
}
return accessibleName;
}
function getTextAlternativeInternal(element, options) {
if (options.visitedElements.has(element)) return "";
const childOptions = { ...options, embeddedInTargetElement: options.embeddedInTargetElement === "self" ? "descendant" : options.embeddedInTargetElement };
if (!options.includeHidden) {
const isEmbeddedInHiddenReferenceTraversal = !!options.embeddedInLabelledBy?.hidden || !!options.embeddedInLabel?.hidden;
if (isElementIgnoredForAria(element) || (!isEmbeddedInHiddenReferenceTraversal && isElementHiddenForAria(element))) {
options.visitedElements.add(element);
return "";
}
}
const labelledBy = getAriaLabelledByElements(element);
if (!options.embeddedInLabelledBy) {
const accessibleName = (labelledBy || []).map(ref => getTextAlternativeInternal(ref, { ...options, embeddedInLabelledBy: { element: ref, hidden: isElementHiddenForAria(ref) }, embeddedInTargetElement: undefined, embeddedInLabel: undefined })).join(" ");
if (accessibleName) return accessibleName;
}
const role = getAriaRole(element) || "";
const tagName = elementSafeTagName(element);
const ariaLabel = element.getAttribute("aria-label") || "";
if (ariaLabel.trim()) { options.visitedElements.add(element); return ariaLabel; }
if (!["presentation","none"].includes(role)) {
if (tagName === "INPUT" && ["button","submit","reset"].includes(element.type)) {
options.visitedElements.add(element);
const value = element.value || "";
if (value.trim()) return value;
if (element.type === "submit") return "Submit";
if (element.type === "reset") return "Reset";
return element.getAttribute("title") || "";
}
if (tagName === "INPUT" && element.type === "image") {
options.visitedElements.add(element);
const alt = element.getAttribute("alt") || "";
if (alt.trim()) return alt;
const title = element.getAttribute("title") || "";
if (title.trim()) return title;
return "Submit";
}
if (tagName === "IMG") {
options.visitedElements.add(element);
const alt = element.getAttribute("alt") || "";
if (alt.trim()) return alt;
return element.getAttribute("title") || "";
}
if (!labelledBy && ["BUTTON","INPUT","TEXTAREA","SELECT"].includes(tagName)) {
const labels = element.labels;
if (labels?.length) {
options.visitedElements.add(element);
return [...labels].map(label => getTextAlternativeInternal(label, { ...options, embeddedInLabel: { element: label, hidden: isElementHiddenForAria(label) }, embeddedInLabelledBy: undefined, embeddedInTargetElement: undefined })).filter(name => !!name).join(" ");
}
}
}
const allowsNameFromContent = ["button","cell","checkbox","columnheader","gridcell","heading","link","menuitem","menuitemcheckbox","menuitemradio","option","radio","row","rowheader","switch","tab","tooltip","treeitem"].includes(role);
if (allowsNameFromContent || !!options.embeddedInLabelledBy || !!options.embeddedInLabel) {
options.visitedElements.add(element);
const accessibleName = innerAccumulatedElementText(element, childOptions);
const maybeTrimmedAccessibleName = options.embeddedInTargetElement === "self" ? accessibleName.trim() : accessibleName;
if (maybeTrimmedAccessibleName) return accessibleName;
}
if (!["presentation","none"].includes(role) || tagName === "IFRAME") {
options.visitedElements.add(element);
const title = element.getAttribute("title") || "";
if (title.trim()) return title;
}
options.visitedElements.add(element);
return "";
}
function innerAccumulatedElementText(element, options) {
const tokens = [];
const visit = (node, skipSlotted) => {
if (skipSlotted && node.assignedSlot) return;
if (node.nodeType === 1) {
const display = getElementComputedStyle(node)?.display || "inline";
let token = getTextAlternativeInternal(node, options);
if (display !== "inline" || node.nodeName === "BR") token = " " + token + " ";
tokens.push(token);
} else if (node.nodeType === 3) {
tokens.push(node.textContent || "");
}
};
const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : [];
if (assignedNodes.length) {
for (const child of assignedNodes) visit(child, false);
} else {
for (let child = element.firstChild; child; child = child.nextSibling) visit(child, true);
if (element.shadowRoot) {
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) visit(child, true);
}
}
return tokens.join("");
}
const kAriaCheckedRoles = ["checkbox","menuitemcheckbox","option","radio","switch","menuitemradio","treeitem"];
function getAriaChecked(element) {
const tagName = elementSafeTagName(element);
if (tagName === "INPUT" && element.indeterminate) return "mixed";
if (tagName === "INPUT" && ["checkbox","radio"].includes(element.type)) return element.checked;
if (kAriaCheckedRoles.includes(getAriaRole(element) || "")) {
const checked = element.getAttribute("aria-checked");
if (checked === "true") return true;
if (checked === "mixed") return "mixed";
return false;
}
return false;
}
const kAriaDisabledRoles = ["application","button","composite","gridcell","group","input","link","menuitem","scrollbar","separator","tab","checkbox","columnheader","combobox","grid","listbox","menu","menubar","menuitemcheckbox","menuitemradio","option","radio","radiogroup","row","rowheader","searchbox","select","slider","spinbutton","switch","tablist","textbox","toolbar","tree","treegrid","treeitem"];
function getAriaDisabled(element) {
return isNativelyDisabled(element) || hasExplicitAriaDisabled(element);
}
function hasExplicitAriaDisabled(element, isAncestor) {
if (!element) return false;
if (isAncestor || kAriaDisabledRoles.includes(getAriaRole(element) || "")) {
const attribute = (element.getAttribute("aria-disabled") || "").toLowerCase();
if (attribute === "true") return true;
if (attribute === "false") return false;
return hasExplicitAriaDisabled(parentElementOrShadowHost(element), true);
}
return false;
}
const kAriaExpandedRoles = ["application","button","checkbox","combobox","gridcell","link","listbox","menuitem","row","rowheader","tab","treeitem","columnheader","menuitemcheckbox","menuitemradio","switch"];
function getAriaExpanded(element) {
if (elementSafeTagName(element) === "DETAILS") return element.open;
if (kAriaExpandedRoles.includes(getAriaRole(element) || "")) {
const expanded = element.getAttribute("aria-expanded");
if (expanded === null) return undefined;
if (expanded === "true") return true;
return false;
}
return undefined;
}
const kAriaLevelRoles = ["heading","listitem","row","treeitem"];
function getAriaLevel(element) {
const native = {H1:1,H2:2,H3:3,H4:4,H5:5,H6:6}[elementSafeTagName(element)];
if (native) return native;
if (kAriaLevelRoles.includes(getAriaRole(element) || "")) {
const attr = element.getAttribute("aria-level");
const value = attr === null ? Number.NaN : Number(attr);
if (Number.isInteger(value) && value >= 1) return value;
}
return 0;
}
const kAriaPressedRoles = ["button"];
function getAriaPressed(element) {
if (kAriaPressedRoles.includes(getAriaRole(element) || "")) {
const pressed = element.getAttribute("aria-pressed");
if (pressed === "true") return true;
if (pressed === "mixed") return "mixed";
}
return false;
}
const kAriaSelectedRoles = ["gridcell","option","row","tab","rowheader","columnheader","treeitem"];
function getAriaSelected(element) {
if (elementSafeTagName(element) === "OPTION") return element.selected;
if (kAriaSelectedRoles.includes(getAriaRole(element) || "")) return getAriaBoolean(element.getAttribute("aria-selected")) === true;
return false;
}
function receivesPointerEvents(element) {
const cache = cachePointerEvents;
let e = element;
let result;
const parents = [];
for (; e; e = parentElementOrShadowHost(e)) {
const cached = cache?.get(e);
if (cached !== undefined) { result = cached; break; }
parents.push(e);
const style = getElementComputedStyle(e);
if (!style) { result = true; break; }
const value = style.pointerEvents;
if (value) { result = value !== "none"; break; }
}
if (result === undefined) result = true;
for (const parent of parents) cache?.set(parent, result);
return result;
}
function getCSSContent(element, pseudo) {
const style = getElementComputedStyle(element, pseudo);
if (!style) return undefined;
const contentValue = style.content;
if (!contentValue || contentValue === "none" || contentValue === "normal") return undefined;
if (style.display === "none" || style.visibility === "hidden") return undefined;
const match = contentValue.match(/^"(.*)"$/);
if (match) {
const content = match[1].replace(/\\\\"/g, '"');
if (pseudo) {
const display = style.display || "inline";
if (display !== "inline") return " " + content + " ";
}
return content;
}
return undefined;
}
`;
}
function getAriaSnapshotCode(): string {
return `
// === ariaSnapshot ===
let lastRef = 0;
function generateAriaTree(rootElement) {
const options = { visibility: "ariaOrVisible", refs: "interactable", refPrefix: "", includeGenericRole: true, renderActive: true, renderCursorPointer: true };
const visited = new Set();
const snapshot = {
root: { role: "fragment", name: "", children: [], element: rootElement, props: {}, box: computeBox(rootElement), receivesPointerEvents: true },
elements: new Map(),
refs: new Map(),
iframeRefs: []
};
const visit = (ariaNode, node, parentElementVisible) => {
if (visited.has(node)) return;
visited.add(node);
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
if (!parentElementVisible) return;
const text = node.nodeValue;
if (ariaNode.role !== "textbox" && text) ariaNode.children.push(node.nodeValue || "");
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const element = node;
const isElementVisibleForAria = !isElementHiddenForAria(element);
let visible = isElementVisibleForAria;
if (options.visibility === "ariaOrVisible") visible = isElementVisibleForAria || isElementVisible(element);
if (options.visibility === "ariaAndVisible") visible = isElementVisibleForAria && isElementVisible(element);
if (options.visibility === "aria" && !visible) return;
const ariaChildren = [];
if (element.hasAttribute("aria-owns")) {
const ids = element.getAttribute("aria-owns").split(/\\s+/);
for (const id of ids) {
const ownedElement = rootElement.ownerDocument.getElementById(id);
if (ownedElement) ariaChildren.push(ownedElement);
}
}
const childAriaNode = visible ? toAriaNode(element, options) : null;
if (childAriaNode) {
if (childAriaNode.ref) {
snapshot.elements.set(childAriaNode.ref, element);
snapshot.refs.set(element, childAriaNode.ref);
if (childAriaNode.role === "iframe") snapshot.iframeRefs.push(childAriaNode.ref);
}
ariaNode.children.push(childAriaNode);
}
processElement(childAriaNode || ariaNode, element, ariaChildren, visible);
};
function processElement(ariaNode, element, ariaChildren, parentElementVisible) {
const display = getElementComputedStyle(element)?.display || "inline";
const treatAsBlock = display !== "inline" || element.nodeName === "BR" ? " " : "";
if (treatAsBlock) ariaNode.children.push(treatAsBlock);
ariaNode.children.push(getCSSContent(element, "::before") || "");
const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : [];
if (assignedNodes.length) {
for (const child of assignedNodes) visit(ariaNode, child, parentElementVisible);
} else {
for (let child = element.firstChild; child; child = child.nextSibling) {
if (!child.assignedSlot) visit(ariaNode, child, parentElementVisible);
}
if (element.shadowRoot) {
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) visit(ariaNode, child, parentElementVisible);
}
}
for (const child of ariaChildren) visit(ariaNode, child, parentElementVisible);
ariaNode.children.push(getCSSContent(element, "::after") || "");
if (treatAsBlock) ariaNode.children.push(treatAsBlock);
if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0]) ariaNode.children = [];
if (ariaNode.role === "link" && element.hasAttribute("href")) ariaNode.props["url"] = element.getAttribute("href");
if (ariaNode.role === "textbox" && element.hasAttribute("placeholder") && element.getAttribute("placeholder") !== ariaNode.name) ariaNode.props["placeholder"] = element.getAttribute("placeholder");
}
beginAriaCaches();
try { visit(snapshot.root, rootElement, true); }
finally { endAriaCaches(); }
normalizeStringChildren(snapshot.root);
normalizeGenericRoles(snapshot.root);
return snapshot;
}
function computeAriaRef(ariaNode, options) {
if (options.refs === "none") return;
if (options.refs === "interactable" && (!ariaNode.box.visible || !ariaNode.receivesPointerEvents)) return;
let ariaRef = ariaNode.element._ariaRef;
if (!ariaRef || ariaRef.role !== ariaNode.role || ariaRef.name !== ariaNode.name) {
ariaRef = { role: ariaNode.role, name: ariaNode.name, ref: (options.refPrefix || "") + "e" + (++lastRef) };
ariaNode.element._ariaRef = ariaRef;
}
ariaNode.ref = ariaRef.ref;
}
function toAriaNode(element, options) {
const active = element.ownerDocument.activeElement === element;
if (element.nodeName === "IFRAME") {
const ariaNode = { role: "iframe", name: "", children: [], props: {}, element, box: computeBox(element), receivesPointerEvents: true, active };
computeAriaRef(ariaNode, options);
return ariaNode;
}
const defaultRole = options.includeGenericRole ? "generic" : null;
const role = getAriaRole(element) || defaultRole;
if (!role || role === "presentation" || role === "none") return null;
const name = normalizeWhiteSpace(getElementAccessibleName(element, false) || "");
const receivesPointerEventsValue = receivesPointerEvents(element);
const box = computeBox(element);
if (role === "generic" && box.inline && element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE) return null;
const result = { role, name, children: [], props: {}, element, box, receivesPointerEvents: receivesPointerEventsValue, active };
computeAriaRef(result, options);
if (kAriaCheckedRoles.includes(role)) result.checked = getAriaChecked(element);
if (kAriaDisabledRoles.includes(role)) result.disabled = getAriaDisabled(element);
if (kAriaExpandedRoles.includes(role)) result.expanded = getAriaExpanded(element);
if (kAriaLevelRoles.includes(role)) result.level = getAriaLevel(element);
if (kAriaPressedRoles.includes(role)) result.pressed = getAriaPressed(element);
if (kAriaSelectedRoles.includes(role)) result.selected = getAriaSelected(element);
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
if (element.type !== "checkbox" && element.type !== "radio" && element.type !== "file") result.children = [element.value];
}
return result;
}
function normalizeGenericRoles(node) {
const normalizeChildren = (node) => {
const result = [];
for (const child of node.children || []) {
if (typeof child === "string") { result.push(child); continue; }
const normalized = normalizeChildren(child);
result.push(...normalized);
}
const removeSelf = node.role === "generic" && !node.name && result.length <= 1 && result.every(c => typeof c !== "string" && !!c.ref);
if (removeSelf) return result;
node.children = result;
return [node];
};
normalizeChildren(node);
}
function normalizeStringChildren(rootA11yNode) {
const flushChildren = (buffer, normalizedChildren) => {
if (!buffer.length) return;
const text = normalizeWhiteSpace(buffer.join(""));
if (text) normalizedChildren.push(text);
buffer.length = 0;
};
const visit = (ariaNode) => {
const normalizedChildren = [];
const buffer = [];
for (const child of ariaNode.children || []) {
if (typeof child === "string") { buffer.push(child); }
else { flushChildren(buffer, normalizedChildren); visit(child); normalizedChildren.push(child); }
}
flushChildren(buffer, normalizedChildren);
ariaNode.children = normalizedChildren.length ? normalizedChildren : [];
if (ariaNode.children.length === 1 && ariaNode.children[0] === ariaNode.name) ariaNode.children = [];
};
visit(rootA11yNode);
}
function hasPointerCursor(ariaNode) { return ariaNode.box.cursor === "pointer"; }
function renderAriaTree(ariaSnapshot) {
const options = { visibility: "ariaOrVisible", refs: "interactable", refPrefix: "", includeGenericRole: true, renderActive: true, renderCursorPointer: true };
const lines = [];
let nodesToRender = ariaSnapshot.root.role === "fragment" ? ariaSnapshot.root.children : [ariaSnapshot.root];
const visitText = (text, indent) => {
const escaped = yamlEscapeValueIfNeeded(text);
if (escaped) lines.push(indent + "- text: " + escaped);
};
const createKey = (ariaNode, renderCursorPointer) => {
let key = ariaNode.role;
if (ariaNode.name && ariaNode.name.length <= 900) {
const name = ariaNode.name;
if (name) {
const stringifiedName = name.startsWith("/") && name.endsWith("/") ? name : JSON.stringify(name);
key += " " + stringifiedName;
}
}
if (ariaNode.checked === "mixed") key += " [checked=mixed]";
if (ariaNode.checked === true) key += " [checked]";
if (ariaNode.disabled) key += " [disabled]";
if (ariaNode.expanded) key += " [expanded]";
if (ariaNode.active && options.renderActive) key += " [active]";
if (ariaNode.level) key += " [level=" + ariaNode.level + "]";
if (ariaNode.pressed === "mixed") key += " [pressed=mixed]";
if (ariaNode.pressed === true) key += " [pressed]";
if (ariaNode.selected === true) key += " [selected]";
if (ariaNode.ref) {
key += " [ref=" + ariaNode.ref + "]";
if (renderCursorPointer && hasPointerCursor(ariaNode)) key += " [cursor=pointer]";
}
return key;
};
const getSingleInlinedTextChild = (ariaNode) => {
return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === "string" && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined;
};
const visit = (ariaNode, indent, renderCursorPointer) => {
const escapedKey = indent + "- " + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer));
const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode);
if (!ariaNode.children.length && !Object.keys(ariaNode.props).length) {
lines.push(escapedKey);
} else if (singleInlinedTextChild !== undefined) {
lines.push(escapedKey + ": " + yamlEscapeValueIfNeeded(singleInlinedTextChild));
} else {
lines.push(escapedKey + ":");
for (const [name, value] of Object.entries(ariaNode.props)) lines.push(indent + " - /" + name + ": " + yamlEscapeValueIfNeeded(value));
const childIndent = indent + " ";
const inCursorPointer = !!ariaNode.ref && renderCursorPointer && hasPointerCursor(ariaNode);
for (const child of ariaNode.children) {
if (typeof child === "string") visitText(child, childIndent);
else visit(child, childIndent, renderCursorPointer && !inCursorPointer);
}
}
};
for (const nodeToRender of nodesToRender) {
if (typeof nodeToRender === "string") visitText(nodeToRender, "");
else visit(nodeToRender, "", !!options.renderCursorPointer);
}
return lines.join("\\n");
}
function getAISnapshot() {
const snapshot = generateAriaTree(document.body);
const refsObject = {};
for (const [ref, element] of snapshot.elements) refsObject[ref] = element;
window.__devBrowserRefs = refsObject;
return renderAriaTree(snapshot);
}
function selectSnapshotRef(ref) {
const refs = window.__devBrowserRefs;
if (!refs) throw new Error("No snapshot refs found. Call getAISnapshot first.");
const element = refs[ref];
if (!element) throw new Error('Ref "' + ref + '" not found. Available refs: ' + Object.keys(refs).join(", "));
return element;
}
`;
}
/**
* Clear the cached script (useful for development/testing)
*/
export function clearSnapshotScriptCache(): void {
cachedScript = null;
}
@@ -0,0 +1,14 @@
/**
* ARIA Snapshot module for dev-browser.
*
* Provides Playwright-compatible ARIA snapshots with cross-connection ref persistence.
* Refs are stored on window.__devBrowserRefs and survive across Playwright reconnections.
*
* Usage:
* import { getSnapshotScript } from './snapshot';
* const script = getSnapshotScript();
* await page.evaluate(script);
* // Now window.__devBrowser_getAISnapshot() and window.__devBrowser_selectSnapshotRef(ref) are available
*/
export { getSnapshotScript, clearSnapshotScriptCache } from "./browser-script";
@@ -0,0 +1,13 @@
/**
* Injectable snapshot script for browser context.
*
* This module provides the getSnapshotScript function that returns a
* self-contained JavaScript string for injection into browser contexts.
*
* The script is injected via page.evaluate() and exposes:
* - window.__devBrowser_getAISnapshot(): Returns ARIA snapshot YAML
* - window.__devBrowser_selectSnapshotRef(ref): Returns element for given ref
* - window.__devBrowserRefs: Map of ref -> Element (persists across connections)
*/
export { getSnapshotScript, clearSnapshotScriptCache } from "./browser-script";
+34
View File
@@ -0,0 +1,34 @@
// API request/response types - shared between client and server
export interface ServeOptions {
port?: number;
headless?: boolean;
cdpPort?: number;
/** Directory to store persistent browser profiles (cookies, localStorage, etc.) */
profileDir?: string;
}
export interface ViewportSize {
width: number;
height: number;
}
export interface GetPageRequest {
name: string;
/** Optional viewport size for new pages */
viewport?: ViewportSize;
}
export interface GetPageResponse {
wsEndpoint: string;
name: string;
targetId: string; // CDP target ID for reliable page matching
}
export interface ListPagesResponse {
pages: string[];
}
export interface ServerInfoResponse {
wsEndpoint: string;
}
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Path aliases
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"include": ["src/**/*", "scripts/**/*"]
}
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
testTimeout: 60000, // Playwright tests can be slow
hookTimeout: 60000,
teardownTimeout: 60000,
},
});
+34
View File
@@ -0,0 +1,34 @@
---
name: dev-local
description: Start, inspect, reset, or stop the complete local Sauci stack. Use for local development, service logs, port diagnosis, and full-stack test setup.
---
# Local Sauci stack
Use `scripts/dev-local.sh`; do not manually mix remote and local services.
| Service | Port |
|---|---:|
| PostgreSQL | 54320 |
| API | 3003 |
| web | 3000 (overridable with `SAUCI_WEB_PORT`) |
| admin | 3001 (overridable with `SAUCI_ADMIN_PORT`) |
| MCP | 3002 (overridable with `SAUCI_MCP_PORT`) |
| Expo Metro | 8081 |
Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime,
and the configured hosted Auth credentials where an authenticated client needs them.
- `scripts/dev-local.sh up`: idempotently start PostgreSQL and services.
- `status`, `logs <service>`, `restart <service>`, `attach`: inspect.
- `reset`: local-only database reset plus deterministic fixtures.
- `e2e`: start or safely reuse the local stack, validate local endpoints, and
run Playwright with launcher-derived URLs and a loopback PostgreSQL fixture.
- `ios` / `android`: launch the native app against the standalone local API and
configured hosted Auth project.
- `down`; `down --all`: stop services, optionally infrastructure.
Hosted Supabase provides Auth only. Local E2E never starts or writes to a
Supabase data plane. If a port is occupied, use `status` and inspect its owning
process before stopping anything. If a tmux window exited, read its logs before
restarting it.
+140
View File
@@ -0,0 +1,140 @@
---
name: generate-release
description: Generate a signed Sauci mobile release by bumping the public version, writing on-brand release notes, building and verifying iOS and Android binaries, committing release metadata, and tagging the release commit. Use for an actual production mobile release; use release-mobile for preparation, one-platform builds, uploads, or submission work that does not require the complete release lifecycle.
---
# Generate release
Create a reproducible release whose tag identifies the reviewed release source
and final platform build metadata. Read `apps/mobile/AGENTS.md`,
`docs/releasing.md`, and the `release-mobile` and `commit` skills before acting. Read
[references/release-notes-voice.md](references/release-notes-voice.md) before
drafting notes and [references/artifact-verification.md](references/artifact-verification.md)
before accepting a build.
A request to generate or create a release authorizes the scoped version edits,
release notes, local builds, release commits, and one local annotated tag. When
the request also asks to ship, upload, submit, or release to the stores, it
authorizes the complete store pipeline for the stated platforms after the exact
artifact has been verified: upload, App Review / Google Play review submission,
and rollout, including full production rollout (see the `release-mobile` skill).
It does not authorize pushing commits or tags, changing signing credentials, or
regenerating native projects unless the request explicitly includes that action.
## Defaults
- Build both iOS and Android production binaries unless the user narrows the
platforms.
- Increment the patch version unless the user specifies `minor`, `major`, or an
exact SemVer version.
- Use `v<version>` for the annotated Git tag.
- Write artifacts beneath ignored `dist/releases/v<version>/`; never commit
binaries, credentials, logs, or build directories.
## Preflight
Fetch the remote and inspect the worktree, branch, upstream, existing tags,
toolchains, EAS authentication, and signing availability without changing them.
Require all of the following:
- a named branch, normally `main` for a production release;
- a clean starting worktree with `HEAD` equal to its fetched upstream;
- the previous reachable `v[0-9]*` release tag and no collision for the next tag;
- consistent public and OTA runtime versions across `app.json`, explicit
`app.config.js` runtime, Android, and every iOS target
(`scripts/set_version.mjs --check`);
- `npm run release:preflight -w @sauci/mobile`, which also checks the iOS widget
effective build settings and OTA certificate archive inclusion;
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
- the configured production API/Auth/RevenueCat environment, without printing
secret values.
For an OTA release, run `npm run ota:preflight:production -w @sauci/mobile`
before any publish. This exercises the EOAS-compatible Expo export environment
without contacting the update server or uploading an update.
Do not switch branches, discard work, repair credentials, push, or weaken branch
protection merely to pass preflight. Stop with the exact blocker. Never run
`expo prebuild` as part of this workflow.
## Prepare the release
1. Derive the next version with `scripts/set_version.mjs --bump <kind>` or set an
explicit version with `--version <x.y.z>`. The script updates the checked-in
public-version sources but deliberately leaves platform build numbers to EAS.
2. Review the complete first-parent and ordinary commit diff from the previous
reachable release tag to the starting commit. Inspect the actual product diff
for every user-facing claim; commit subjects alone are not evidence.
3. Add the new entry immediately below the title in `docs/release-notes.md`.
Describe user-visible value, meaningful fixes, privacy or reliability changes,
and only claims supported by the diff. Omit internal migrations, refactors,
CI, infrastructure, dependency churn, and implementation jargon unless the
user experiences a direct result. Use a temporary `Build numbers pending
artifact verification` line until the binaries have been inspected; never
guess or predict build numbers.
4. Run `npm run verify:full` and the native acceptance required by the
`release-mobile` skill. A missing prerequisite is a failure/limitation, not a
pass.
5. Review the scoped diff and create `chore(release): prepare v<version>`. Do not
stage unrelated paths. This clean release-preparation commit must descend
directly from the fetched upstream commit.
## Build and capture platform metadata
Build from the clean prepared source using the package scripts in
`apps/mobile/package.json`, with `EAS_LOCAL_BUILD_ARTIFACTS_DIR` set to the
versioned artifact directory. When building both platforms, build Android first,
then iOS.
For local EAS, retain the working directory and logs under a stable ignored path,
set `EAS_LOCAL_BUILD_ARTIFACTS_DIR`, and preflight available disk space before
starting. Set `EAS_LOCAL_BUILD_PLUGIN_PATH` only to the matching plugin
executable `bin/run`, never a package directory or a hardcoded cache location.
Classify plugin, archive, npm, and ENOSPC failures as pre-compilation failures;
repair and preflight them without spending another build. Make at most one
meaningful build for each newly proven input state. After a signing, profile, or
capability failure, read back and prove the provider correction before rebuilding.
After each build:
1. Inspect the worktree before doing anything else. Accept only the documented
EAS build-number changes in `app.json`, that platform's native version source,
and the iOS widget plist when applicable. Treat any implementation, resource,
dependency, or native-project regeneration as a failed build.
2. Independently inspect the binary for package/bundle identifier, public
version, platform build number, production endpoint configuration where
inspectable, signing identity/fingerprint, OTA runtime/channel/certificate,
and SHA-256. Require the embedded public version to equal the requested
version. For Android, also inspect all required ABIs, Worklets/Reanimated/Skia
libraries, and the release permissions. For iOS, inspect every packaged
extension after export.
3. Record the verified platform build number in the new release-note entry.
4. Commit only the expected metadata and release-note update as
`chore(<platform>): record v<version> build <number>`. The worktree must be
clean before starting the next platform build.
Do not accept a successful EAS exit code as proof of a valid artifact. Quarantine
or remove rejected artifacts so only accepted paths can be offered for upload. If
either requested platform fails, do not tag a partial release.
## Tag and prove the result
Re-run focused version checks and `npm run verify:fast`. Confirm every requested
artifact exists, has a SHA-256 digest, passed independent version/signature
inspection, and was produced from the release commit chain. Require a clean
worktree, then create the annotated local tag `v<version>` at `HEAD`.
Read back the tag object and prove:
- the tag resolves to `HEAD`;
- the tag version equals all public-version sources and embedded artifacts;
- recorded build numbers equal the embedded platform build numbers;
- no binary or ignored build output entered the commit;
- the release commits descend from the fetched starting branch commit;
- for a two-platform release, the Android artifact's source commit differs from
the final tag only by the later verified iOS build metadata.
Report the branch, tag, commit, release-note summary, artifact paths, versions,
build numbers, signatures, SHA-256 digests, checks run, and any environmental
limitations. State explicitly that the tag and commits remain local unless a
separate authorized push was completed.
@@ -0,0 +1,4 @@
interface:
display_name: "Generate Release"
short_description: "Bump, build, note, and tag Sauci releases"
default_prompt: "Use $generate-release to create the next signed Sauci mobile release for iOS and Android."
@@ -0,0 +1,39 @@
# Release artifact verification
Use the strongest locally available platform tools and record the commands and
results. Never print provisioning profiles, keys, tokens, or environment values.
## Android AAB
Require a production `.aab`. Inspect its manifest with `bundletool dump manifest`
or Android SDK `apkanalyzer`, and verify:
- package `com.sauci.app`;
- `versionName` equals the release version;
- `versionCode` equals the recorded Android build number.
Verify the archive signature with `jarsigner -verify -verbose -certs`. Compare the
signing certificate SHA-1 to the production fingerprint documented in
`docs/releasing.md`; do not copy credentials into commands or reports. Calculate
the artifact SHA-256 with `shasum -a 256`.
## iOS IPA
Extract the `.ipa` into a new temporary directory created with `mktemp -d`. Read
the application `Info.plist` with `/usr/libexec/PlistBuddy` or `plutil` and verify:
- `CFBundleIdentifier` is `com.sauci.app`;
- `CFBundleShortVersionString` equals the release version;
- `CFBundleVersion` equals the recorded iOS build number.
Use `codesign -dvvv` and `codesign --verify --deep --strict` on the extracted app
to inspect and verify signing without exposing the embedded profile. Calculate the
original `.ipa` SHA-256 with `shasum -a 256`. Remove only the explicit temporary
extraction directory after inspection.
## Source correspondence
For both platforms, retain the build command, artifact path, build completion
time, and digest in the final report. Compare the post-build tracked diff with the
documented allowed version files. A binary with the right version but unexpected
source regeneration, wrong signing, or an unverifiable manifest is not accepted.
@@ -0,0 +1,51 @@
# Sauci release-note voice
Sauci sounds warm, confident, inviting, and quietly playful. It is a premium
couples app about private connection and shared curiosity: supportive without
being clinical, polished without sounding corporate, and intimate without being
graphic or presumptuous.
## Shape
Use a short opening sentence followed by three to six compact bullets. Lead with
the change that most improves a couple's experience. Combine related work into
one benefit rather than mirroring the commit list. Include a brief closing line
only when it adds warmth or useful context.
In `docs/release-notes.md`, use:
```markdown
## v1.2.3 — 29 August 2026
_iOS build 44 · Android build 48_
A little more ease, clarity, and connection in every check-in.
- **Closer conversations** — Find the prompts that feel right for both of you.
- **Smoother moments** — Chats and matches now feel quicker and more reliable.
- **More control** — Clearer choices help keep your shared space comfortable.
```
Adapt the words to the evidence; do not reuse this copy when those claims are not
present in the release. If only one platform was requested, record only its build.
## Language
- Address the couple's experience with `you`, `your partner`, `together`, or
`your shared space` where natural.
- Prefer concrete benefits: `pick up where you left off`, `find the right prompt`,
`keep conversations flowing`, `stay in control`.
- Use restrained warmth. One gentle phrase is enough; avoid forced innuendo,
breathless hype, exclamation marks, emoji, and claims that Sauci will improve or
fix a relationship.
- Prefer `new`, `smoother`, `clearer`, `more private`, and `more reliable` to
`revolutionary`, `game-changing`, `massive`, or `best ever`.
- Explain a fix as the experience restored, not the defect mechanics. Name a
security or privacy change plainly when users need to understand it.
## Evidence boundary
Release notes describe shipped behavior only. Do not claim a feature from a plan,
flagged-off path, migration, test, or commit title without confirming the runtime
path in the diff and release configuration. Do not expose internal service names,
database details, moderation logic, security-sensitive implementation, or content
that the product intentionally keeps private between partners.
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "../../../..");
const files = {
app: path.join(repoRoot, "apps/mobile/app.json"),
appConfig: path.join(repoRoot, "apps/mobile/app.config.js"),
android: path.join(repoRoot, "apps/mobile/android/app/build.gradle"),
androidExpo: path.join(repoRoot, "apps/mobile/android/app/src/main/res/values/strings.xml"),
ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"),
iosInfo: path.join(repoRoot, "apps/mobile/ios/Sauci/Info.plist"),
iosExpo: path.join(repoRoot, "apps/mobile/ios/Sauci/Supporting/Expo.plist"),
widgetInfo: path.join(repoRoot, "apps/mobile/targets/widget/Info.plist"),
};
const semverPattern = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/;
function fail(message) {
console.error(`set_version: ${message}`);
process.exit(1);
}
function parseVersion(value, label) {
const match = semverPattern.exec(value);
if (!match) fail(`${label} must be an exact numeric SemVer version (x.y.z)`);
return match.slice(1).map(Number);
}
function writePreservingFinalNewline(file, contents, previous) {
fs.writeFileSync(file, previous.endsWith("\n") ? contents : contents.replace(/\n$/, ""));
}
function readState() {
const appRaw = fs.readFileSync(files.app, "utf8");
const appConfigRaw = fs.readFileSync(files.appConfig, "utf8");
const androidRaw = fs.readFileSync(files.android, "utf8");
const androidExpoRaw = fs.readFileSync(files.androidExpo, "utf8");
const iosRaw = fs.readFileSync(files.ios, "utf8");
const iosInfoRaw = fs.readFileSync(files.iosInfo, "utf8");
const iosExpoRaw = fs.readFileSync(files.iosExpo, "utf8");
const widgetInfoRaw = fs.readFileSync(files.widgetInfo, "utf8");
const appVersion = JSON.parse(appRaw).expo?.version;
const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map(
(match) => match[1],
);
const iosVersions = [...iosRaw.matchAll(/\bMARKETING_VERSION\s*=\s*([^;]+);/g)].map(
(match) => match[1].trim(),
);
const plistVersion = (raw, label) => {
const versions = [...raw.matchAll(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/g)].map(
(match) => match[1],
);
if (versions.length !== 1) fail(`expected exactly one ${label} CFBundleShortVersionString`);
return versions[0];
};
const iosInfoVersion = plistVersion(iosInfoRaw, "iOS app Info.plist");
const widgetInfoVersion = plistVersion(widgetInfoRaw, "iOS widget Info.plist");
const expoRuntimeVersion = (raw, label) => {
const versions = [...raw.matchAll(/<key>EXUpdatesRuntimeVersion<\/key>\s*<string>([^<]+)<\/string>/g)].map(
(match) => match[1],
);
if (versions.length !== 1) fail(`expected exactly one ${label} Expo runtime version`);
return versions[0];
};
const appConfigRuntimeVersions = [...appConfigRaw.matchAll(/runtimeVersion:\s*['"]([^'"]+)['"]/g)].map(
(match) => match[1],
);
const androidRuntimeVersions = [...androidExpoRaw.matchAll(/<string name="expo_runtime_version">([^<]+)<\/string>/g)].map(
(match) => match[1],
);
if (appConfigRuntimeVersions.length !== 1) fail("expected exactly one explicit app.config.js runtimeVersion");
if (androidRuntimeVersions.length !== 1) fail("expected exactly one Android Expo runtime version");
const iosExpoVersion = expoRuntimeVersion(iosExpoRaw, "iOS");
if (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version");
if (androidVersions.length !== 1) fail("expected exactly one Android versionName");
if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values");
const versions = [
appVersion,
...androidVersions,
...iosVersions,
iosInfoVersion,
widgetInfoVersion,
appConfigRuntimeVersions[0],
androidRuntimeVersions[0],
iosExpoVersion,
];
versions.forEach((version) => parseVersion(version, `configured version ${version}`));
if (new Set(versions).size !== 1) {
fail(`public versions disagree: ${versions.join(", ")}`);
}
return {
appRaw,
appConfigRaw,
androidRaw,
androidExpoRaw,
iosRaw,
iosInfoRaw,
iosExpoRaw,
widgetInfoRaw,
current: appVersion,
iosCount: iosVersions.length,
};
}
function nextVersion(current, bump) {
const [major, minor, patch] = parseVersion(current, "current version");
if (bump === "major") return `${major + 1}.0.0`;
if (bump === "minor") return `${major}.${minor + 1}.0`;
if (bump === "patch") return `${major}.${minor}.${patch + 1}`;
fail("--bump must be patch, minor, or major");
}
const args = process.argv.slice(2);
const check = args.length === 1 && args[0] === "--check";
const bumpIndex = args.indexOf("--bump");
const versionIndex = args.indexOf("--version");
if (!check && (bumpIndex === -1) === (versionIndex === -1)) {
fail("use --check, --bump patch|minor|major, or --version x.y.z");
}
const state = readState();
if (check) {
console.log(`public and OTA runtime version ${state.current} is consistent across app.json, Android, and ${state.iosCount} iOS settings`);
process.exit(0);
}
const desired =
bumpIndex !== -1
? nextVersion(state.current, args[bumpIndex + 1])
: (parseVersion(args[versionIndex + 1], "--version"), args[versionIndex + 1]);
if (desired === state.current) fail(`requested version is already ${state.current}`);
const currentParts = parseVersion(state.current, "current version");
const desiredParts = parseVersion(desired, "requested version");
const increases = desiredParts.some(
(part, index) => part > currentParts[index] && desiredParts.slice(0, index).every((value, prior) => value === currentParts[prior]),
);
if (!increases) fail(`requested version ${desired} must be greater than ${state.current}`);
const appRaw = state.appRaw.replace(
/(\"version\"\s*:\s*\")([^\"]+)(\")/,
`$1${desired}$3`,
);
const androidRaw = state.androidRaw.replace(
/(\bversionName\s+["'])([^"']+)(["'])/,
`$1${desired}$3`,
);
const appConfigRaw = state.appConfigRaw.replace(
/(runtimeVersion:\s*['"])([^'"]+)(['"])/,
`$1${desired}$3`,
);
const androidExpoRaw = state.androidExpoRaw.replace(
/(<string name="expo_runtime_version">)([^<]+)(<\/string>)/,
`$1${desired}$3`,
);
const iosRaw = state.iosRaw.replace(
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
`$1${desired}$3`,
);
const iosInfoRaw = state.iosInfoRaw.replace(
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
`$1${desired}$3`,
);
const iosExpoRaw = state.iosExpoRaw.replace(
/(<key>EXUpdatesRuntimeVersion<\/key>\s*<string>)([^<]+)(<\/string>)/,
`$1${desired}$3`,
);
const widgetInfoRaw = state.widgetInfoRaw.replace(
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
`$1${desired}$3`,
);
writePreservingFinalNewline(files.app, appRaw, state.appRaw);
writePreservingFinalNewline(files.appConfig, appConfigRaw, state.appConfigRaw);
writePreservingFinalNewline(files.android, androidRaw, state.androidRaw);
writePreservingFinalNewline(files.androidExpo, androidExpoRaw, state.androidExpoRaw);
writePreservingFinalNewline(files.ios, iosRaw, state.iosRaw);
writePreservingFinalNewline(files.iosInfo, iosInfoRaw, state.iosInfoRaw);
writePreservingFinalNewline(files.iosExpo, iosExpoRaw, state.iosExpoRaw);
writePreservingFinalNewline(files.widgetInfo, widgetInfoRaw, state.widgetInfoRaw);
console.log(`updated public version ${state.current} -> ${desired}`);
+15
View File
@@ -0,0 +1,15 @@
---
name: incident-triage
description: Diagnose a Sauci development, CI, backend, or production symptom with evidence. Use for failures, regressions, logs, or unexplained runtime behavior; diagnosis alone does not authorize a fix or deployment.
---
# Incident triage
Establish environment, time window, expected behavior, and blast radius. Reproduce
locally when safe, inspect the narrow relevant logs and data boundary, and separate
product bugs from stale tests or environment failures. Preserve exact error text
without exposing secrets.
Return evidence, confirmed cause or ranked hypotheses, affected scope, and the
smallest recommended next action. Do not mutate production, implement a fix, or
deploy unless the request explicitly includes it.
+18
View File
@@ -0,0 +1,18 @@
---
name: mobile-simulator
description: Build, launch, and verify Sauci on iOS or Android simulators with local backend wiring and captured evidence. Use for native UI, navigation, permissions, or device behavior.
---
# Mobile simulator verification
Read `apps/mobile/AGENTS.md` and `apps/mobile/DESIGN.md` for UI work.
1. Start/reset the local stack through the `dev-local` skill.
2. Use `scripts/dev-local.sh ios` or `android`; do not embed remote credentials.
3. Drive stable accessibility labels/test IDs. Use `maestro test e2e/maestro` for
committed flows and platform simulator tools for focused exploration.
4. Capture the final visible state and relevant Metro/native logs under
`evidence/`.
5. Report device/runtime, steps, expected versus observed, and artifact paths.
Do not claim a native result from Expo web or component tests alone.
+235
View File
@@ -0,0 +1,235 @@
---
name: prd
description: "Generate a Product Requirements Document (PRD) as JSON for Ralph. Triggers on: create a prd, write prd for, plan this feature, requirements for, spec out."
---
# PRD Generator (JSON Only)
Create a structured JSON PRD that Ralph can execute deterministically. This PRD is the **single source of truth** for stories, gates, and status.
---
## The Job
1. Receive a feature description from the user
2. Ask **5–10** clarifying questions (with lettered options), in batches of up to 5 at a time (ask 5, wait for answers, then ask the next batch if needed).
3. **Always ask about quality gates** (commands that must pass)
4. Generate a **detailed** JSON PRD and save it to the provided path
**Important:** Do NOT implement anything. Only generate JSON.
---
## Step 1: Clarifying Questions
Ask questions even if the prompt seems clear. The goal is to capture missing product details, stack choices, and UI/route structure so the PRD is implementable without guesswork. Focus on:
- **Problem/Goal:** What problem does this solve?
- **Core Functionality:** What are the key actions?
- **Scope/Boundaries:** What should it NOT do?
- **Success Criteria:** How do we know it's done?
- **Stack + Environment:** frameworks, hosting, runtime, database, auth approach
- **UI + Routes:** key screens, navigation, route map, layout constraints
- **Data Model + Import Format:** entities, relationships, external data shape
- **Rules/Calculations:** business logic, progression rules, edge cases
- **Quality Gates:** tests, lint, typecheck, build/dev verification (REQUIRED)
Always ask explicitly:
- **Is this a new project or an existing codebase?**
### Format Questions Like This:
```
1. What is the primary goal of this feature?
A. Improve user onboarding experience
B. Increase user retention
C. Reduce support burden
D. Other: [please specify]
2. Who is the target user?
A. New users only
B. Existing users only
C. All users
D. Admin users only
3. What quality commands must pass for each story?
A. npm test
B. npm run lint
C. npm run typecheck
D. Other: [specify]
Note: All example questions and options in this section are illustrative only. Do not copy them verbatim into the PRD unless the user explicitly chooses them. Derive the final quality gates and requirements from the user's answers and the feature context.
4. What stack + hosting are we using (and any constraints)?
A. React + Vite (static hosting)
B. Next.js (Node/Edge)
C. TanStack Start (Cloudflare)
D. Other: [specify]
5. What UI screens/routes are required?
A. Minimal (1–2 pages)
B. Basic app shell (dashboard + detail pages)
C. Full routing map (list all routes)
D. Other: [specify]
```
---
## Step 2: JSON Structure
Output a JSON file with this shape (include detailed top-level fields so the PRD is fully self-contained):
```json
{
"version": 1,
"project": "Feature Name",
"overview": "Short problem + solution summary",
"goals": [
"Goal 1",
"Goal 2"
],
"nonGoals": [
"Explicitly out of scope items"
],
"successMetrics": [
"How success is measured"
],
"openQuestions": [
"Remaining unknowns"
],
"stack": {
"framework": "TanStack Start",
"hosting": "Cloudflare",
"database": "D1",
"auth": "describe approach"
},
"routes": [
{ "path": "/", "name": "Home", "purpose": "..." }
],
"uiNotes": [
"Layout or component requirements"
],
"dataModel": [
{ "entity": "Workout", "fields": ["id", "userId", "date", "notes"] }
],
"importFormat": {
"description": "Expected JSON shape",
"example": { "programName": "..." }
},
"rules": [
"Key business rules / calculations"
],
"qualityGates": ["npm run test:ping"],
"stories": [
{
"id": "US-001",
"title": "Short story title",
"status": "open",
"dependsOn": [],
"description": "As a [user], I want [feature] so that [benefit].",
"acceptanceCriteria": [
"Specific verifiable criterion",
"Another criterion"
]
}
]
}
```
### Rules
- **IDs**: Sequential (`US-001`, `US-002`, ...)
- **Status**: Always `"open"` for new stories
- **DependsOn**: Use IDs only; empty array if none
- **Quality Gates**: Only at the top-level `qualityGates`
- **Acceptance Criteria**: Verifiable, specific, testable
- **Every story must include**: at least 1 example + 1 negative case
- **UI stories**: include explicit routes, components, and UI states
- **New projects**: include initial setup stories (scaffold, env/config, local dev, deploy basics, **package installs**)
- **Dependencies**: any new package/library introduced must be called out with install commands in acceptance criteria (e.g., `npm install <pkg>`), plus any required config or scripts.
- **Ordering**: if this is a new project, the **first story must be setup** (scaffold + installs + scripts + env/config). Migrations or data model work come after setup.
---
## Output Requirements
- Save the JSON to the exact path provided in the prompt (e.g., `.agents/tasks/prd-<slug>.json`)
- Output **only** the JSON file content (no Markdown PRD)
- Do not include extra commentary in the file
After saving, tell the user:
`PRD JSON saved to <path>. Close this chat and run \`ralph build\`.`
If the prompt provides a **directory** (not a filename), choose a short filename:
- `prd-<short-slug>.json` where `<short-slug>` is 1–3 meaningful words (avoid filler like “i want to”).
- Examples: `prd-workout-tracker.json`, `prd-usage-billing.json`
---
## Story Size (Critical)
Each story must be completable in a **single Ralph iteration**.
If a story feels too large, split it into multiple smaller stories with dependencies.
---
## Example Output (JSON)
```json
{
"version": 1,
"project": "Task Priority System",
"overview": "Add priority levels to tasks so users can focus on what matters most.",
"goals": [
"Allow assigning priority (high/medium/low) to any task",
"Enable filtering by priority"
],
"nonGoals": [
"No automatic priority assignment"
],
"successMetrics": [
"Users can change priority in under 2 clicks"
],
"openQuestions": [
"Should priority affect ordering within a column?"
],
"stack": {
"framework": "React",
"hosting": "Cloudflare Pages",
"database": "D1",
"auth": "single shared login"
},
"routes": [
{ "path": "/tasks", "name": "Task List", "purpose": "View and filter tasks" },
{ "path": "/tasks/:id", "name": "Task Detail", "purpose": "Edit task priority" }
],
"uiNotes": [
"Priority badge colors: high=red, medium=yellow, low=gray"
],
"dataModel": [
{ "entity": "Task", "fields": ["id", "title", "priority"] }
],
"importFormat": {
"description": "Not applicable",
"example": {}
},
"rules": [
"Priority defaults to medium when not set"
],
"qualityGates": ["npm run test:ping"],
"stories": [
{
"id": "US-001",
"title": "Add priority field to database",
"status": "open",
"dependsOn": [],
"description": "As a developer, I want to store task priority so it persists across sessions.",
"acceptanceCriteria": [
"Add priority column with default 'medium'",
"Example: creating a task without priority -> defaults to 'medium'",
"Negative case: invalid priority 'urgent' -> validation error",
"Migration runs successfully"
]
}
]
}
```
+14
View File
@@ -0,0 +1,14 @@
---
name: promote-learning
description: Record or promote a reproduced Sauci agent lesson into durable documentation, a skill, fixture, or executable invariant. Use after repeated or confirmed workflow failures, not for speculative observations.
---
# Promote a learning
Read `docs/agents/learnings.md` and copy `.agents/learnings/TEMPLATE.md` into the
appropriate state directory. Include reproduction and verification evidence.
Prefer one executable destination. Promotion requires review, a destination,
and a review-after date; update the record to `promoted` only after that artifact
exists and passes `npm run learn:check`. Never turn a transient provider failure or
untested workaround into a golden rule.
+25
View File
@@ -0,0 +1,25 @@
---
name: question-pack
description: Create or review Sauci question-pack content and its local migration/seed representation. Use for pack generation, inverse pairs, intensity, targeting, or content quality.
---
# Sauci question packs
Inspect existing packs before generating to avoid duplication.
Before creating or editing content that uses a question interaction type, read
`docs/question-types.md`. If the type, response payload, configuration, or
matching semantics change, update that maintained contract and run
`npm run lint:harness` in the same task.
- Write proposals, not interview questions; use gender-neutral “your partner”.
- Avoid time-bound words such as tonight, now, or today.
- Symmetric activities have no partner text.
- Every asymmetric activity has the reversed counterpart and a correct
`inverse_of` link; inverse pairs are not duplicates.
- Intensity 1 is non-sexual bonding, 2 romantic/flirty, 3 light sexual, 4
explicit, and 5 advanced/BDSM. Check surrounding content before deciding.
- Preserve targeting and required-prop semantics; do not infer them from wording.
Return metadata, content, inverse/link validation, and distribution statistics.
Database changes follow the `db-migration` skill and require local verification.
+147
View File
@@ -0,0 +1,147 @@
---
name: release-mobile
description: Prepare, verify, and ship Sauci iOS or Android releases end to end. Use for versioning, EAS/native builds, store uploads, App Review / Play review submission, and rollout.
---
# Mobile release
Read `docs/releasing.md`, `apps/mobile/RELEASING.md` when present, and the mobile
scoped instructions. Confirm target platform and release profile. Run repository
verification plus a native acceptance pass before starting a release build.
## OTA update vs full store release
Choose the delivery mechanism before doing anything else; they are different
pipelines with different blast radii. `docs/mobile-ota.md` is the authority.
- **OTA update** (`npm run ota:publish:<env>` in `apps/mobile`): ships only the
JavaScript bundle and assets to builds already installed on devices. No store
review, live within minutes, and rolled back by repointing the XPREM channel
at a previous update. Valid only when the change touches no native code, no
native dependency, no `ios/`/`android/` project files, and no config that
alters the native binary. The OTA runtime version equals the public app
version, so an OTA update can only reach installs of the current
`expo.version`.
- **Full store release** (this skill): a new signed binary through App Store
Connect and Google Play, including review. Required whenever native code,
native dependencies, the checked-in native projects, Expo SDK, permissions,
entitlements, or `expo.version` change. Rollback is coarser: Play staged
rollouts can be halted, iOS phased release can be paused, and a bad release is
otherwise superseded by shipping the next version.
When a change qualifies for OTA, prefer OTA unless the user asks for a store
release. Never present an OTA publish as a store release or vice versa; report
which mechanism was used.
## Authorization
An explicit request for a mobile release authorizes the complete pipeline for
the stated platforms: version bump, build, verification, store upload, review
submission (App Review / Google Play review), and rollout — including full
production rollout. Rollout is recoverable by design (staged-rollout halt,
phased-release pause, OTA supersession, or the next store version), so do not
pause to re-confirm these steps; execute them and report. A request that
explicitly narrows scope (for example "upload only", "TestFlight only", or a
named track) still bounds what you do.
Before a build from the current upstream branch, fetch, require a clean worktree,
and prove `HEAD` matches its upstream. The `generate-release` skill may instead
build a clean, scoped release-preparation commit that descends directly from that
fetched upstream; between its multi-platform builds it commits only independently
verified EAS version metadata. Sauci tracks its native iOS and Android projects,
so routine release builds must package those checked-in sources. Do not run
`expo prebuild` first; native regeneration is a separate reviewed change.
Run `npm run release:preflight -w @sauci/mobile` before allocating an EAS build.
It rejects inconsistent public/runtime versions, an iOS widget build-setting
override, and a public OTA certificate that would be omitted from the archive.
For a local EAS build, also run `npm run release:preflight:local-eas -w
@sauci/mobile` after setting `EAS_LOCAL_BUILD_PLUGIN_PATH` to the matching
plugin's executable `bin/run`. Do not point it at the package directory or
hardcode a machine-local cache path.
Classify a failure before rebuilding. Archive, plugin-spawn, package-install, and
disk-space failures happen before native compilation and must be repaired and
preflighted without consuming another build. After a signing, profile, or
capability failure, read back the provider state and prove only the affected
input changed before rebuilding. Retain the EAS work directory, command log, and
stable artifact path for every meaningful attempt. Never retry an ambiguous
upload.
Report provider progress by phase: packaging, native compilation, submission
queue, or store processing. Do not describe every failure as a rebuild.
Use the package scripts in `apps/mobile/package.json` to build artifacts and the
Fastlane lanes in `apps/mobile/fastlane/Fastfile` to upload, submit, and roll
out.
## Upload and submission execution
Verification before upload is unchanged and mandatory. Independently verify and
report:
- the platform, exact artifact path, bundle/package identifier, public version,
build number, signature identity, and SHA-256 digest;
- the exact destination and resulting state: which Play track and rollout
status, or App Store Connect version state and whether App Review submission
and automatic release are included.
Once these details match the request, proceed through upload, review
submission, and rollout without requesting further confirmation.
- **Android**: `bundle exec fastlane android release aab:<absolute path>`
commits the release on the production track (pass `track:` to target another
track, `rollout:0.1` for a staged rollout). Play review runs automatically;
rollout proceeds on approval. Use the `upload` lane only when the request is
explicitly upload-only.
- **iOS**: `bundle exec fastlane ios release ipa:<absolute path>` uploads,
creates the App Store version, submits for App Review, and releases
automatically on approval (pass `phased_release:true` for a 7-day phased
release, `release_notes:"..."` for What's New). Use the `upload` lane for
TestFlight-only requests. `eas submit` remains valid for upload-only flows;
when used, pass explicit `--platform`, `--profile production`, `--path`, and
`--wait` — never `--latest`.
App Store Connect API group association does not establish the automatic-
distribution switch. Verify that switch in an authenticated App Store Connect UI
session before upload; if that UI verification is unavailable, stop rather
than inferring the distribution effect from API data.
If an upload or submission command returns an ambiguous result, inspect EAS and
the store before doing anything else; do not retry blind. After submission, read
back the store state (Play Console track status / App Store Connect version
state) and report it.
Preserve platform signing and local EAS versioning. After a build, verify that
tracked changes are limited to the expected version metadata, then independently
read the artifact version and signature. For an iOS IPA, inspect every packaged
app extension as well as the containing app and require matching
`CFBundleShortVersionString` and `CFBundleVersion` values. Treat Xcode archive
warnings as diagnostic evidence, not the final artifact state: decide whether to
reject or rebuild only after reading the exported IPA itself. Report the
artifact, version/build number, upload and submission results, rollout state,
and store read-back state.
For Android, verify the accepted AAB has all four required ABIs, the Worklets,
Reanimated, and Skia native libraries, no broad `READ_MEDIA_IMAGES` or
`READ_MEDIA_VIDEO` permissions, and the production OTA runtime, channel, and
public certificate. For iOS, verify the exported IPA, not archive warnings, and
check both the containing app and every extension.
For the Android native dependency ordering already tracked in Gradle, preserve
the explicit task dependency that makes Prefab inputs available before dependent
native tasks. Fix task validation with ordering, not Firebase removal or
dependency-version roulette.
After Android upload, EAS Submit `FINISHED`, the exact internal/DRAFT/no-rollout
submit configuration, and the retained artifact digest are the available
authoritative read-back when a direct Play track read would require creating an
edit. Never create an edit merely to claim read-only verification.
For a checked-in iOS entitlement, inspect the real Apple App ID capability state
before a signing rebuild. The capability sync bug tracked in
https://github.com/expo/eas-cli/issues/3986 can report success while Apple
rejects a malformed update. Read back Associated Domains, regenerate only the
affected profile, and decode that profile to prove the entitlement before
rebuilding. Noninteractive managed App Store Connect key auth may require
`EXPO_APPLE_TEAM_ID`; preserve certificates and unrelated target profiles.
+38
View File
@@ -0,0 +1,38 @@
---
name: verify
description: Prove a completed Sauci engineering task works before a PR. Use when asked to verify, prove behavior, or open a PR with runtime evidence.
---
# Verify Sauci work
Run locally on a non-default branch with the implementation committed. Use an
independent verifier for user-facing, cross-boundary, or high-risk runtime
changes. Documentation, mechanical configuration, isolated tests, and low-risk
package-local changes may use focused deterministic checks without a verifier.
1. Package-local deterministic checks do not need a running stack. For runtime
verification, reuse a compatible running local stack when available; otherwise
start the documented full stack with `scripts/dev-local.sh up`. Reset fixtures
first when the task changes durable state. Web is `http://127.0.0.1:3000`,
admin is `http://127.0.0.1:3001`, and MCP health is
`http://127.0.0.1:3002/health`.
2. When independent runtime verification is required, give a fresh read-only
verifier the acceptance criteria. It drives browser
surfaces with the repository browser capability, native surfaces with Maestro
or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are
described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`.
3. The verifier writes screenshots, traces, video, or response evidence under
`evidence/` and returns only expected, observed, evidence paths, and
`works|broken`. It never edits code.
4. If independent verification is broken, fix and use a fresh verifier. Stop
after three failed rounds and report the unresolved evidence.
5. Run focused checks for the affected change. Run affected E2E when the runtime
path requires it. Run `npm run verify:full` when its prerequisites are
available; it already runs `npm run verify:fast`, so do not run both in
sequence. Never weaken an assertion just to pass.
6. Open a PR only after both task verification and regression checks pass. Upload
evidence to the established CI artifact or `pr-evidence` prerelease and embed
the screenshot plus a video/trace link in the PR body.
A green test suite without an independently observed outcome is incomplete when
independent runtime verification is required.
+37 -2
View File
@@ -1,14 +1,49 @@
node_modules node_modules
**/node_modules
.next
**/.next
.git .git
.gitignore .gitignore
*.md *.md
.env* .env*
.DS_Store .DS_Store
dist dist
**/dist
.expo .expo
android **/.expo
ios **/android
**/ios
*.log *.log
.turbo .turbo
**/.turbo
coverage coverage
.nyc_output .nyc_output
apps/admin
apps/mcp
!apps/mcp
!apps/mcp/**
apps/mobile
apps/supabase
apps/web
!apps/web
!apps/web/**
e2e
evidence
etc
infra
scripts
!scripts
!scripts/fix-hoisting.js
terraform
.agents
.claude
.codex
.ralph
# The admin app builds from this same monorepo context (apps/admin/Dockerfile).
!apps/admin
!apps/admin/**
# Host build artefacts must never leak into any image context.
**/node_modules
**/dist
-163
View File
@@ -1,163 +0,0 @@
name: Deploy Supabase to Production
on:
workflow_dispatch:
push:
branches:
- master
paths:
- 'apps/supabase/**'
- 'terraform/**'
- '.github/workflows/deploy-supabase.yml'
jobs:
deploy-database:
name: Deploy Migrations & Functions
runs-on: ubuntu-latest
strategy:
max-parallel: 1
matrix:
environment:
- name: non-prod
project_ref: itbzhrvlgvdmzbnhzhyx
- name: prod
project_ref: ckjcrkjpmhqhiucifukx
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_PROJECT_REF: ${{ matrix.environment.project_ref }}
ENVIRONMENT_NAME: ${{ matrix.environment.name }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Supabase CLI
uses: supabase/setup-cli@v1
with:
version: latest
- name: Link to ${{ matrix.environment.name }} Project
run: supabase link --project-ref $SUPABASE_PROJECT_REF --workdir apps
- name: Apply Database Migrations (${{ matrix.environment.name }})
run: supabase db push --workdir apps --include-all
- name: Sync Edge Function Secrets (${{ matrix.environment.name }})
env:
# Add secrets from GitHub Secrets
REVENUECAT_WEBHOOK_SECRET: ${{ secrets.REVENUECAT_WEBHOOK_SECRET }}
REVENUECAT_API_KEY: ${{ secrets.REVENUECAT_API_KEY }}
REVENUECAT_ENTITLEMENT_ID: ${{ secrets.REVENUECAT_ENTITLEMENT_ID }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
ADMIN_PRIVATE_KEY_JWK: ${{ secrets.ADMIN_PRIVATE_KEY_JWK }}
ADMIN_KEYS_JSON: ${{ secrets.ADMIN_KEYS_JSON }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: |
# Sync secrets to Supabase (only if they're set in GitHub)
secrets_to_set=""
[ -n "$REVENUECAT_WEBHOOK_SECRET" ] && secrets_to_set="$secrets_to_set REVENUECAT_WEBHOOK_SECRET=$REVENUECAT_WEBHOOK_SECRET"
[ -n "$REVENUECAT_API_KEY" ] && secrets_to_set="$secrets_to_set REVENUECAT_API_KEY=$REVENUECAT_API_KEY"
[ -n "$REVENUECAT_ENTITLEMENT_ID" ] && secrets_to_set="$secrets_to_set REVENUECAT_ENTITLEMENT_ID=$REVENUECAT_ENTITLEMENT_ID"
[ -n "$OPENROUTER_API_KEY" ] && secrets_to_set="$secrets_to_set OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
[ -n "$ADMIN_PRIVATE_KEY_JWK" ] && secrets_to_set="$secrets_to_set ADMIN_PRIVATE_KEY_JWK=$ADMIN_PRIVATE_KEY_JWK"
[ -n "$ADMIN_KEYS_JSON" ] && secrets_to_set="$secrets_to_set ADMIN_KEYS_JSON=$ADMIN_KEYS_JSON"
[ -n "$DISCORD_WEBHOOK_URL" ] && secrets_to_set="$secrets_to_set DISCORD_WEBHOOK_URL=$DISCORD_WEBHOOK_URL"
if [ -n "$secrets_to_set" ]; then
echo "Syncing secrets to Supabase ($ENVIRONMENT_NAME)..."
supabase secrets set $secrets_to_set --project-ref $SUPABASE_PROJECT_REF
else
echo "No secrets to sync"
fi
- name: Deploy Edge Functions (${{ matrix.environment.name }})
run: |
# Deploy all edge functions (excluding _shared directory)
for dir in apps/supabase/functions/*/; do
function_name=$(basename "$dir")
# Skip _shared directory (it's a shared module, not a function)
if [ "$function_name" = "_shared" ]; then
echo "Skipping _shared (shared module)"
continue
fi
echo "Deploying function: $function_name to $ENVIRONMENT_NAME"
supabase functions deploy "$function_name" --no-verify-jwt --project-ref $SUPABASE_PROJECT_REF --workdir apps
done
- name: Remove Deleted Functions (${{ matrix.environment.name }})
run: |
# Get list of functions deployed on Supabase (skip header lines, extract slug column)
deployed=$(supabase functions list --project-ref $SUPABASE_PROJECT_REF | grep -E '^[a-z]' | awk '{print $1}')
# Get list of functions in the repo (excluding _shared)
local_functions=$(ls -d apps/supabase/functions/*/ 2>/dev/null | xargs -I {} basename {} | grep -v '^_shared$' || true)
# Delete any deployed functions that no longer exist in the repo
for func in $deployed; do
# Skip empty or invalid function names
[ -z "$func" ] && continue
[[ "$func" =~ ^[^a-z] ]] && continue
if ! echo "$local_functions" | grep -q "^${func}$"; then
echo "Deleting removed function from $ENVIRONMENT_NAME: $func"
supabase functions delete "$func" --project-ref $SUPABASE_PROJECT_REF
fi
done
deploy-infrastructure:
name: Deploy Infrastructure (Terraform)
runs-on: ubuntu-latest
env:
TF_VAR_supabase_access_token: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.6"
# Non-Production Environment
- name: Terraform Init (Non-Prod)
working-directory: terraform/environments/non-prod
run: terraform init
- name: Terraform Format Check
working-directory: terraform/modules/supabase
run: terraform fmt -check
- name: Terraform Validate (Non-Prod)
working-directory: terraform/environments/non-prod
run: terraform validate
- name: Terraform Plan (Non-Prod)
working-directory: terraform/environments/non-prod
run: terraform plan -out=tfplan
- name: Terraform Apply (Non-Prod)
working-directory: terraform/environments/non-prod
run: terraform apply -auto-approve tfplan
# Production Environment
- name: Terraform Init (Prod)
working-directory: terraform/environments/prod
run: terraform init
- name: Terraform Validate (Prod)
working-directory: terraform/environments/prod
run: terraform validate
- name: Terraform Plan (Prod)
working-directory: terraform/environments/prod
run: terraform plan -out=tfplan
- name: Terraform Apply (Prod)
working-directory: terraform/environments/prod
run: terraform apply -auto-approve tfplan
+94
View File
@@ -0,0 +1,94 @@
name: Verify
on:
workflow_dispatch:
pull_request:
push:
branches: [staging, main]
concurrency:
group: verify-${{ github.ref }}
cancel-in-progress: true
jobs:
branch-policy:
runs-on: ubuntu-latest
steps:
- name: Enforce promotion flow
if: github.event_name == 'pull_request'
env:
BASE_BRANCH: ${{ github.base_ref }}
HEAD_BRANCH: ${{ github.head_ref }}
run: |
if [ "$BASE_BRANCH" = "master" ]; then
echo "master is frozen; target staging or main" >&2
exit 1
fi
if [ "$BASE_BRANCH" = "main" ]; then
case "$HEAD_BRANCH" in
staging|hotfix/*|release/*) ;;
*)
echo "main takes staging promotions, or a hotfix/* or release/* branch for direct work" >&2
exit 1
;;
esac
fi
if [ "$BASE_BRANCH" != "staging" ] && [ "$BASE_BRANCH" != "main" ]; then
echo "Feature pull requests target staging; promotions target main" >&2
exit 1
fi
- name: Confirm direct verification
if: github.event_name != 'pull_request'
run: echo "Direct branch verification requested for $GITHUB_SHA"
static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run learn:check
- run: npm run verify:fast
- name: Enforce API and mobile coverage floors
run: npm run test:coverage
- run: npm run build
backend-integration:
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: sauci_test
POSTGRES_USER: sauci
POSTGRES_PASSWORD: sauci_ci_only
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U sauci -d sauci_test"
--health-interval 5s
--health-timeout 3s
--health-retries 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Run standalone backend integration tests
env:
DATABASE_URL: postgresql://sauci:sauci_ci_only@127.0.0.1:5432/sauci_test
run: npm run test:integration:coverage -w @sauci/api
container-contract:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Verify production Compose contract
run: infra/backend/verify-production.sh
+12
View File
@@ -23,6 +23,7 @@ apps/mobile/.env.*
# Supabase # Supabase
.supabase/ .supabase/
supabase/.temp/
# IDE # IDE
.idea/ .idea/
@@ -50,9 +51,15 @@ pnpm-debug.log*
coverage/ coverage/
.nyc_output/ .nyc_output/
*.lcov *.lcov
evidence/
e2e/.auth/
e2e/test-results/
e2e/playwright-report/
.playwright-mcp/
# Misc # Misc
*.pem *.pem
!apps/mobile/certs/certificate.pem
.cache/ .cache/
.temp/ .temp/
.tmp/ .tmp/
@@ -61,3 +68,8 @@ nul
# Opencode # Opencode
.opencode/ .opencode/
# Agent runtime evidence (maintained lessons remain tracked)
.ralph/activity.log
.ralph/runs/
.migration-state/
+1
View File
@@ -0,0 +1 @@
20.19.4
+23
View File
@@ -0,0 +1,23 @@
# Guardrails (Signs)
> Lessons learned from failures. Read before acting.
## Core Signs
### Sign: Read Before Writing
- **Trigger**: Before modifying any file
- **Instruction**: Read the file first
- **Added after**: Core principle
### Sign: Test Before Commit
- **Trigger**: Before committing changes
- **Instruction**: Run required tests and verify outputs
- **Added after**: Core principle
---
## Learned Signs
Durable lessons are promoted through `docs/agents/learnings.md`. Ralph runtime
logs are evidence, not instruction. Read promoted artifacts through `AGENTS.md`;
do not add permanent signs from an unreproduced failure.
+235
View File
@@ -0,0 +1,235 @@
# Progress Log
Started: Sun Jan 25 20:37:25 GMT 2026
## Codebase Patterns
- (add reusable patterns here)
---
## [2026-01-25 20:45:51] - US-001: Inventory oversized files and refactor plan
Thread:
Run: 20260125-204012-65223 (iteration 1)
Run log: /Users/jjbrunton/Projects/Sauci/.ralph/runs/run-20260125-204012-65223-iter-1.log
Run summary: /Users/jjbrunton/Projects/Sauci/.ralph/runs/run-20260125-204012-65223-iter-1.md
- Guardrails reviewed: yes
- No-commit run: false
- Commit: 06dfc5b docs(inventory): add oversized file plan
- Post-commit status: dirty (pre-existing changes in apps/mobile/* and untracked .agents/.codex)
- Verification:
- Command: npm test -> FAIL (missing script: "test")
- Command: npm run lint -> FAIL (existing lint errors in @sauci/mobile)
- Command: npm run typecheck -> PASS
- Command: npm run build -> PASS
- Files changed:
- docs/oversized-file-inventory.md
- .ralph/activity.log
- .ralph/progress.md
- What was implemented
- Documented oversized source files (>400 lines) with line counts and layer-based split plans.
- Noted scope exclusions for non-refactorable binaries/lockfiles and under-300-line files.
- **Learnings for future iterations:**
- Patterns discovered
- Split plans should align with feature directories to avoid cross-feature dependencies.
- Gotchas encountered
- Root `npm test` script is missing; lint currently fails due to existing issues.
- Useful context
- Large admin pages and mobile screens dominate the oversized list; prioritize feature-layer splits.
---
## [2026-01-25 21:06:18] - US-002: Refactor screen-level files into layer-based modules
Thread:
Run: 20260125-204012-65223 (iteration 2)
Run log: /Users/jjbrunton/Projects/Sauci/.ralph/runs/run-20260125-204012-65223-iter-2.log
Run summary: /Users/jjbrunton/Projects/Sauci/.ralph/runs/run-20260125-204012-65223-iter-2.md
- Guardrails reviewed: yes
- No-commit run: false
- Commit: 8d4d9d8 refactor(swipe): split swipe screen modules
- Post-commit status: clean
- Verification:
- Command: npm test -> FAIL (missing script: "test")
- Command: npm run lint -> FAIL (existing lint errors in @sauci/mobile)
- Command: npm run typecheck -> PASS
- Command: npm run build -> PASS
- Command: CI=1 npm run web -- --port 8082 -> FAIL (expo-notifications web localStorage error)
- Files changed:
- .agents/ralph/PROMPT_build.md
- .agents/ralph/README.md
- .agents/ralph/agents.sh
- .agents/ralph/config.sh
- .agents/ralph/diagram.svg
- .agents/ralph/log-activity.sh
- .agents/ralph/loop.sh
- .agents/ralph/ralph.webp
- .agents/ralph/references/CONTEXT_ENGINEERING.md
- .agents/ralph/references/GUARDRAILS.md
- .agents/tasks/prd-mobile-refactor.json
- .codex/skills/commit/SKILL.md
- .codex/skills/commit/references/commit_examples.md
- .codex/skills/dev-browser/SKILL.md
- .codex/skills/dev-browser/bun.lock
- .codex/skills/dev-browser/package-lock.json
- .codex/skills/dev-browser/package.json
- .codex/skills/dev-browser/references/scraping.md
- .codex/skills/dev-browser/scripts/start-relay.ts
- .codex/skills/dev-browser/scripts/start-server.ts
- .codex/skills/dev-browser/server.sh
- .codex/skills/dev-browser/src/client.ts
- .codex/skills/dev-browser/src/index.ts
- .codex/skills/dev-browser/src/relay.ts
- .codex/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts
- .codex/skills/dev-browser/src/snapshot/browser-script.ts
- .codex/skills/dev-browser/src/snapshot/index.ts
- .codex/skills/dev-browser/src/snapshot/inject.ts
- .codex/skills/dev-browser/src/types.ts
- .codex/skills/dev-browser/tsconfig.json
- .codex/skills/dev-browser/vitest.config.ts
- .codex/skills/prd/SKILL.md
- .ralph/activity.log
- .ralph/guardrails.md
- .ralph/runs/run-20260125-204012-65223-iter-1.md
- apps/mobile/android/app/build.gradle
- apps/mobile/app.json
- apps/mobile/app/(app)/swipe.tsx
- apps/mobile/ios/Sauci.xcodeproj/project.pbxproj
- apps/mobile/src/features/swipe/SwipeScreen.tsx
- apps/mobile/src/features/swipe/components/SwipeBlockedState.tsx
- apps/mobile/src/features/swipe/components/SwipeCardStack.tsx
- apps/mobile/src/features/swipe/components/SwipeCaughtUpState.tsx
- apps/mobile/src/features/swipe/components/SwipeDailyLimitState.tsx
- apps/mobile/src/features/swipe/components/SwipeHeader.tsx
- apps/mobile/src/features/swipe/components/SwipeInfoStateLayout.tsx
- apps/mobile/src/features/swipe/components/SwipeLoadingState.tsx
- apps/mobile/src/features/swipe/components/SwipeNoPacksState.tsx
- apps/mobile/src/features/swipe/components/SwipeNoPartnerState.tsx
- apps/mobile/src/features/swipe/components/SwipeQuestionCard.tsx
- apps/mobile/src/features/swipe/components/SwipeUploadOverlay.tsx
- apps/mobile/src/features/swipe/hooks/useProgressShimmer.ts
- apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts
- apps/mobile/src/features/swipe/services/swipeService.ts
- apps/mobile/src/features/swipe/types.ts
- What was implemented
- Split the swipe screen route into a thin container and feature-layer modules (hooks/services/components).
- Extracted state-specific UI screens, header, card stack, and upload overlay to keep screen files smaller.
- Preserved swipe question handling, data fetching, and paywall/feedback flows through the new hook/service layer.
- **Learnings for future iterations:**
- Patterns discovered
- Centralizing screen state in a dedicated hook makes UI extraction simpler without behavior drift.
- Gotchas encountered
- Expo web dev server fails in CI mode due to expo-notifications localStorage usage.
- Useful context
- Repo lint currently fails due to existing hook rule violations in unrelated files.
---
## [2026-01-26 14:21:00] - US-003: Refactor oversized shared components
Thread:
Run: 20260126-140911-55357 (iteration 1)
Run log: /Users/jjbrunton/Projects/Sauci/.ralph/runs/run-20260126-140911-55357-iter-1.log
Run summary: /Users/jjbrunton/Projects/Sauci/.ralph/runs/run-20260126-140911-55357-iter-1.md
- Guardrails reviewed: yes
- No-commit run: false
- Commit: f302cb6 refactor(ui): split IntensitySlider pieces
- Post-commit status: clean
- Verification:
- Command: npm test -> FAIL (missing script: "test")
- Command: npm run lint -> FAIL (existing lint errors/warnings)
- Command: npm run typecheck -> PASS
- Command: npm run build -> PASS
- Command: npm run web -- --port 19006 -> FAIL (expo-notifications localStorage error)
- Files changed:
- .agents/tasks/prd-mobile-refactor.json
- .ralph/activity.log
- .ralph/runs/run-20260125-204012-65223-iter-10.md
- .ralph/runs/run-20260125-204012-65223-iter-11.md
- .ralph/runs/run-20260125-204012-65223-iter-12.md
- .ralph/runs/run-20260125-204012-65223-iter-13.md
- .ralph/runs/run-20260125-204012-65223-iter-14.md
- .ralph/runs/run-20260125-204012-65223-iter-15.md
- .ralph/runs/run-20260125-204012-65223-iter-16.md
- .ralph/runs/run-20260125-204012-65223-iter-17.md
- .ralph/runs/run-20260125-204012-65223-iter-18.md
- .ralph/runs/run-20260125-204012-65223-iter-19.md
- .ralph/runs/run-20260125-204012-65223-iter-2.md
- .ralph/runs/run-20260125-204012-65223-iter-20.md
- .ralph/runs/run-20260125-204012-65223-iter-21.md
- .ralph/runs/run-20260125-204012-65223-iter-22.md
- .ralph/runs/run-20260125-204012-65223-iter-23.md
- .ralph/runs/run-20260125-204012-65223-iter-24.md
- .ralph/runs/run-20260125-204012-65223-iter-25.md
- .ralph/runs/run-20260125-204012-65223-iter-3.md
- .ralph/runs/run-20260125-204012-65223-iter-4.md
- .ralph/runs/run-20260125-204012-65223-iter-5.md
- .ralph/runs/run-20260125-204012-65223-iter-6.md
- .ralph/runs/run-20260125-204012-65223-iter-7.md
- .ralph/runs/run-20260125-204012-65223-iter-8.md
- .ralph/runs/run-20260125-204012-65223-iter-9.md
- apps/mobile/src/components/ui/IntensitySlider.tsx
- apps/mobile/src/components/ui/IntensitySlider/IntensitySliderCurrentCard.tsx
- apps/mobile/src/components/ui/IntensitySlider/IntensitySliderInfoModal.tsx
- apps/mobile/src/components/ui/IntensitySlider/IntensitySliderLevels.tsx
- apps/mobile/src/components/ui/IntensitySlider/IntensitySliderTrack.tsx
- apps/mobile/src/components/ui/IntensitySlider/constants.ts
- apps/mobile/src/components/ui/IntensitySlider/styles.ts
- What was implemented
- Split IntensitySlider into focused subcomponents with shared styles/constants to reduce file size and keep the external API intact.
- **Learnings for future iterations:**
- Patterns discovered
- Extracting shared styles/constants keeps large UI components maintainable without API changes.
- Gotchas encountered
- Expo web failed with expo-notifications localStorage error during browser verification.
- Useful context
- Root npm lacks a test script; lint currently fails from pre-existing issues.
---
## [2026-01-26 14:26:40] - US-004: Refactor oversized hooks/services/utils
Thread:
Run: 20260126-140911-55357 (iteration 2)
Run log: /Users/jjbrunton/Projects/Sauci/.ralph/runs/run-20260126-140911-55357-iter-2.log
Run summary: /Users/jjbrunton/Projects/Sauci/.ralph/runs/run-20260126-140911-55357-iter-2.md
- Guardrails reviewed: yes
- No-commit run: false
- Commit: 5a9defb refactor(swipe): split useSwipeScreen helpers
- Post-commit status: clean
- Verification:
- Command: npm test -> FAIL (missing script: "test")
- Command: npm run lint -> FAIL (pre-existing lint errors in @sauci/mobile)
- Command: npm run typecheck -> PASS
- Command: npm run build -> PASS
- Files changed:
- .agents/tasks/prd-mobile-refactor.json
- .ralph/activity.log
- .ralph/runs/run-20260126-140911-55357-iter-1.md
- apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts
- apps/mobile/src/features/swipe/utils/useSwipeScreen-helpers.ts
- What was implemented
- Split useSwipeScreen logic into helper utilities for filtering, countdowns, totals, pack info, and upload handling while preserving exports.
- **Learnings for future iterations:**
- Patterns discovered
- Gotchas encountered
- Useful context
---
## [2026-01-26 14:33:30] - US-005: Validate refactor quality gates
Thread:
Run: 20260126-140911-55357 (iteration 3)
Run log: /Users/jjbrunton/Projects/Sauci/.ralph/runs/run-20260126-140911-55357-iter-3.log
Run summary: /Users/jjbrunton/Projects/Sauci/.ralph/runs/run-20260126-140911-55357-iter-3.md
- Guardrails reviewed: yes
- No-commit run: false
- Commit: 368399f fix(validation): satisfy quality gate checks
- Post-commit status: clean
- Verification:
- Command: npm test -> PASS
- Command: npm run lint -> PASS
- Command: npm run typecheck -> PASS
- Command: npm run build -> PASS
- Files changed:
- .agents/tasks/prd-mobile-refactor.json
- .ralph/activity.log
- .ralph/runs/run-20260126-140911-55357-iter-2.md
- AGENTS.md
- apps/mobile/src/components/questions/QuestionCardAudio.tsx
- apps/mobile/src/features/chat/components/MessageContent.tsx
- package.json
- .ralph/progress.md
- What was implemented
- Added root test script, fixed lint hook violations, documented validation commands
- **Learnings for future iterations:**
- Patterns discovered
- Gotchas encountered
- Useful context
---
+99
View File
@@ -0,0 +1,99 @@
# Sauci agent map
Sauci is a couples connection product: an Expo mobile app, React admin, Next.js
web app, standalone Node/PostgreSQL backend, hosted Supabase Auth, and an internal
MCP service in one npm/Turborepo workspace.
## Golden rules
1. Read the nearest `AGENTS.md` before changing a scoped area.
2. Preserve tenant and couple isolation. Never bypass RLS, role checks, or auth.
3. Product-data schema changes are committed SQL files in `apps/api/drizzle/`.
Hosted Supabase owns Auth only; do not deploy product-data migrations or Edge
Functions there. Never execute remote DDL through MCP or provider tools.
4. Never target production from local development or E2E. Database integration
tests must fail closed unless `DATABASE_URL` resolves to loopback. Authenticated
native acceptance may use only the designated non-production Auth project.
5. Read `apps/mobile/DESIGN.md` before mobile UI work.
6. Keep shared package exports type-only and route them through `src/index.ts`.
7. Do not commit secrets, generated evidence, local logs, or runtime state.
8. Implement in a task-specific git worktree created from current `staging`,
not in the shared `staging` checkout. After the work is verified, commit the
scoped changes, merge the completed task branch back into local `staging`,
push `staging`, and prove local and remote commit parity.
9. Before merging, identify every deployed app or service the change can affect
and prove the relevant compatibility contracts still hold. Preserve deployed
interfaces and supported client behavior; repository checks alone do not
prove a live change is safe.
10. The Sol primary agent owns scope, risk classification, acceptance criteria,
progress steering, final diff and evidence review, and the final answer.
Delegate every substantive repository task to a project agent. Each
substantive implementation must use one Terra `implementation` agent as a
coherent execution unit, normally combining scoped discovery, implementation,
focused tests, and iteration. "Substantive" includes repository exploration,
multi-file reasoning, implementation, test execution, or contract/risk
analysis. Add a `repo_scout`, reviewer, or verifier only for separable work or
the triggers in `docs/agents/routing.md`; do not duplicate the implementation
agent's detailed exploration or checks without a concrete reason. Skip
delegation only for trivial conversation, a single obvious lookup, or a
mechanical one-line edit whose coordination cost exceeds the work.
11. Use high-effort reviewers only for the risk triggers in
`docs/agents/routing.md`.
12. Agents must not use em dash characters in text they add to this repository.
13. Before beginning implementation, tell the user whether the proposed change
is likely deliverable through an Expo OTA update, likely requires a new
native/store build, or has no applicable OTA path, with a brief reason. If
uncertain, state that uncertainty and name the validation needed.
## Repository map
| Area | Purpose | Local instructions |
|---|---|---|
| `apps/mobile` | Expo React Native product | `apps/mobile/AGENTS.md` |
| `apps/admin` | Vite administration UI | `apps/admin/AGENTS.md` |
| `apps/web` | Next.js marketing/redemption UI | `apps/web/AGENTS.md` |
| `apps/api` | standalone API, worker, PostgreSQL migrations | repository rules |
| `apps/supabase` | legacy data-plane history; hosted Auth configuration only | `apps/supabase/AGENTS.md` |
| `apps/mcp` | internal administrative MCP service | `apps/mcp/AGENTS.md` |
| `packages/shared` | shared TypeScript contracts | `packages/shared/AGENTS.md` |
| `e2e` | cross-application Playwright flows | `e2e/README.md` |
| `docs` | maintained system of record | `docs/index.md` |
## Workflows
- Implementation: create a task worktree from current `staging`; commit verified
work, merge it back to local `staging`, push `staging`, and verify remote parity.
- Bootstrap: `npm ci`
- Full local stack: `scripts/dev-local.sh up`
- Fast checks: `npm run verify:fast`
- Authoritative repository checks: `npm run verify:full`
- Running-stack E2E: `npm run verify:e2e`
- Everything: `npm run verify`
- Before a PR: use `.codex/skills/verify/SKILL.md`
When a check cannot run because a prerequisite is absent, report it as an
environment limitation; do not call it a passing product check.
## Where to look
| Question | Source of truth |
|---|---|
| System boundaries and data flow | `docs/architecture/system.md` |
| Local services, ports, fixtures | `docs/development/local-stack.md` |
| Dependency and lockfile discipline | `docs/development/dependencies.md` |
| Test layers and required gates | `docs/testing/strategy.md` |
| Database migration safety | `docs/operations/database-migrations.md` |
| Agent roles and effort routing | `docs/agents/routing.md` |
| Learning promotion | `docs/agents/learnings.md` |
| Mobile visual language | `apps/mobile/DESIGN.md` |
| Domain documentation index | `docs/index.md` |
## Documentation discipline
Update the maintained doc when a contract, command, port, or invariant changes.
When question interaction types, response payloads, configuration, or match
semantics change, update `docs/question-types.md` in the same change and run
`npm run lint:harness` to prove its type list matches the shared contract.
Plans and historical notes are evidence, not current architecture. A repeated
lesson becomes permanent only after it is reproduced and promoted into a doc,
skill, fixture, or executable check.
+4 -297
View File
@@ -1,298 +1,5 @@
# CLAUDE.md # Claude compatibility entrypoint
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Read and follow [`AGENTS.md`](AGENTS.md). It is the runtime-neutral source of truth
for this repository. Scoped rules live in the nearest `AGENTS.md`; maintained
## Important: Design Guidelines documentation is indexed by [`docs/index.md`](docs/index.md).
**Always read `apps/mobile/DESIGN.md` before making UI/UX changes to the mobile app.** This file contains the design system, visual language, and styling patterns that must be followed for consistency. Agents and subagents should read this file when:
- Creating new screens or components
- Modifying existing UI elements
- Adding animations or visual effects
- Working with colors, typography, or spacing
## Project Overview
Sauci is a couples relationship app that helps partners communicate better and grow closer together. Users swipe on thoughtful questions (yes/no/maybe), and when both partners answer positively, a "match" is created that unlocks a chat thread for that topic.
## Commands
```bash
# Install dependencies (from root)
npm install
# Start all apps in development
npm run dev
# Run specific apps
cd apps/mobile && npm run dev # Expo mobile app
cd apps/admin && npm run dev # React Admin dashboard
# Type checking
npm run typecheck
# Linting
npm run lint
# Build all
npm run build
```
### Mobile-specific commands
```bash
cd apps/mobile
npm run ios # Run on iOS simulator
npm run android # Run on Android emulator
npm run web # Run in browser
```
### Supabase commands
```bash
supabase start # Start local Supabase
supabase db reset # Reset DB and run all migrations
supabase functions serve # Run edge functions locally
supabase migration new <name> # Create new migration
```
### Supabase Environments
There are two Supabase projects configured via MCP servers:
| Environment | Project Ref | URL | MCP Server |
|-------------|-------------|-----|------------|
| **Production** | `ckjcrkjpmhqhiucifukx` | https://ckjcrkjpmhqhiucifukx.supabase.co | `sauci-prod` |
| **Non-Production** | `itbzhrvlgvdmzbnhzhyx` | https://itbzhrvlgvdmzbnhzhyx.supabase.co | `sauci-non-prod` |
When using Claude Code with MCP tools:
- Use `mcp__sauci-prod__*` tools for production database operations
- Use `mcp__sauci-non-prod__*` tools for development/staging database operations
- **Always verify which environment you're targeting before running destructive operations**
### Database Migrations - IMPORTANT
**NEVER use MCP tools (`apply_migration`) for database migrations.** Always use the Supabase CLI instead.
**Why:** MCP migrations are tracked in the database but do NOT create local migration files. This means:
- Migration history is not committed to git
- Other developers won't get the schema changes
- `supabase db reset` won't include MCP-applied migrations
- Changes cannot be reproduced from scratch
**Correct workflow for schema changes:**
```bash
# 1. Create migration file locally
supabase migration new <descriptive_name>
# 2. Edit the migration file in apps/supabase/migrations/
# 3. Test locally
supabase db reset
# 4. Deploy to remote (link to correct project first)
supabase link --project-ref <project_ref>
supabase db push
```
**MCP tools ARE appropriate for:**
- Reading data (`execute_sql` for SELECT queries)
- Exploring schema (`list_tables`, `list_migrations`)
- Deploying edge functions (`deploy_edge_function`)
- Generating types (`generate_typescript_types`)
## Architecture
### Monorepo Structure (Turborepo)
- `apps/mobile` - Expo React Native app (expo-router for navigation)
- `apps/admin` - React Admin dashboard for content management
- `apps/supabase` - Database migrations and edge functions
- `packages/shared` - Shared TypeScript types (`@sauci/shared`)
### Mobile App Architecture
- **Routing**: Expo Router with file-based routing (`app/` directory)
- `(auth)/` - Authentication screens
- `(app)/` - Main app screens (protected)
- **State Management**: Zustand stores in `src/store/index.ts`
- `useAuthStore` - User, couple, partner state
- `useMatchStore` - Matches and notifications
- `usePacksStore` - Question packs and enabled packs per couple
- `useMessageStore` - Chat messages and unread counts
- `useSubscriptionStore` - RevenueCat subscription state
- **Backend**: Supabase client in `src/lib/supabase.ts`
- **UI Components**: Glass-morphism themed components in `src/components/ui/`
### Data Model (Core Entities)
- **Profile** - User profile extending Supabase auth (has `couple_id`)
- **Couple** - Two users linked via invite code
- **QuestionPack** - Collection of questions (can be premium)
- **Question** - Individual question with intensity level (1-5)
- **Response** - User's answer (yes/no/maybe) to a question
- **Match** - Created when both partners answer positively (yes_yes, yes_maybe, maybe_maybe)
### Edge Functions
Edge functions in `apps/supabase/functions/` handle complex operations:
- `submit-response` - Saves response, checks for matches, triggers notifications
- `manage-couple` - Join/leave couple operations
- `delete-relationship` - Deletes couple data (matches, messages, media) while keeping accounts
- `delete-account` - Permanently deletes user account and all associated data (Apple App Store requirement)
- `send-notification` - Push notifications for matches
- `send-nudge-notification` - Partner nudge notifications (rate limited to once per 12 hours)
- `revenuecat-webhook` - Subscription webhook handler
- `sync-subscription` - Syncs RevenueCat subscription status
- `redeem-code` - Redeems promotional codes for premium access (website only)
#### Edge Function Deployment Guidelines
When deploying edge functions via MCP tools (`mcp__sauci-*__deploy_edge_function`):
**IMPORTANT: Always use `verify_jwt: false`** for all edge functions. Supabase's built-in JWT verification causes 401 errors even with valid tokens. Instead, handle authentication manually in the function code using:
```typescript
const { data: { user }, error: authError } = await supabase.auth.getUser(
authHeader.replace("Bearer ", "")
);
```
This approach:
- Gives better error messages
- Allows custom auth handling
- Avoids mysterious 401 failures
**Current function settings (all should be verify_jwt: false):**
| Function | verify_jwt | Auth Method |
|----------|------------|-------------|
| `manage-couple` | false | Manual via getUser() |
| `submit-response` | false | Manual via getUser() |
| `sync-subscription` | false | Manual via getUser() |
| `delete-relationship` | false | Manual via getUser() |
| `delete-account` | false | Manual via getUser() |
| `redeem-code` | false | Manual via getUser() |
| `revenuecat-webhook` | false | Webhook signature validation |
| `send-notification` | false | Internal trigger (no auth) |
| `send-message-notification` | false | Internal trigger (no auth) |
| `send-nudge-notification` | false | Manual via getUser() |
### Database Best Practices
- **Always use `.maybeSingle()` instead of `.single()`** when the row might not exist. `.single()` throws an error if 0 or >1 rows are returned.
- **Profile creation trigger**: The `on_auth_user_created` trigger automatically creates a profile when a user signs up. Never assume a profile exists without checking.
- **Check for null profiles** in edge functions before performing operations.
### Key Patterns
- Responses use UPSERT with `onConflict: "user_id,question_id"`
- Match detection happens in `submit-response` edge function after each response
- Real-time subscriptions used for chat messages
- RevenueCat handles in-app purchases and subscription management
- RLS policies enforce couple-level data isolation
### Redemption Code System
Promotional codes that grant users premium access. **Codes can only be redeemed on the website, NOT in the mobile app.**
**Database Tables:**
- `redemption_codes` - Stores codes with max_uses, current_uses, expiration, active status
- `code_redemptions` - Tracks which users redeemed which codes
**Components:**
- **Admin UI** (`apps/admin/src/pages/RedemptionCodesPage.tsx`) - Super admins can generate, manage, and view redemption codes
- **Edge Function** (`apps/supabase/functions/redeem-code/`) - Validates and processes code redemption (unauthenticated, accepts email + code)
- **Database Function** (`redeem_code_by_email(p_email, p_code)`) - Atomic redemption with validation (active, not expired, has uses remaining, user hasn't redeemed)
- **Web UI** (`apps/web/app/redeem/page.tsx`) - Public redemption page
**Flow:**
1. Admin generates code in admin dashboard
2. User visits `/redeem` on website and enters email + code
3. `redeem-code` edge function calls `redeem_code_by_email()` database function
4. Database function looks up user by email, validates code, and sets `profiles.is_premium = true`
**Important:** Do NOT add redemption code UI to the mobile app. Apple requires in-app purchases for premium features purchased within iOS apps.
## Mobile UI - DO NOT CHANGE
### Tab Bar Background
**NEVER set `backgroundColor: 'transparent'` on the tab bar.** Always use a semi-transparent solid color:
```typescript
backgroundColor: 'rgba(18, 18, 18, 0.85)',
```
The `BlurView` in `tabBarBackground` is unreliable and can fail during re-renders/transitions, causing a fully transparent tab bar. The solid rgba background acts as a fallback while still allowing the blur effect on top.
**Location:** `apps/mobile/app/(app)/_layout.tsx` - `tabBarStyle`
## Infrastructure as Code (Terraform)
Supabase project configuration is managed via Terraform in the `terraform/` directory. This includes auth settings, OAuth providers, email templates, SMTP config, rate limits, and more.
**Directory structure:**
```
terraform/
├── modules/
│ └── supabase/ # Shared configuration module
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── templates/ # Email templates
└── environments/
├── prod/ # Production (ckjcrkjpmhqhiucifukx)
└── non-prod/ # Non-production (itbzhrvlgvdmzbnhzhyx)
```
**When to update Terraform:**
- Changing auth settings (OAuth providers, JWT expiry, MFA settings)
- Modifying email templates or subjects
- Updating rate limits or security settings
- Changing SMTP configuration
- Modifying storage or API settings
**Workflow (always test in non-prod first):**
```bash
# 1. Apply to non-prod first
cd terraform/environments/non-prod
export TF_VAR_supabase_access_token=sbp_xxxxx
terraform plan
terraform apply
# 2. Verify changes work in non-prod
# 3. Apply to prod
cd terraform/environments/prod
terraform plan
terraform apply
```
**Important:** After making Terraform changes, remind the user to:
1. Commit the updated `terraform/` files
2. The GitHub Action will auto-apply on merge to master (non-prod first, then prod)
**Do NOT use Terraform for:**
- Database schema changes (use migrations)
- Edge function deployment (use CLI or MCP)
- Storage bucket creation (use migrations)
## Edge Function Secrets
Edge functions use secrets stored in Supabase. These are synced from GitHub Secrets during CI/CD deployment.
**Current secrets:**
| Secret | Used By | Description |
|--------|---------|-------------|
| `REVENUECAT_WEBHOOK_SECRET` | revenuecat-webhook | Webhook signature validation |
| `REVENUECAT_API_KEY` | sync-subscription | RevenueCat API access |
| `REVENUECAT_ENTITLEMENT_ID` | sync-subscription | Entitlement identifier |
| `OPENROUTER_API_KEY` | classify-message | AI classification API |
| `ADMIN_PRIVATE_KEY_JWK` | admin-decrypt-* | E2EE admin decryption |
| `ADMIN_KEYS_JSON` | admin-decrypt-*, migrate-e2ee | Multiple admin keys |
| `DISCORD_WEBHOOK_URL` | send-discord-notification | Discord notifications |
**Auto-provided by Supabase (don't set):**
- `SUPABASE_URL`
- `SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
**When adding a new edge function that needs secrets:**
1. Add the secret to the edge function code: `Deno.env.get("SECRET_NAME")`
2. Update `.github/workflows/deploy-supabase.yml` to include the new secret in the sync step
3. Update `apps/supabase/functions/.secrets.example` with the new secret
4. **Remind the user** to add the secret to GitHub Secrets (Settings → Secrets → Actions)
5. Update this table in CLAUDE.md
**Local development:**
```bash
cp apps/supabase/functions/.secrets.example apps/supabase/functions/.secrets
# Fill in values
supabase functions serve --env-file apps/supabase/functions/.secrets
```
Loaded 100 of 856 files, more files were not shown because too many files have changed in this diff. Show more