mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 10:19:43 +00:00
Compare commits
283
Commits
6f1be7a4e1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c904f4e960 | ||
|
|
15c7894bec | ||
|
|
57c7ecebe6 | ||
|
|
17372a9bf0 | ||
|
|
0f9c9c2e15 | ||
|
|
4a57f31615 | ||
|
|
1c7084c033 | ||
|
|
0dfe4435b0 | ||
|
|
5ac4843095 | ||
|
|
8e48467414 | ||
|
|
1fed348072 | ||
|
|
a1b62c3b47 | ||
|
|
4415dcf29f | ||
|
|
95162d1889 | ||
|
|
dd1a47ecfa | ||
|
|
a02e9d531b | ||
|
|
49a9cd270f | ||
|
|
077f5433e1 | ||
|
|
ade2c42d51 | ||
|
|
daefdc6da9 | ||
|
|
029a90b338 | ||
|
|
bb0c712ede | ||
|
|
1c4ec2b412 | ||
|
|
21006cd041 | ||
|
|
f9481054de | ||
|
|
922066f492 | ||
|
|
ff2cf09170 | ||
|
|
402c027b14 | ||
|
|
1f5a939c3f | ||
|
|
418dd50306 | ||
|
|
206acbc670 | ||
|
|
ea038c9863 | ||
|
|
a431adad89 | ||
|
|
860986f900 | ||
|
|
439f506fff | ||
|
|
557d78b785 | ||
|
|
87309101ca | ||
|
|
d998e32047 | ||
|
|
7edd957547 | ||
|
|
fdaa8c47bb | ||
|
|
04328fb3a8 | ||
|
|
f373e3beb4 | ||
|
|
ba3b37989f | ||
|
|
583d38eb8c | ||
|
|
d604cd7a11 | ||
|
|
ddf1e33a4e | ||
|
|
da1c310738 | ||
|
|
5c8c9cc9a5 | ||
|
|
c297f03b56 | ||
|
|
c79d993cf5 | ||
|
|
b5e6b70d54 | ||
|
|
2a2cbe9e85 | ||
|
|
eb54aa4daa | ||
|
|
e509ac732f | ||
|
|
a72fe9c06d | ||
|
|
450c527d1e | ||
|
|
6c7219c741 | ||
|
|
b02a105e10 | ||
|
|
f4687f95fe | ||
|
|
53a48ab6d5 | ||
|
|
a6233d0175 | ||
|
|
c776859183 | ||
|
|
cc12f5f0ff | ||
|
|
3659cc8c2f | ||
|
|
4b227ad62c | ||
|
|
7c8d91c89c | ||
|
|
fe1248c59d | ||
|
|
6883befff3 | ||
|
|
a06f1b69e8 | ||
|
|
3a019e62d3 | ||
|
|
636811e66a | ||
|
|
84712a950e | ||
|
|
d14869d745 | ||
|
|
f1573f4906 | ||
|
|
d825e3d80c | ||
|
|
13094267a6 | ||
|
|
ecc31a13a2 | ||
|
|
7a76936829 | ||
|
|
83df3b45f8 | ||
|
|
9609a9084b | ||
|
|
90cde4aca4 | ||
|
|
ecff9e2ec8 | ||
|
|
ffc655f576 | ||
|
|
d8bedead0c | ||
|
|
6c3a181613 | ||
|
|
d8ff39a2f3 | ||
|
|
b8cb2addff | ||
|
|
5e28117662 | ||
|
|
196428f796 | ||
|
|
6bd04a6c06 | ||
|
|
7aca39aa8f | ||
|
|
79233f58aa | ||
|
|
c9e8835810 | ||
|
|
ff8f99f418 | ||
|
|
604eec3322 | ||
|
|
dbf548c7bd | ||
|
|
4973f2c631 | ||
|
|
2b05e50312 | ||
|
|
26312e5a58 | ||
|
|
66c25849e9 | ||
|
|
5c4a4608b1 | ||
|
|
659cee76b6 | ||
|
|
6bc4e31719 | ||
|
|
aa062c9fd0 | ||
|
|
5947220fc6 | ||
|
|
8bf93f372b | ||
|
|
2f05e9ae87 | ||
|
|
3fc6f8aa1a | ||
|
|
02a7349899 | ||
|
|
8f70dbcc00 | ||
|
|
ac80d6b614 | ||
|
|
056bb62d04 | ||
|
|
80ec1663f9 | ||
|
|
8ff05c050b | ||
|
|
549419892d | ||
|
|
a46b8b95e6 | ||
|
|
e54f6d31ed | ||
|
|
267f3c3cf9 | ||
|
|
02aa6e4a75 | ||
|
|
cf19d23d8a | ||
|
|
db5980c9bd | ||
|
|
2664791233 | ||
|
|
f37673cad7 | ||
|
|
0ada812294 | ||
|
|
f4db567267 | ||
|
|
d298a33656 | ||
|
|
af4e1d0cf6 | ||
|
|
9e6e4612f5 | ||
|
|
7d392e756d | ||
|
|
3c08963a70 | ||
|
|
d86d57d5c2 | ||
|
|
57501230cd | ||
|
|
986d0b21cb | ||
|
|
504eddaf68 | ||
|
|
f52b8536be | ||
|
|
09255da7db | ||
|
|
8e83cc0d85 | ||
|
|
8955e49aae | ||
|
|
04822043ae | ||
|
|
eab73e6314 | ||
|
|
fd8981fa28 | ||
|
|
25b7c6238e | ||
|
|
77f2718fdd | ||
|
|
f22d4a3649 | ||
|
|
45d24911fb | ||
|
|
f38b6f14fa | ||
|
|
74c1acfe2d | ||
|
|
23db4ed951 | ||
|
|
ccd3f03437 | ||
|
|
6523ddeb1c | ||
|
|
4f9950f21b | ||
|
|
d48b44baa8 | ||
|
|
5564f9bbab | ||
|
|
c972b1c236 | ||
|
|
30aa7bc386 | ||
|
|
8bbb0e8cea | ||
|
|
fbdb2a8fff | ||
|
|
bdfe8b7171 | ||
|
|
2cbe9e9c55 | ||
|
|
8d9a3f4f1c | ||
|
|
476e4c845f | ||
|
|
3d53d001b7 | ||
|
|
8658fc38fe | ||
|
|
24de9af4cd | ||
|
|
5e581efe51 | ||
|
|
12c6b8bcc9 | ||
|
|
599be22eb1 | ||
|
|
9514835bf2 | ||
|
|
65bd1bbd6b | ||
|
|
e0813ba5a3 | ||
|
|
f7c686d5cf | ||
|
|
314664ecd6 | ||
|
|
11f89b1345 | ||
|
|
2481956c11 | ||
|
|
8804e121b6 | ||
|
|
7b3de48dbd | ||
|
|
5ebf57bdbf | ||
|
|
089e629021 | ||
|
|
58b70710db | ||
|
|
d645d83c47 | ||
|
|
179f912170 | ||
|
|
a604f82110 | ||
|
|
ae8e72ddb5 | ||
|
|
2e4cddd1c8 | ||
|
|
440f224095 | ||
|
|
22871d037c | ||
|
|
fd38b303f0 | ||
|
|
266233a473 | ||
|
|
387b242aea | ||
|
|
2bc69c2790 | ||
|
|
9da44217f7 | ||
|
|
5f77b06044 | ||
|
|
5c973c4ac5 | ||
|
|
a13bbc3f65 | ||
|
|
10c4ae8d84 | ||
|
|
ac4a05d5ed | ||
|
|
c7ee8a4851 | ||
|
|
9957fa43f2 | ||
|
|
502452c9e4 | ||
|
|
e6bb368bc8 | ||
|
|
aedba2a480 | ||
|
|
78900a24fb | ||
|
|
03443fe898 | ||
|
|
b60c788362 | ||
|
|
61ec3692b7 | ||
|
|
1482305810 | ||
|
|
8b37569818 | ||
|
|
2178750d07 | ||
|
|
028e21e54c | ||
|
|
b24a402b4c | ||
|
|
ce0b188776 | ||
|
|
f35114dc77 | ||
|
|
aacb4f8731 | ||
|
|
1be54175e4 | ||
|
|
a7470310ae | ||
|
|
3acadeff7c | ||
|
|
91544629fa | ||
|
|
32f5214751 | ||
|
|
96882642c7 | ||
|
|
a455325147 | ||
|
|
2dad7aa352 | ||
|
|
be20391788 | ||
|
|
006761d3b0 | ||
|
|
7a0de45482 | ||
|
|
72e031eb84 | ||
|
|
2ab976ec42 | ||
|
|
c0650ed5b7 | ||
|
|
94c76535a4 | ||
|
|
b43804d8fd | ||
|
|
79862dac13 | ||
|
|
bf2ef59688 | ||
|
|
d869592a8b | ||
|
|
d43ae1ef05 | ||
|
|
024dc4d288 | ||
|
|
04dee69888 | ||
|
|
650c81856f | ||
|
|
7545289dd3 | ||
|
|
6df9442cd0 | ||
|
|
01bb8225ae | ||
|
|
5a69bbc1a5 | ||
|
|
3afb091e90 | ||
|
|
de0d667f4f | ||
|
|
458f18be03 | ||
|
|
785e73d6c0 | ||
|
|
4c18afaeae | ||
|
|
520d0a8444 | ||
|
|
964fc25fc7 | ||
|
|
c8aaa8967d | ||
|
|
8a44fef764 | ||
|
|
edb2cedda0 | ||
|
|
73d7c1a200 | ||
|
|
307af2c1e9 | ||
|
|
057c682c6c | ||
|
|
e29183f104 | ||
|
|
f07f80b529 | ||
|
|
25db338861 | ||
|
|
e380c0a31c | ||
|
|
14b0199625 | ||
|
|
620369a7ce | ||
|
|
ce419fee8b | ||
|
|
368399f463 | ||
|
|
1370576dd5 | ||
|
|
6957502985 | ||
|
|
5a9defb1ee | ||
|
|
70449659cf | ||
|
|
f302cb6c63 | ||
|
|
a01079a7c3 | ||
|
|
8d4d9d8750 | ||
|
|
209dc652d2 | ||
|
|
06dfc5b245 | ||
|
|
45499edc9a | ||
|
|
0711080112 | ||
|
|
a531f43d91 | ||
|
|
1e4974e2a0 | ||
|
|
7952d98d18 | ||
|
|
d4e05bc7ff | ||
|
|
a6b3b6a251 | ||
|
|
9402369246 | ||
|
|
1daf386765 | ||
|
|
8612b16b93 | ||
|
|
6c2e471d47 | ||
|
|
69ac0940d3 | ||
|
|
bc38e41572 |
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Ralph
|
||||
|
||||

|
||||
|
||||
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/`
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 |
Executable
+15
@@ -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"
|
||||
Executable
+955
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
{
|
||||
"version": 1,
|
||||
"project": "Sauci Private Mutual Discovery, OTA-first delivery plan",
|
||||
"overview": "Evolve Sauci into a private mutual-discovery journey for adult couples: choose a mood and private shared comfort ceiling, answer a short set privately, reveal only mutual interest, then choose an optional next action. Deliver independent client slices through Expo OTA where the installed binary is compatible, while keeping session privacy, consent state, catalogue safety, API, data, and operational changes as separately gated work.",
|
||||
"goals": [
|
||||
"Make the first useful experience a short, mood-led Quick Spark session rather than an open-ended question feed.",
|
||||
"Reveal only mutual interest or both-partner responses and never reveal an unmatched preference or which partner selected a lower comfort ceiling.",
|
||||
"Let a mutual match lead to an optional talk, save, or shared action without turning either partner into an assigned recipient.",
|
||||
"Make defer, decline, a softer alternative, and withdrawal explicit, reversible where appropriate, and free of penalties.",
|
||||
"Keep developer-authored content non-graphic, reviewed, and eligible before it can reach a customer.",
|
||||
"Maximize JavaScript and bundled-asset work that can ship through OTA after live compatibility is proven."
|
||||
],
|
||||
"nonGoals": [
|
||||
"No production deployment, OTA publish, store submission, migration execution, catalogue activation, or provider write is authorized by this PRD.",
|
||||
"No explicit sexual instructions, content intended for sexual gratification, or use of UGC as a policy workaround.",
|
||||
"No clinical relationship-repair service, coercion intervention, solo experience, points economy, or mandatory task assignment.",
|
||||
"No disclosure of individual answers, individual comfort selections, intimate content, or prompt text through analytics or push notification previews.",
|
||||
"No assumption that the legacy Supabase recommendation-function documentation describes the current standalone API implementation."
|
||||
],
|
||||
"successMetrics": [
|
||||
"Measure only aggregate privacy-safe events: session starts, two-partner completion, mutual matches, optional talk/save/action selections, skip/defer/decline/withdraw/report/block, and 7/28-day repeat sessions.",
|
||||
"No analytics event contains question text, response text, audio, photo, individual answer, individual ceiling, or intimate match detail.",
|
||||
"A paired couple can complete a reviewed Quick Spark session and see only its allowed shared outcome.",
|
||||
"A user can stop, defer, decline, or withdraw without losing access or creating a partner-visible attribution of their private reason.",
|
||||
"Every OTA-labelled story is released only after its platform/runtime canary and final native-surface review pass."
|
||||
],
|
||||
"openQuestions": [
|
||||
"Which reviewed mood taxonomy and initial prompt set best fit the product positioning without crossing the cross-platform content ceiling?",
|
||||
"Should saved mutual interests expire automatically, be archived manually, or both?",
|
||||
"What exact participant-visible wording preserves a withdrawal without falsely implying that a partner declined?",
|
||||
"What age assurance, reporting, blocking, moderation ownership, rate limits, and incident process are effective in production for all relevant UGC surfaces?",
|
||||
"Does the public Android build embed the same XPREM app id, channel headers, certificate, and runtime 1.0.8 configuration as the inspected source?"
|
||||
],
|
||||
"stack": {
|
||||
"framework": "Expo React Native with Expo Router and TypeScript",
|
||||
"mobileDelivery": "Self-hosted XPREM implementing Expo Updates; iOS public source runtime is 1.0.8 and commit 3659cc8 records iOS build 49, but installed-build compatibility requires a live canary",
|
||||
"backend": "Standalone Node HTTP API with PostgreSQL and Drizzle migrations",
|
||||
"admin": "React/Vite content administration UI",
|
||||
"auth": "Hosted Supabase Auth only; product data remains in the standalone API database",
|
||||
"analytics": "Existing privacy-safe telemetry conventions, expanded only after event-schema review"
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
"path": "/(app)/swipe",
|
||||
"name": "Quick Spark entry and session",
|
||||
"purpose": "Mood-led private short session, reusing the existing swipe feature while a dedicated route is evaluated."
|
||||
},
|
||||
{
|
||||
"path": "/(app)/matches",
|
||||
"name": "Mutual discovery outcomes",
|
||||
"purpose": "Show a mutual result and route to existing conversation, saving, or a future shared-action flow."
|
||||
},
|
||||
{
|
||||
"path": "/(app)/dares",
|
||||
"name": "Existing dares",
|
||||
"purpose": "Remain a separate sender-to-recipient challenge model; it must not be relabelled as a shared action."
|
||||
},
|
||||
{
|
||||
"path": "/admin/questions and /admin/packs",
|
||||
"name": "Question catalogue authoring",
|
||||
"purpose": "Author, review, and activate only content whose enforced eligibility contract is proven."
|
||||
}
|
||||
],
|
||||
"uiNotes": [
|
||||
"Use the existing Swipe/Match feature colour, rose-to-purple gradient, locked flame and heart icons, dark solid surfaces, and high-contrast accessible controls.",
|
||||
"Quick Spark should feel finite and calm: mood selection, a short 5-8 prompt progress state, private-answer reassurance, and a restrained completion state.",
|
||||
"Comfort selection must describe a shared ceiling, not a score to compare. Never render the partner's value, a lower-value hint, or copy that attributes the ceiling to either person.",
|
||||
"Outcome actions must be equal-weight choices. Talk opens the existing match conversation; Save is not shown until it has durable server semantics; shared action is not shown until its domain contract exists.",
|
||||
"Safety actions must be clear, non-judgmental, and available before commitment. Avoid gamification, streak pressure, countdown pressure, or punitive copy."
|
||||
],
|
||||
"dataModel": [
|
||||
{
|
||||
"entity": "mutual_discovery_sessions",
|
||||
"fields": ["id", "couple_id", "created_at", "status", "mood", "shared_comfort_ceiling", "expires_at"]
|
||||
},
|
||||
{
|
||||
"entity": "mutual_discovery_session_participants",
|
||||
"fields": ["session_id", "user_id", "private_comfort_selection", "completed_at", "withdrawn_at"]
|
||||
},
|
||||
{
|
||||
"entity": "shared_actions",
|
||||
"fields": ["id", "couple_id", "match_id", "status", "created_by", "saved_at", "deferred_at", "withdrawn_at", "expires_at"]
|
||||
},
|
||||
{
|
||||
"entity": "shared_action_events",
|
||||
"fields": ["id", "shared_action_id", "actor_id", "event_type", "created_at"]
|
||||
},
|
||||
{
|
||||
"entity": "catalogue_review",
|
||||
"fields": ["content_id", "content_kind", "content_status", "reviewed_by", "reviewed_at", "review_reason"]
|
||||
}
|
||||
],
|
||||
"importFormat": {
|
||||
"description": "No bulk import. Developer-authored prompts and packs use the existing audited admin authoring workflow and must remain unreviewed and ineligible until an editorial decision is recorded and server enforcement is verified.",
|
||||
"example": {}
|
||||
},
|
||||
"rules": [
|
||||
"Mutuality is server-calculated from couple-scoped inputs. A client must never infer mutuality from its own answer or a local cache.",
|
||||
"The shared comfort ceiling is the minimum of two private selections, but neither private selection is returned to the partner or emitted in analytics.",
|
||||
"A no produces no mutual match. Existing stored maybe semantics remain backwards compatible until a separately approved session contract changes them.",
|
||||
"Existing `max_intensity` and pack selection gates are not a per-session private comfort protocol and must not be presented as one.",
|
||||
"Current dares have sender and recipient roles. Reuse only a truthful optional handoff after the mutual shared-action domain exists; do not map shared action states onto dare states.",
|
||||
"All API reads and writes enforce authenticated couple isolation server-side. Unauthorized or cross-couple identifiers return non-disclosing failures.",
|
||||
"New developer-authored adult-oriented prompts cannot be activated until universal question and pack eligibility enforcement is proven in the active API path.",
|
||||
"Push payloads remain generic and do not include question, answer, match, action, or intimate content text.",
|
||||
"A new question interaction type, response payload, configuration shape, or match semantic updates docs/question-types.md and passes lint:harness in its implementation story.",
|
||||
"OTA eligibility is assessed per final diff. JavaScript and assets are only OTA candidates when native code, dependencies, permissions, entitlements, config-plugin output, Expo SDK, ios/, android/, expo.version, and runtimeVersion remain unchanged."
|
||||
],
|
||||
"qualityGates": [
|
||||
"npm run lint:harness",
|
||||
"npm run verify:fast",
|
||||
"npm run verify:full",
|
||||
"Focused API route and integration tests for every changed domain",
|
||||
"Focused mobile Jest tests, TypeScript check, lint, and observable iOS/Android simulator evidence for every changed mobile UI flow",
|
||||
"Read-only OTA preflight and doctor checks for the targeted channel and platform before any separately authorized OTA publish",
|
||||
"Live production compatibility and end-to-end read-back before a production release, including deployed API compatibility for any client change"
|
||||
],
|
||||
"stories": [
|
||||
{
|
||||
"id": "US-001",
|
||||
"title": "Establish release and live-state gates for mutual discovery",
|
||||
"status": "open",
|
||||
"dependsOn": [],
|
||||
"description": "As a release owner, I want a verified compatibility record before labelling a client slice OTA-safe so that an update reaches the intended installed app without assuming source equals production.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 0, release readiness.",
|
||||
"Deployment classification: operations and live-state verification, no OTA or store release in this story.",
|
||||
"Record read-only evidence that public iOS source is version/runtime 1.0.8, commit 3659cc8 records build 49, and the production XPREM doctor serves v2 clients; separately record whether a real installed iOS build 49 receives a signed production manifest for runtime 1.0.8.",
|
||||
"Record Android installed-build, XPREM header, certificate, channel, runtime, and manifest evidence independently; until then mark Android OTA eligibility unverified.",
|
||||
"Map every deployed consumer for the proposed API contracts and identify backward-compatible response handling required before the first backend rollout.",
|
||||
"Example: an installed iOS build reports runtime 1.0.8 and receives the expected signed production manifest, so a JS-only iOS Quick Spark diff may proceed to release review.",
|
||||
"Negative case: source contains runtime 1.0.8 but the installed binary does not request or accept that manifest, so no OTA is published and a native/store remediation is planned."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-002",
|
||||
"title": "Prove universal reviewed-question eligibility before activating new content",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-001"],
|
||||
"description": "As a catalogue owner, I want the active recommendation and pack APIs to fail closed on review status so that a new Quick Spark prompt cannot become customer-visible before editorial approval.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 0, safety prerequisite.",
|
||||
"Deployment classification: API/data contract work, no OTA path by itself.",
|
||||
"Trace the active standalone API recommendation and pack endpoints, not only legacy Supabase migrations, and document the effective review-status behavior before changing it.",
|
||||
"If enforcement is absent, implement the smallest server-side, migration-backed eligibility contract with admin authoring parity and preserve current allowed content availability.",
|
||||
"Ensure a stale mobile client cannot retrieve or answer an unreviewed or archived developer-authored question through any recommendation, pack-detail, or answer route.",
|
||||
"Example: an allowed question in an enabled pack remains selectable for a paired couple.",
|
||||
"Negative case: an unreviewed question is returned by neither a direct pack request nor a recommendation, even when a stale client sends its id."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-003",
|
||||
"title": "Add a Quick Spark mood entry shell",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-001", "US-002"],
|
||||
"description": "As a paired user, I want to choose a gentle mood before a short private session so that the discovery experience feels intentional rather than like an endless feed.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 1, OTA-first client foundation.",
|
||||
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||
"Add a thin route entry and focused swipe-feature components for a reviewed fixed mood taxonomy, selected locally for this prototype with no new persistence contract.",
|
||||
"Show paired, loading, no-pack, unpaired, and retry states using existing truthful states; do not promise mutual outcomes to an unpaired user whose answers remain sealed.",
|
||||
"Provide stable accessibility labels or testIDs for mood choices and continue action.",
|
||||
"Example: a paired user chooses 'playful' and continues into the existing question flow with a clear short-session explanation.",
|
||||
"Negative case: an unpaired user cannot be shown copy implying that their private answer will match with a partner."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-004",
|
||||
"title": "Present a finite Quick Spark card sequence",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-003"],
|
||||
"description": "As a user in Quick Spark, I want a finite 5-8 prompt session with honest progress so that I can participate without open-ended pressure.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 1, OTA-first client foundation.",
|
||||
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||
"Compose the sequence only from already eligible questions returned by the current API and preserve existing answer, skip, upload, retry, and sealed-answer behavior.",
|
||||
"Render progress as completed prompts out of the actual session length and make skip available without punitive copy or a lost-progress state.",
|
||||
"Keep route files thin and add focused tests for sequence construction, progress, and early exit.",
|
||||
"Example: a session with six eligible questions shows progress from 1 of 6 through 6 of 6 and ends in a completion state.",
|
||||
"Negative case: with fewer than five eligible questions, the UI does not pad with ineligible or repeated prompts and instead gives a truthful fallback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-005",
|
||||
"title": "Add privacy-preserving Quick Spark completion and existing-talk handoff",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-004"],
|
||||
"description": "As a user who finishes a short session, I want a clear private completion state and a way to talk about an existing mutual match so that the app supports choice without inventing a new server state.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 1, OTA-first client foundation.",
|
||||
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||
"Use only existing server-calculated match data and route Talk to the existing match conversation; do not persist a new Save or shared-action state in local storage as if it were couple-shared.",
|
||||
"If no mutual result exists yet because the partner has not answered, show a neutral waiting state without exposing the current user's answer or implying partner rejection.",
|
||||
"Make completion, Talk, and dismiss controls accessible and testable.",
|
||||
"Example: both partners answer yes to an eligible swipe prompt and the completion outcome can open that existing match conversation.",
|
||||
"Negative case: one partner answers yes and the other has not answered, so no prompt text, answer, or individual interest is disclosed."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-006",
|
||||
"title": "Create the per-session private comfort schema and API contract",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-001", "US-002"],
|
||||
"description": "As a couple, I want each partner to privately choose a comfort level that produces a shared ceiling so that selection is bounded without exposing who chose the lower level.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 2, server foundation.",
|
||||
"Deployment classification: database migration and API deployment required; no OTA path by itself.",
|
||||
"Add a committed Drizzle migration and a standalone API contract for a couple-scoped session and one private selection per participant, with idempotency and explicit session lifecycle validation.",
|
||||
"Calculate and return only a shared ceiling suitable for the caller. Do not return either participant's private selection to the other participant or analytics payloads.",
|
||||
"Enforce authentication, couple isolation, participant membership, duplicate-submission handling, and non-disclosing errors for foreign session ids.",
|
||||
"Example: selections 2 and 4 yield a shared ceiling of 2 while each partner can only retrieve the shared result.",
|
||||
"Negative case: a user outside the couple cannot read, set, or infer any session or individual selection."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-007",
|
||||
"title": "Apply shared comfort selection to server-side Quick Spark composition",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-006"],
|
||||
"description": "As a session participant, I want the prompt set selected under our shared ceiling so that the content respects both private choices.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 2, server foundation.",
|
||||
"Deployment classification: API deployment required; a later compatible mobile consumer can be OTA-delivered only after US-001 platform gates pass.",
|
||||
"Add a couple-scoped session recommendation endpoint or extend a documented endpoint so the API filters reviewed, enabled, unanswered prompts by the calculated session ceiling.",
|
||||
"Preserve existing recommendation behavior for non-session clients and document any backward-compatible response fields before rollout.",
|
||||
"Ensure response ordering does not leak an excluded higher-intensity prompt or either participant's private selection.",
|
||||
"Example: a shared ceiling of 2 returns only eligible intensity 1-2 reviewed prompts from enabled inventory.",
|
||||
"Negative case: an intensity 3 prompt is not returned or inferable when one participant privately selected 2."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-008",
|
||||
"title": "Wire private comfort selection and server-backed Quick Spark into mobile",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-004", "US-006", "US-007"],
|
||||
"description": "As a paired user, I want to select my comfort privately and receive only the shared session outcome so that the client enforces the same privacy model as the API.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 2, client integration.",
|
||||
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval and deployed API compatibility read-back; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||
"Replace prototype-only composition with the authenticated session API and handle loading, incomplete-partner, expired-session, retry, and non-disclosing failure states.",
|
||||
"Render only the caller's choice before submission and only the returned shared ceiling afterwards. Clear user-scoped local state at sign-out.",
|
||||
"Do not cache, log, or send individual comfort values to analytics beyond the server request needed to create the session.",
|
||||
"Example: a user chooses a private level, resumes later, and sees a waiting state until both participants have selected without seeing the partner's value.",
|
||||
"Negative case: a replayed response or API error cannot leave another account's session or private value visible after sign-out/sign-in."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-009",
|
||||
"title": "Create a durable shared-action state machine",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-001"],
|
||||
"description": "As a couple with a mutual match, I want a shared action record with neutral lifecycle states so that Talk, Save, Defer, softer alternative, and withdrawal are truthful and do not overload the existing dare model.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 3, server foundation.",
|
||||
"Deployment classification: database migration and API deployment required; no OTA path by itself.",
|
||||
"Define and implement a committed migration, server-side state machine, actor permissions, expiry policy, and match linkage for shared actions independently of sent_dares.",
|
||||
"Support the approved vocabulary for save, defer, decline, softer alternative, withdrawal, and completion only where their semantics are explicit; ensure a participant can withdraw without exposing a private reason.",
|
||||
"Return generic, non-attributing partner-visible state copy and use generic push previews if notifications are later introduced.",
|
||||
"Example: either member saves a mutual match for later and both can see the neutral saved state.",
|
||||
"Negative case: a sender-to-recipient dare transition cannot be used to imply that a shared action was accepted, completed, or declined."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-010",
|
||||
"title": "Expose couple-isolated shared-action API routes and tests",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-009"],
|
||||
"description": "As a mobile client, I want a documented HTTP contract for shared actions so that the UI can offer optional choices without relying on client-only truth.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 3, server foundation.",
|
||||
"Deployment classification: API deployment required; no OTA path by itself.",
|
||||
"Provide versioned read and mutation routes with schema validation, idempotency where retries are possible, non-disclosing cross-couple failures, and backward-compatible match responses.",
|
||||
"Test every legal and illegal state transition, both partner roles, withdrawn and expired behavior, and match ownership.",
|
||||
"Document the participant-visible contract and any interaction-type or match-semantics change in the maintained documentation in the same implementation.",
|
||||
"Example: either partner can defer a saved shared action and the API returns a neutral deferred representation to both.",
|
||||
"Negative case: a caller cannot mutate, list, or learn the existence of an action outside their couple."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-011",
|
||||
"title": "Add optional shared-action choices to mutual match outcomes",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-005", "US-010"],
|
||||
"description": "As a couple viewing a mutual match, I want equal, optional Talk, Save, and shared-action choices so that mutual discovery becomes a real-world possibility without pressure.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 3, client integration.",
|
||||
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval and deployed API compatibility read-back; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||
"Render shared-action UI only for server-confirmed mutual matches and use the shared-action API as the source of truth for save, defer, softer alternative, withdrawal, and completion state.",
|
||||
"Keep Talk routed to existing match chat, keep existing Dares visually and semantically distinct, and make all action choices optional and dismissible.",
|
||||
"Include accessible labels/testIDs and focused tests for loading, retry, stale response, and each safe state.",
|
||||
"Example: a mutual match can be saved, then later shown as saved to both partners without exposing who initiated it.",
|
||||
"Negative case: an unmatched question, a sealed solo answer, or a stale local match cannot create or display a shared action."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-012",
|
||||
"title": "Add a reviewed Tell Me Privately session variant",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-002", "US-008"],
|
||||
"description": "As a couple, I want a text or audio-led private response option with a both-partner reveal so that we can share a thought without an explicit content catalogue.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 4, controlled content expansion.",
|
||||
"Deployment classification: question-type changes require shared contract, API, admin, database, and mobile deployment; it is not OTA-only. A client renderer after the deployed contract may be an OTA candidate subject to US-001 gates.",
|
||||
"First determine whether existing text_answer and audio types can meet the approved experience without changing payload or match semantics; prefer configuration and reviewed content if they can.",
|
||||
"If a new interaction type, payload, configuration, or match semantic is necessary, change the shared union, committed database constraint, API, admin authoring, mobile renderer, docs/question-types.md, and harness contract together.",
|
||||
"Ensure both-partner reveal preserves existing response-summary privacy and does not put user-authored text or audio in analytics or push previews.",
|
||||
"Example: two partners submit allowed text answers and see the existing both_answered match representation with a Talk option.",
|
||||
"Negative case: one partner's private text or audio cannot be revealed, notified, or analytically recorded before the other required response exists."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-013",
|
||||
"title": "Create privacy-safe measurement and release decision review",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-008", "US-011", "US-012"],
|
||||
"description": "As a product and safety owner, I want a reviewable aggregate measurement plan and staged release decision so that adoption never outruns consent, safety, or platform compliance.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 5, controlled rollout readiness.",
|
||||
"Deployment classification: analytics/API/client work plus release operations; OTA eligibility is evaluated per final client diff and does not authorize publishing.",
|
||||
"Define a typed event schema containing only aggregate category and lifecycle fields, with a test that rejects prompt text, answer text, media paths, private comfort values, and intimate match detail.",
|
||||
"Set pre-agreed stop and review thresholds for reports, blocks, skips, defers, declines, withdrawals, and error rates; do not frame safety signals as conversion loss to suppress.",
|
||||
"Require production read-back of deployed API compatibility, content review evidence, UGC moderation and incident controls, and platform-specific OTA canary before a separately authorized staged release.",
|
||||
"Example: an aggregate session_completed event records mood category and completion state but no prompt or response content.",
|
||||
"Negative case: a telemetry payload containing a response text field, media path, or individual comfort value fails validation and is not sent."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
@@ -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 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
|
||||
- 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
|
||||
|
||||
@@ -28,26 +29,28 @@ Generate a complete question pack following all Sauci content guidelines.
|
||||
Extract from $ARGUMENTS:
|
||||
- **Pack name**: Required - the name for this pack
|
||||
- **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
|
||||
- **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
|
||||
|
||||
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 intensity levels are covered?
|
||||
- Where does this new pack fit in the progression?
|
||||
- What question types are represented?
|
||||
- Where does this new pack fit?
|
||||
|
||||
2. Check if category is explicit:
|
||||
```sql
|
||||
SELECT id, name, is_explicit FROM categories WHERE name ILIKE '%<category>%';
|
||||
```
|
||||
|
||||
3. If new category, establish the progression plan:
|
||||
- What will the beginner pack cover?
|
||||
- What will intermediate cover?
|
||||
- What will advanced cover?
|
||||
3. Check the current type distribution if adding to an existing pack:
|
||||
```sql
|
||||
SELECT question_type, count(*) FROM questions
|
||||
WHERE pack_id = '[pack_id]' AND deleted_at IS NULL
|
||||
GROUP BY question_type ORDER BY count DESC;
|
||||
```
|
||||
|
||||
### Step 3: Generate Questions
|
||||
|
||||
@@ -74,9 +77,73 @@ Follow these rules strictly:
|
||||
**For NON-EXPLICIT categories (is_explicit = false):**
|
||||
- CRITICAL: No sexual acts, crude language, or NSFW content
|
||||
- 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)
|
||||
- **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)
|
||||
- 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):
|
||||
@@ -114,12 +183,9 @@ This ensures:
|
||||
2. The database tracks which questions are inverses of each other
|
||||
3. Accurate unique question counts (200 questions = 100 unique concepts with inverses)
|
||||
|
||||
**Database Schema:**
|
||||
- `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.7 Intensity Guidelines
|
||||
|
||||
#### 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 |
|
||||
|-------|------|-------------|----------|
|
||||
@@ -129,7 +195,7 @@ This ensures:
|
||||
| 4 | Steamy | Explicit sex, moderate adventure | intercourse, light bondage, anal play |
|
||||
| 5 | Intense | Advanced/BDSM/extreme | impact play, power dynamics, taboo kinks |
|
||||
|
||||
#### 3.6 Targeting Rules
|
||||
#### 3.8 Targeting Rules
|
||||
|
||||
**Couple Targeting (allowed_couple_genders):**
|
||||
- DEFAULT: null (ALL couples) unless explicit anatomical requirement
|
||||
@@ -140,19 +206,13 @@ This ensures:
|
||||
|
||||
**Initiator Targeting (target_user_genders):**
|
||||
- DEFAULT: null (anyone can initiate)
|
||||
- Only for asymmetric questions (those with partner_text)
|
||||
- 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
|
||||
- Only for asymmetric swipe questions (those with partner_text)
|
||||
- Set based on who does the action in "text" field
|
||||
- When in doubt, use null
|
||||
|
||||
**Anatomical Consistency:**
|
||||
- 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
|
||||
**Note:** who_likely, audio, and photo questions are always NULL for both targeting fields (they're symmetric and gender-neutral).
|
||||
|
||||
#### 3.7 Required Props
|
||||
#### 3.9 Required Props
|
||||
|
||||
Identify physical items/accessories needed:
|
||||
- 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:**
|
||||
- [ ] 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 time-specific words
|
||||
- [ ] 5-12 words (concise)
|
||||
- [ ] No cheesy/cliche language
|
||||
- [ ] 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:**
|
||||
- [ ] 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 frames responses as "allowing" not forced
|
||||
- [ ] No actual duplicates (same `text` appearing twice)
|
||||
@@ -190,7 +257,7 @@ Before outputting, verify each question against this checklist:
|
||||
**Consistency:**
|
||||
- [ ] No mixed anatomy in alternatives
|
||||
- [ ] Tone matches explicit/non-explicit category
|
||||
- [ ] Fits progression within category
|
||||
- [ ] Fits within the category
|
||||
|
||||
### Step 5: Output
|
||||
|
||||
@@ -207,55 +274,56 @@ VALUES (
|
||||
)
|
||||
RETURNING id;
|
||||
|
||||
-- Then insert questions with inverse_of linking
|
||||
-- For inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
|
||||
-- Then insert questions
|
||||
-- For swipe inverse pairs: insert primary first (inverse_of = NULL), then inverse pointing to primary
|
||||
|
||||
-- Generate UUIDs for inverse pairs upfront
|
||||
-- Pair 1: Primary and its inverse
|
||||
-- Swipe pair example
|
||||
WITH pair1_primary AS (
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, inverse_of)
|
||||
VALUES ('[pack_id]', 'Spank your partner', 'Be spanked by your partner', 3, NULL)
|
||||
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, 'swipe', '{}', NULL)
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, inverse_of)
|
||||
SELECT '[pack_id]', 'Be spanked by your partner', 'Spank your partner', 3, id
|
||||
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, 'swipe', '{}', id
|
||||
FROM pair1_primary;
|
||||
|
||||
-- Symmetric questions (no inverse_of needed)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, inverse_of) VALUES
|
||||
('[pack_id]', 'Symmetric question text', NULL, 2, NULL, NULL, NULL, NULL);
|
||||
-- Symmetric swipe (no inverse needed)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
|
||||
('[pack_id]', 'Cook dinner together', NULL, 1, 'swipe', '{}', NULL);
|
||||
|
||||
-- Or bulk insert with explicit UUIDs for pairs:
|
||||
INSERT INTO questions (id, pack_id, text, partner_text, intensity, inverse_of) VALUES
|
||||
-- Pair 1
|
||||
('uuid-primary-1', '[pack_id]', 'Give your partner a massage', 'Receive a massage from your partner', 2, NULL),
|
||||
('uuid-inverse-1', '[pack_id]', 'Receive a massage from your partner', 'Give your partner a massage', 2, 'uuid-primary-1'),
|
||||
-- Pair 2
|
||||
('uuid-primary-2', '[pack_id]', 'Blindfold your partner', 'Be blindfolded by your partner', 3, NULL),
|
||||
('uuid-inverse-2', '[pack_id]', 'Be blindfolded by your partner', 'Blindfold your partner', 3, 'uuid-primary-2'),
|
||||
-- Symmetric (no pair)
|
||||
(gen_random_uuid(), '[pack_id]', 'Cook dinner together', NULL, 1, NULL);
|
||||
-- who_likely questions (always symmetric, no inverse)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
|
||||
('[pack_id]', 'Who is more likely to plan a surprise date?', NULL, 1, 'who_likely', '{}', NULL),
|
||||
('[pack_id]', 'Who is more likely to cry during a movie?', NULL, 1, 'who_likely', '{}', NULL);
|
||||
|
||||
-- audio questions (always symmetric, no inverse)
|
||||
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
|
||||
('[pack_id]', 'Describe your favourite memory together', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL),
|
||||
('[pack_id]', 'Record your best impression of your partner', NULL, 1, 'audio', '{"max_duration_seconds": 60}', 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:
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total questions | X |
|
||||
| Unique question concepts | X (total - inverse count) |
|
||||
| Intensity 1 | X |
|
||||
| Intensity 2 | X |
|
||||
| Intensity 3 | X |
|
||||
| Intensity 4 | X |
|
||||
| Intensity 5 | X |
|
||||
| Swipe questions | X |
|
||||
| who_likely questions | X |
|
||||
| audio questions | X |
|
||||
| photo questions | X |
|
||||
| text_answer questions | 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 initiator targeting | X |
|
||||
| With required props | X |
|
||||
@@ -263,21 +331,24 @@ Also provide a summary table:
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/generate-pack "Testing the Waters" --category="Public Thrills" --intensity=2-3 --count=30
|
||||
/generate-pack "Kink Discovery" --category="The Kink Lab" --intensity=3-5 --count=50
|
||||
/generate-pack "Staying Close" --category=new:"Long Distance" --intensity=1 --count=30
|
||||
/generate-pack "Testing the Waters" --category="Public Thrills" --count=30
|
||||
/generate-pack "Kink Discovery" --category="The Kink Lab" --count=50 --premium
|
||||
/generate-pack "Staying Close" --category=new:"Long Distance" --count=30
|
||||
/generate-pack "Date Night" --category="Quality Time" --types=who_likely,audio,photo
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- DO ask clarifying questions if the pack concept is unclear
|
||||
- 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 use the inverse_of column to link inverse pairs (inverse points to primary's UUID)
|
||||
- DON'T use cheesy or cliche language
|
||||
- DON'T assume heterosexual couples
|
||||
- DON'T skip the inverse question requirement
|
||||
- DON'T forget to set inverse_of - this is how we track unique question concepts
|
||||
- DON'T skip the inverse question requirement for asymmetric swipe questions
|
||||
- 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 treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,227 +1,7 @@
|
||||
---
|
||||
name: ios-simulator-skill
|
||||
version: 1.3.0
|
||||
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.
|
||||
description: Build, launch, and verify Sauci on an iOS simulator with local backend wiring and evidence.
|
||||
---
|
||||
|
||||
# iOS Simulator Skill
|
||||
|
||||
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.
|
||||
Read `.codex/skills/mobile-simulator/SKILL.md` completely and follow it. For this
|
||||
invocation, select the iOS path.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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"
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
+1402
-1199
File diff suppressed because it is too large.
Load diff
@@ -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(() => {});
|
||||
Executable
+24
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
@@ -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}`);
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -1,14 +1,49 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
.next
|
||||
**/.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*
|
||||
.DS_Store
|
||||
dist
|
||||
**/dist
|
||||
.expo
|
||||
android
|
||||
ios
|
||||
**/.expo
|
||||
**/android
|
||||
**/ios
|
||||
*.log
|
||||
.turbo
|
||||
**/.turbo
|
||||
coverage
|
||||
.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
|
||||
@@ -1,154 +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
|
||||
|
||||
env:
|
||||
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
||||
SUPABASE_PROJECT_REF: ckjcrkjpmhqhiucifukx
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Supabase CLI
|
||||
uses: supabase/setup-cli@v1
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Link to Production Project
|
||||
run: supabase link --project-ref $SUPABASE_PROJECT_REF --workdir apps
|
||||
|
||||
- name: Apply Database Migrations
|
||||
run: supabase db push --workdir apps
|
||||
|
||||
- name: Sync Edge Function Secrets
|
||||
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..."
|
||||
supabase secrets set $secrets_to_set --project-ref $SUPABASE_PROJECT_REF
|
||||
else
|
||||
echo "No secrets to sync"
|
||||
fi
|
||||
|
||||
- name: Deploy Edge Functions
|
||||
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"
|
||||
supabase functions deploy "$function_name" --no-verify-jwt --project-ref $SUPABASE_PROJECT_REF --workdir apps
|
||||
done
|
||||
|
||||
- name: Remove Deleted Functions
|
||||
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: $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
|
||||
@@ -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
|
||||
+13
-1
@@ -17,12 +17,13 @@ build/
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
apps/mobile/.env.prod
|
||||
apps/mobile/.env.*
|
||||
.secrets
|
||||
|
||||
|
||||
# Supabase
|
||||
.supabase/
|
||||
supabase/.temp/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -50,9 +51,15 @@ pnpm-debug.log*
|
||||
coverage/
|
||||
.nyc_output/
|
||||
*.lcov
|
||||
evidence/
|
||||
e2e/.auth/
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
.playwright-mcp/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
!apps/mobile/certs/certificate.pem
|
||||
.cache/
|
||||
.temp/
|
||||
.tmp/
|
||||
@@ -61,3 +68,8 @@ nul
|
||||
|
||||
# Opencode
|
||||
.opencode/
|
||||
|
||||
# Agent runtime evidence (maintained lessons remain tracked)
|
||||
.ralph/activity.log
|
||||
.ralph/runs/
|
||||
.migration-state/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
---
|
||||
@@ -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.
|
||||
Loaded 100 of 932 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user