tlon-workflow
Skill109 starsChanged 11 days agoMITView on GitHub
Use when taking a Tlon Messenger task from a fresh worktree to a merged pull request: reproducing or fixing something in the iOS, Android or web app, validating it on a simulator, emulator or browser, opening the PR with evidence, and following its review.
- Reads credentials
- Commits and pushes
In this repository5 other files it gives its agents.
- AGENTS.mdAGENTS.md
- desk/AGENTS.mdAGENTS.md
- pr-review-loopSkill
- tlon-pr-triageSkill
- tlon-workflow-doctorSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
16 rules. About 0 are common, 5 are distinctive, and 11 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
11
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- could be automatic: a setting could enforce it
- Very specific: line 24
If any line says fix, use the tlon-workflow-doctor skill and come back.
- Very specific: line 64
Every stim ios and stim android in this skill takes --remote eas.
- Very specific: line 96
Every line needs APP_VARIANT=preview: the Gradle variant alone leaves the app configured as production.
- Very specific: line 98
Use stim logs --errors, not --since 5m --level error: it filters the hiddenapi ...
- Distinctive: line 189
When unsure, more platforms rather than fewer.
- Very specific: line 213
Read references/driving-the-app.md before the first capture on a device: recording quality, which commands resolve which targets, the chat …
- Very specific: line 231
Before running packages/shared tests, npm rebuild better-sqlite3 from the worktree root.
- Distinctive: line 233
Commit as you go: steps 7 and 8 read the branch, not the working tree.
- Very specific: line 241
Keep stim logs --since windows short.
- Very specific: line 253
Read pr-description.md in this skill's directory, then fill .github/pull_request_template.md section by section, and run the fresh-eyes …
- Very specific: line 304
Follow the pr-review-loop skill: it runs pr-watch.mjs round by round, triages and answers every finding, and hands the pull request to a …
- Distinctive: line 309
If a fix changes visible behavior, re-capture the evidence from step 4 before pushing, and update the pull request's evidence tables.
- Distinctive: line 323
When the pull request is merged or closed, go to step 10.
- Very specific: line 327
After the pull request is merged or closed, and after asking the user.
- Distinctive: line 329
Delete the throwaway group on the ship first, while the app is still up and signed in.
- Very specific: line 342
Never --force: it discards uncommitted and untracked files permanently.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Tlon workflow
One task, one worktree, one pull request, across three platforms: iOS, Android and web. The iOS and Android devices are EAS Simulator sessions, not simulators or emulators on this machine. Read Stim's guide once per session:
stim guide agentEverything below is written from the repository root. apps/tlon-mobile is the app directory Stim wants, so every stim command runs from there -- including the ones for web and Cosmos ports. Give this skill's scripts an absolute path.
Before anything
Unsandboxed (sandboxed, gh auth status cannot reach the keyring and reports a false not authenticated):
node .agents/skills/tlon-workflow-doctor/check.mjsIf any line says fix, use the tlon-workflow-doctor skill and come back. Do not work around a missing tool.
The loop
1. A fresh worktree
First, what already exists for this ticket: gh pr list --search <ticket id> --state all and the links on the ticket itself. A closed pull request from an earlier run: note its branch name so yours differs, read its diagnosis only after you have reached your own, verify what you take from it, and do not cite it in your description.
git fetch --prune origin
git ls-remote --heads origin <handle>/<topic> # empty, or pick another name
git worktree add -b <handle>/<topic> .worktrees/<name> origin/develop
cd .worktrees/<name>/apps/tlon-mobile
stim worktree warm --refreshThe default branch is develop; every branch starts there and every PR targets it. Branches are named <handle>/<topic>; take the handle from the existing branches (git branch -r | grep -o '^ origin/[^/]*/' | sort | uniq -c), not from your GitHub login. A name that already exists on origin cannot be pushed without force; pick another. .worktrees/ is gitignored at any depth.
git worktree add needs an unsandboxed shell. Sandboxed it half-fails: no worktree, but the branch is created, so the retry stops with a branch named '<...>' already exists. Delete the branch before retrying.
warm carries node_modules, ios/Pods, both .env.local files, and .claude/, so the credentials in steps 2 and 3 are set once in the source checkout. Keep the source checkout clean and on develop; --refresh refuses a dirty or detached one and prints the git line that clears it. Clear it rather than dropping --refresh.
Without Xcode (step 2) there is no CocoaPods, and --refresh runs pod install in the source checkout whenever ios/Pods/Manifest.lock is missing, so it fails with STIM_DEPS_FAILED. Do its other two jobs by hand, then warm without it; a plain warm copies what exists and only warns about Pods it actually carried:
git -C <source checkout> pull --ff-only && pnpm -C <source checkout> install
stim worktree warm2. Run the app
Stim builds the app on this machine (on iOS without Xcode, downloads it; see below), installs it on an EAS Simulator session, and serves it JavaScript from this worktree's Metro through an Expo tunnel (metro.tunnel in apps/tlon-mobile/.stim.json). Nothing boots locally.
eas sim:availability # "available" for the tlon account
stim start --remote
stim ios --remote eas && node <worktree>/.agents/skills/tlon-workflow/eas-device.mjs keepalive
stim logs --errors # exit 0 and "No matching log records" on stderr is the passEvery stim ios and stim android in this skill takes --remote eas; without it Stim boots a local simulator or emulator. Plain stim start has no tunnel, and a running one cannot gain it (STIM_REMOTE_START_REQUIRED): stim stop, then stim start --remote.
One platform at a time. A worktree holds one EAS session, so stim android --remote eas beside a live iOS session refuses with STIM_REMOTE_PLATFORM_MISMATCH. Finish a platform, stim stop, then stim start --remote and the other one. Step 4 orders the captures to fit.
A session bills from creation until it stops, including while the local build runs and while you are thinking. Stim creates it before building, so a fingerprint miss (fingerprint ... miss -- 1 source changed: ...) compiles on the clock; apps/tlon-mobile/.gitignore is one of those sources. stim stop ends it (step 10) as soon as the platform's captures are done; do not leave one open across the review.
Local work over five minutes loses the device. The EAS device's agent-device daemon exits five minutes after its last request while no agent-device session is open, and Stim sends it nothing between connecting and installing (appandflow/stim#1212). A fingerprint miss is enough, and so is a pod install on a cache hit: the install fails with Remote daemon is unavailable, and a rerun fails the same way. stim stop, stim start --remote, then this step again; the build is cached by then, so the new session installs within a couple of minutes.
From the output keep the session ID (device EAS Simulator (<id>), or udid under --json) and the Watch this device: <url> line. Give the URL to the user: it is the only way to see the device. It carries a token, so it never goes in a pull request, ticket or comment.
For Android, the same line with stim android --remote eas. Keep the && ... keepalive on every run, backgrounded or not: it has to start the moment Stim returns (see below).
A cold stim ios outlasts most tool timeouts: run it in the background or with the longest timeout you have, and rerun the same command if a call times out. A rerun reuses the session. So does stim ios --remote eas after a native change.
iOS without Xcode (Linux, or a Mac where xcodebuild -version fails; the doctor's xcode line says which): the app cannot build here, so add --eas-profile development-simulator to every stim ios in this skill. Stim then downloads the EAS development build whose native fingerprint matches this worktree, before it creates the session, and installs that; JavaScript still comes from Metro and Fast Refresh works as above. With Xcode, leave the flag off and build locally.
stim ios --remote eas --eas-profile development-simulator && node <worktree>/.agents/skills/tlon-workflow/eas-device.mjs keepaliveWhen no build matches, Stim stops with STIM_EAS_BUILD_MISSING before any session exists and prints the build command, npx eas-cli build --platform ios --profile development-simulator. That is a cloud build of about 25 minutes, billed to the tlon account: ask the user before running it, then run the same stim ios line again. Builds are shared across the account, so a branch that changes no native input reuses the build for its base once anyone has made it; a branch that does needs a new build after each native change. --eas-profile cannot be combined with --scheme, so the preview flavor below has no cloud-build path.
Android on EAS Simulator is marked in development by eas-cli, and this workflow has not been run against it: expect gaps and report them. The APK still builds here, so it needs the Android SDK. If Gradle fails with java.lang.OutOfMemoryError: Java heap space, the knob is org.gradle.jvmargs=-Xmx2048m in android/gradle.properties.
Android defaults to productionDebug (io.tlon.groups), committed as android.variant in apps/tlon-mobile/.stim.json, so plain stim android --remote eas is right. For the preview flavor (io.tlon.groups.preview, which you then pass to --app and open):
APP_VARIANT=preview stim start --remote
APP_VARIANT=preview stim ios --remote eas --scheme Landscape-preview
APP_VARIANT=preview stim android --remote eas --variant previewDebugEvery line needs APP_VARIANT=preview: the Gradle variant alone leaves the app configured as production. The two device lines take the same && ... keepalive as above.
Use stim logs --errors, not --since 5m --level error: it filters the hiddenapi ... AccessibilityNodeInfo noise agent-device's snapshots generate on Android.
ready describes the process, not the screen: allow roughly another minute for the first screen.
Drive the device through eas-device.mjs. It is agent-device with this session's token and agent-device session added, so every agent-device command in this skill and its references goes through it:
node <worktree>/.agents/skills/tlon-workflow/eas-device.mjs snapshot -i
node <worktree>/.agents/skills/tlon-workflow/eas-device.mjs press 'text="Next"' --settleBare agent-device fails with requires daemon authentication: Stim keeps the connection but not the token.
Nothing may leave the device idle for a minute. An EAS device's lease lapses after about a minute without a command, and the next command then takes a new lease that the session refuses: from then on every call fails with UNAUTHORIZED: Lease does not match session owner (leaseId), and nothing re-attaches, including stim ios --remote eas. A pause to think is enough to lose it. eas-device.mjs keeps a detached process pinging the device every 15 seconds for as long as Stim records the session: keepalive starts it, every other call restarts it if it died, and it stops by itself after stim stop. It cannot save a device that was already idle for a minute before it started, which is why it is chained onto stim ios. Pings that fail are logged to agent-device.remote.keepalive.log in the Stim workspace directory (~/.stim/workspaces/<name>/); a failure or two during a stim ios rerun or a long request is expected. If the session is lost anyway, stim stop, then this step and the sign-in again.
Stim names each worktree's session after it (stim-<worktree>-tlon-mobile-<id>), and agent-device keeps one connection per name, so worktrees can run on EAS side by side. Start them one at a time: Stim boots one EAS device at a time on the machine, whichever agent's worktree it belongs to, and a stim ios --remote eas that waits more than four minutes for another boot gives up, then still runs its whole build before reporting it (appandflow/stim#1213). Wait for one worktree's Watch this device line before starting the next. eas-device.mjs drives the session of the worktree it sits in, whatever directory it runs from: call it by this worktree's absolute path, never another's.
Web is a Vite server, no build. Take its port from stim:
cd <worktree>
pnpm --filter tlon-web exec vite \
--port "$(cd <worktree>/apps/tlon-mobile && stim ports get web)" --strictPortThe subshell in apps/tlon-mobile matters: a port taken from anywhere else is one stim ports stop never visits, so the server outlives the run. Without --strictPort Vite moves to the next free port and serves you another worktree. The app is at http://localhost:<port>/apps/groups/. It needs apps/tlon-web/.env.local with VITE_SHIP_URL naming the ship the dev server proxies to (the same self-hosted dev ship as step 3); VITE_DISABLE_SPLASH_MODAL=true there skips the wayfinding modal on a fresh profile.
Cosmos renders a component in a chosen state without driving the app to it:
cd <worktree>/apps/tlon-web
npx cosmos --port "$(cd <worktree>/apps/tlon-mobile && stim ports get cosmos)"--port is not in cosmos --help but works. Cosmos needs packages/editor/dist: run pnpm run build:packages if it is missing. Fixtures live in packages/app/fixtures, listed by file and named export, so ChatMessage.fixture.tsx appears as ChatMessage / MessageStates.
stim ports lists this worktree's labels and numbers, Metro included: open those and nothing else. Another port is another worktree's, and its page renders and its fixtures load with someone else's code. If you must open a port stim did not hand you, lsof -a -p "$(lsof -nP -iTCP:<port> -sTCP:LISTEN -t | head -1)" -d cwd -Fn prints the worktree being served.
3. Sign in
Most reproductions need a signed-in app. Put a self-hosted dev ship's URL and +code in apps/tlon-mobile/.env.local of the source checkout (gitignored; reaches every worktree through warm):
DEFAULT_SHIP_LOGIN_URL=https://your-ship.tlon.network
DEFAULT_SHIP_LOGIN_ACCESS_CODE=xxxxxx-xxxxxx-xxxxxx-xxxxxxThey are read by app.config.ts, which the Metro that stim start --remote launched serves to the app, and warm copies the file only when the worktree has none. Set them before step 1. If you are setting them now, copy the file into this worktree's apps/tlon-mobile/ as well, then stim stop and step 2 again: a running Metro keeps the environment it started with.
Sign in with the script, once per EAS session (a new session is a fresh device). It opens the app under the agent-device session Stim connected, runs the sequence, and leaves it open for the rest of the run:
node <worktree>/.agents/skills/tlon-workflow/mobile-login.mjs --platform ios --eas
node <worktree>/.agents/skills/tlon-workflow/mobile-login.mjs --platform android --easIt prints signed in, or already signed in when Home is already up. On failure it says which step failed and leaves the session open to snapshot. Empty login fields mean Metro started without the variables above.
The agent-device session is always the one Stim named for this worktree (step 2): a name of your own does not reach the remote device, so there is no session to name or close per run. The script's --udid / --serial form is for a simulator on this machine; hosted QA uses it on its own Mac worker.
What this app does that the sequence above does not show:
- Both prompts come back after every full reload, not only the first launch.
- A "Stay in the loop" sheet appears later over Home on iOS and Android and covers the bottom of the list:
press 'text="Not now"'. - On Android the notifications prompt can arrive after
alert dismisshas already returned;wait 3000before it, orscreenshotand dismiss what is there. - On Android this app's screens collapse into a few group nodes, so
findmatches nothing; atext="..."selector still resolves. - iOS shows a keyboard tip ("Speed up your typing...",
Continue) on the first text entry, which swallows the next tap. Only reached when the fields are not prefilled.
Web has no prefill, and the login page is the ship's own. Run the script, with the worktree path (from apps/tlon-mobile or apps/tlon-web the relative path does not resolve, and from the source checkout it writes the session outside your worktree):
node <worktree>/.agents/skills/tlon-workflow/web-login.mjs --url http://localhost:<port>It takes the +code from apps/tlon-mobile/.env.local, signs in, and prints the path of a Playwright storageState file (.evidence/web-auth.json unless --state says otherwise). Hand that to a context -- browser.newContext({ storageState }) -- and it starts signed in, for the rest of the run. Signing in by hand instead, the selectors are in apps/tlon-web/e2e/auth.setup.ts, with one difference: a ship offering eauth renders a second form with its own Continue, so scope the click to the form holding the password field.
The script fails loudly when the ship's cookie does not survive: the ship marks urbauth- as Secure, so on plain http the app returns to the login page however many times you sign in. Serve over https (SSL=true) for those.
This yields an authType: 'self' session; it does not exercise the hosting-account flows (node status, revival, bot config). DEFAULT_TLON_LOGIN_EMAIL and DEFAULT_TLON_LOGIN_PASSWORD prefill the hosted path the same way. With neither pair set, the phone and email paths send a 2FA code an unattended run cannot read: ask rather than attempting them.
4. Capture the current behavior
For a bug or a change to existing behavior, record what the app does now, before touching code. A screen recording is the default; a screenshot only when the state is static and one frame shows it.
Which platforms. Three exist: iOS, Android and web (which the desktop app wraps). Decide before touching code: once the fix is in, a "before" on a platform you skipped takes the file swap at the end of this step.
- One platform, the one the ticket names, when the change is logic only, or UI built from components that behave the same everywhere (
View,Text, layout, styling). No named platform: iOS. - Both iOS and Android, before and after, when the change touches anything with native quirks:
TextInput,Switch,ScrollViewand list behavior, keyboard, gestures, the WebView editor, permissions, notifications, a native module,Platform.select, or a.ios.tsx/.android.tsxfile; or when the ticket reports a symptom on one platform only. - Web as well, whenever the change is under
packages/app,packages/uiorpackages/sharedand is layout or shared-component behavior: those ship to web and desktop too, and the desktop navigation is a different tree from the mobile one. A change confined toapps/tlon-mobile, or to a.ios.tsx/.android.tsxfile, does not reach web. - Cosmos as well as web, not instead of it, for any UI change to a component that has a fixture in
packages/app/fixtures. Grep the fixtures for the component before assuming none exists. See step 6.
When unsure, more platforms rather than fewer.
With one EAS session at a time (step 2), take both native platforms in turn rather than side by side: all of iOS (before, fix, after), stim stop, then Android, capturing its "before" with the file swap at the end of this step and its "after" on the branch.
Record the behavior, not the journey: navigate to the screen first, start recording, do the one action that triggers it, stop once the result is on screen. Under 30 seconds. Prove the repro first, then record it.
node <worktree>/.agents/skills/tlon-workflow/eas-device.mjs record start <worktree>/.evidence/before-ios.mp4 --quality high --hide-touches
node <worktree>/.agents/skills/tlon-workflow/eas-device.mjs press 'text="<label>"' --settle
node <worktree>/.agents/skills/tlon-workflow/eas-device.mjs longpress '@<ref>' --settle
node <worktree>/.agents/skills/tlon-workflow/eas-device.mjs wait 5000
node <worktree>/.agents/skills/tlon-workflow/eas-device.mjs record stop--hide-touches is not optional on EAS: with the touch overlay the remote export outlasts agent-device's 90-second request limit, and record stop fails with Daemon request timed out and no clip. Without the overlay it returns in seconds, and the clip is written to the local path you gave. Wait for the result's text, then wait 5000 before record stop: the remote recording runs about two seconds behind, so a stop right after the text produced a clip that ended before it, and 3 seconds left the result in the last frame only. The recorder also writes a frame only when the screen changes, so even a good clip ends the moment the result settles and shows it for an instant; hold it before attaching (step 8). Check that record start succeeded and, after record stop, that the file exists (on Android a second recording in the same session has produced nothing without an error) and its duration and last frame are right. Evidence goes in .evidence/ at the root of your worktree (gitignored, removed with the worktree in step 10), as an absolute path: $TMPDIR differs between sandboxed and unsandboxed shells.
EAS also records the whole session and attaches it once the session stops (eas sim:get --id <session id> --json, the screen-recording artifact). It is a backup to check a clip against, not a clip source: its timeline drifts from the wall clock, so a cut by timestamp lands on the wrong moment.
Web is driven by whatever browser automation you have (this repository sets up the Playwright MCP server, see apps/tlon-web/AGENTS.md; a browser pane works too). Same names: before-web.png for a static state, a recording for motion. Use the desktop window size a person would, not a phone-width viewport, which shows the mobile navigation you already tested.
Two cases need stills as well as the clip. A state that lasts under about a second (a delivery indicator between send and server echo): record from before the trigger, then prove it with frames (ffmpeg -ss <t> -i <clip> -frames:v 1 <png>). A difference between two discrete states (with the indicator and without, empty and populated, collapsed and expanded): a still of each. Attach the stills and the clip. For a difference that lives in one component rather than a flow, a pair of Cosmos specimens differing only in the prop under test is better still (step 6).
Reproduce in a throwaway group named after the task and the time (TLON-1234 repro 1435), not the default "Untitled group": other agents and earlier runs leave those behind, and on Android same-named groups are indistinguishable in the list.
Read references/driving-the-app.md before the first capture on a device: recording quality, which commands resolve which targets, the chat list, attachments, backgrounding, making the throwaway group.
If the steps do not reproduce as written, vary them before concluding anything: leave the channel and re-enter it, act from the other platform's client, background and foreground the app. Then check whether the fix already landed: git log -S '<suspect expression>' --oneline -- <path> on the code the ticket points at, and the merged pull requests since it was filed. If it did, check the other platform before stopping: a fix for the reported platform may have left the other one broken. If both are fixed, stop: comment on the ticket naming the pull request that fixed it and the platforms you checked (text and links; the clips stay on disk), report the same to the user, and still do step 10.
To capture a "before" after the fix is already committed (the second platform, or a reviewer asks for another case), swap the file, not the branch: git checkout origin/develop -- <path>, wait for Fast Refresh to show the old behavior (relaunch if it does not, step 5), record, then git checkout HEAD -- <path> and check that the fix is back on screen. A swapped native file needs stim ios --remote eas / stim android --remote eas instead of a relaunch.
5. Fix
The ticket's diagnosis is a lead, not the cause: confirm the mechanism in code before changing it, and say so in the pull request when the two differ. Then the smallest change that fixes it -- no refactor, no cleanup of what sits next to it.
An edit to application JavaScript or TypeScript needs no rebuild; Fast Refresh applies it through the tunnel in a couple of seconds, and stim logs --errors shows what it broke. When you need a full reload, or an edit does not show up, relaunch the app, which fetches a fresh bundle from Metro in about 10 seconds, returns it to its first screen, and keeps the sign-in:
node <worktree>/.agents/skills/tlon-workflow/eas-device.mjs open io.tlon.groups --relaunch # .preview on a preview buildstim reload refuses on a remote device (STIM_RELOAD_STOPPED), and agent-device metro reload either fails on the tunnel's socket or, pointed at local Metro, reports success without reaching the device; use the relaunch. After babel.config.js, metro.config.js, or app.config.ts changes, Metro has to restart, and stim stop also ends the session: stim stop, step 2, sign in again. After a native input changes, stim ios --remote eas or stim android --remote eas again, which keeps the session. Format with pnpm format at the repository root (oxfmt); prettier over a file rewrites it wholesale.
Before running packages/shared tests, npm rebuild better-sqlite3 from the worktree root.
Commit as you go: steps 7 and 8 read the branch, not the working tree. Never force-push, and never git stash: the stash is shared with every other worktree of this checkout.
6. Validate with the same repro
Repeat step 4 into after-<platform>.mp4 on the platform(s) you recorded before, then stim logs --errors again. Evidence is the repro you already recorded, not a new scenario. Say in the pull request which platform(s) you tested and why one was enough, when it was.
Re-snapshot first. Fast Refresh and a relaunch remount the tree with no agent-device action, so the refs stay live and a press replays the old coordinates onto whatever is there now: it reports Tapped and drives the wrong element, silently. After any edit that reaches the running app, snapshot again before touching anything. An edit under packages/ may be a full reload: navigation resets to Home and the sign-in prompts return (alert dismiss, Not now). After any packages/ edit, relaunch (step 5) before capturing, and confirm stim logs --errors is clean: an export added in one module and imported in another leaves the app throwing ReferenceError: Property '<name>' doesn't exist until the bundle is fresh. If the app dies on launch instead (on iOS, EXC_BAD_ACCESS in EXPermissionsService registerRequesters, expo/expo#45314), stim ios --remote eas reinstalls from cache in under a minute.
Keep stim logs --since windows short.
Web. Validate on it whenever step 4 called for it, on the server and Cosmos from step 2. The Cosmos UI (5555) and its renderer (5050) are different origins: screenshots and accessibility reads work, document.querySelector across the frame does not. Headless Chrome renders the Cosmos page blank however long you wait; capture it in a browser you can see.
7. Get an independent review
Before opening the pull request, put the diff in front of a fresh agent that has not seen your reasoning. Give it three things: the task as originally stated, the diff (git diff origin/develop...HEAD), and what to be adversarial about. Do not give it your notes, reasoning, or rejected alternatives, and tell it to ignore .evidence/ and the commit messages. Ask for correctness first, with concrete inputs and the resulting wrong behavior, then whether the change does what the task asked, then what is untested. Tell it the size of the diff and to spend accordingly.
Fix what is real. Push back, with reasons, on what is not: applying a finding you cannot verify is worse than ignoring it. If a fix changes visible behavior, re-capture the evidence from step 4.
8. Open the pull request
Read pr-description.md in this skill's directory, then fill .github/pull_request_template.md section by section, and run the fresh-eyes pass it describes before posting.
git push -u origin <handle>/<topic>
gh pr create --draft --base develop --title "<title>" --body-file <worktree>/.evidence/pr.md \
--attach <worktree>/.evidence/before-ios.mp4 --attach <worktree>/.evidence/after-ios.mp4 \
--attach <worktree>/.evidence/before-android.mp4 --attach <worktree>/.evidence/after-android.mp4 \
--attach '<worktree>/.evidence/before-web.png#before, web' --attach '<worktree>/.evidence/after-web.png#after, web'One --attach per recording or screenshot from steps 4 and 6, for every platform you tested. Push first: on an unpushed branch gh pr create prompts for a remote, and a prompt in an unattended shell is a hang. gh appends the uploaded URLs to the body in --attach order, and rewrites a body reference only when it matches the --attach string exactly.
Video takes no alt text. --attach '<file>#<label>' is image-only and fails with cannot set alt text on video, creating no pull request. gh also does not rewrite a body reference to a video, so  stays a broken relative link while the URLs are appended unlabeled at the end. Attach bare paths, then move the returned user-attachments URLs into the per-platform before/after tables pr-description.md describes:
gh pr view <number> --json body -q .body > <worktree>/.evidence/body.md # edit, then:
gh pr edit <number> --body-file <worktree>/.evidence/body.md
gh pr ready <number>A change with nothing to show on screen (logic, sync, a script) has no recordings: no --attach, no evidence table, and "How did I test?" says what you ran instead.
Check every clip before attaching it. ffprobe -v error -show_entries format=duration -of csv=p=0 <clip> for the length, and a frame strip to see what is in it:
ffmpeg -v error -y -i <clip> -vf "fps=1/3,scale=270:-1,tile=6x3" -frames:v 1 -update 1 <worktree>/.evidence/<clip>-strip.png # one image per clipA clip longer than about 30 seconds, or one that opens on sign-in or navigation, gets cut to the part that shows the behavior. Re-encode; -c copy lands the cut on the wrong frame (simulator recordings are variable frame rate):
ffmpeg -v error -i <clip> -ss <start> -to <end> -c:v libx264 -preset veryfast -crf 23 -an <clip>.trimmed.mp4A clip from an EAS device ends the moment its result settles (step 4). Hold the last frame for two seconds so a reviewer can read it, after any trim:
ffmpeg -v error -i <clip> -vf tpad=stop_mode=clone:stop_duration=2 -c:v libx264 -preset veryfast -crf 23 -an <clip>.held.mp4Mark it ready once the evidence is in: the Codex reviewer only reviews ready pull requests.
Optional: hosted PR QA
For an independent cloud test after the PR is ready, use hosted QA. It explores the implemented PR using reviewer guidance, a disposable backend, and one PR comment with findings and video. It does not fix, push, request reviewers, or merge. It does not require base recordings or all-platform acceptance coverage.
9. Follow the review
Follow the pr-review-loop skill: it runs
pr-watch.mjs round by round, triages and answers every finding, and hands the
pull request to a human reviewer once it is clean. Two additions for this
workflow:
- If a fix changes visible behavior, re-capture the evidence from step 4 before pushing, and update the pull request's evidence tables.
- A
qa-resultis an advisory hosted test report for itsheadSha, not a request to restart the review loop. Read it once, check that it matches the current PR commit, and address verified findings as part of the current round. Do not rerun QA merely because it posted or edited its comment. Rerun only after a relevant fix or at the user's request; incomplete platform coverage is not a code defect. If you explicitly dispatched hosted QA, pass--qa-run <EAS workflow UUID>to the watcher before the human handoff. It waits for that run on the current head within--timeout, or reports that the head changed. Without this option, it only surfaces results already published. A completed review can contain findings or unexplored paths; neither starts another run automatically.
When the pull request is merged or closed, go to step 10.
10. Clean up
After the pull request is merged or closed, and after asking the user. Order matters: remove the worktree before the branch goes, or remove refuses because its commits are no longer on any remote. And leave the worktree before removing it.
Delete the throwaway group on the ship first, while the app is still up and signed in; once the session is stopped, the device and the way in are gone.
cd <worktree>/apps/tlon-mobile
stim ports stop # kills web and Cosmos on this worktree's ports and releases them; leaves Metro alone
stim stop # ends the EAS session (look for "stopped remote session <id>") and, within 15 seconds, its keepalive
eas sim:list --status new --status in-progress # nothing of this run's left billing
cd <source checkout>/apps/tlon-mobile
stim worktree remove <source checkout>/.worktrees/<name> # the path step 1 created; then, if the branch should go too:
git branch -d <handle>/<topic>
git push origin --delete <handle>/<topic>Never --force: it discards uncommitted and untracked files permanently. If remove refuses because a commit exists nowhere else, push the branch instead.
stim stop can end with Stopped with problems over a local device record it could not shut down (emulator: command not found when the Android SDK is not on PATH); that is about this machine, not the session. The stopped remote session line is the one that matters. Without it, the remedy names eas simulator:stop --id <id> (STIM_REMOTE_SESSION_CLEANUP): run it, because a session that did not stop bills until its duration cap.
Under a sandbox
Stim, agent-device, eas, gh and xcrun simctl all need an unsandboxed shell. stim doctor --fix offers to write an allowance into .claude/settings.local.json; that is your own permission configuration, so do not change it because a tool told you to. Run the calls unsandboxed, or ask the user to apply the allowance themselves.
Sandboxed, simctl list devices returns an empty list (Operation not permitted on its CoreSimulator log, a refused connection to CoreSimulatorService). Nothing is wrong with Xcode: rerun it unsandboxed.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

