verify-and-land
Build the checked-out branch, launch that exact app, let the user confirm the fix is really there, then push and land it by the project's landing mode.
SKILL.mdskills/verify-and-land/SKILL.md
GitHubVerify, then land
The gap this closes: a fix is committed, the user opens "the app", the fix is not
there — so they conclude it did not work. Usually it did. They were looking at a
different build: the copy in /Applications that Spotlight, the Dock and
Launchpad all open, or a DerivedData product from another branch. Nothing rebuilds
when the branch changes, because nothing ever does. Measured on one real project: the
installed copy was a month older than the branch build, and four other stale build
products were registered besides.
So this skill never asks the user to open the app. It builds the branch they are standing on, launches that exact bundle, proves on screen which one is running, and only then asks whether the fix is there. A yes lands the branch the way the project has decided to land (Phase 7), without a second question unless the branch itself changes how the project lands; a no keeps the branch open and says what to look at next.
Invoke with: /superpowers-gstack:verify-and-land
Phase 0 — refuse early, and commit first
| Condition | Do this |
|---|---|
| Not a git repo | Refuse: "Not a git repository — there is no branch to verify or land." |
| No runnable app (Phase 2 finds none) | Refuse: "No app target found. This skill verifies something you can look at; for a library or CLI, run the tests instead." |
| Detached HEAD | Refuse landing later, so say now: give the work a branch first (git switch -c <name>), then re-run. |
| On the default branch | Build and launch normally, then skip Phase 7 — there is nothing to land. Say so once. |
Uncommitted changes must be committed before the build. Not a style preference:
the build compiles the working tree, but git push and every landing skill move
commits. Verify a dirty tree and land it and the user has approved something that
is not what shipped. Offer to commit — with a real message, per the repo's
convention — and only then build. If they decline, build anyway so they can look, but
Phase 7 is blocked: say plainly that landing would ship something other than what
they just saw.
Phase 1 — name the change, so the user knows what to look at
DEFAULT_REF=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/||')
[ -z "$DEFAULT_REF" ] && for c in origin/main origin/master main master; do
git rev-parse -q --verify "$c" >/dev/null && DEFAULT_REF=$c && break
done
BASE=$(git merge-base HEAD "$DEFAULT_REF")
git log --oneline "$BASE..HEAD"; git diff --stat "$BASE..HEAD"
Keep the ref that verified, not a stripped name — in a fresh clone origin/main
exists and main may not, and merge-base against a missing ref fails.
Turn the diff into one sentence per observable change, not files: "the sidebar
should keep its width when you resize the window" beats "3 commits touching
SidebarView.swift". A branch often carries more than one change — name each,
because the gate in Phase 6 asks about all of them: one yes must never quietly ship
three changes of which the user looked at one. If a change has no user-visible
effect — a refactor, a test — say so: looking is the wrong check for it; the tests
cover it and the gate covers the rest.
Phase 2 — resolve the container, the scheme, and the destination
Generate the project first if it is generated. A project.yml is itself an
Apple-project signal: many repos commit only the spec and gitignore the
.xcodeproj, so listing schemes before generating finds nothing at all. If
project.yml exists and either no .xcodeproj is present or the spec (or anything
it includes) is newer, run xcodegen generate before anything else.
Carry the container through every command. A repo holding both a .xcworkspace
and a .xcodeproj makes bare xcodebuild ambiguous, and it will pick the wrong one
or fail:
if ls *.xcworkspace >/dev/null 2>&1; then XC=(-workspace "$(ls -d *.xcworkspace | head -1)")
elif ls *.xcodeproj >/dev/null 2>&1; then XC=(-project "$(ls -d *.xcodeproj | head -1)")
else XC=(); fi
xcodebuild "${XC[@]}" -list -json
Every later xcodebuild call takes "${XC[@]}". One scheme is the answer; several,
ask once.
Split macOS from iOS by the scheme's own settings, never by guessing:
xcodebuild "${XC[@]}" -showBuildSettings -scheme "$SCHEME" -configuration Debug 2>/dev/null \
| grep -E '^ +SUPPORTED_PLATFORMS '
macosx → macOS. iphonesimulator → iOS. Both → ask which one the fix is in.
Pin the destination, then re-read the settings through it. BUILT_PRODUCTS_DIR
is platform-specific (Debug vs Debug-iphonesimulator); read without a destination
it answers for the scheme's default platform, so the launch would look in a
directory nothing was written to — and the proof step would validate the wrong place.
For iOS, pin one simulator by UDID: booted is ambiguous with several up, and a
name= destination need not be the one that is booted.
# iOS only — choose exactly one device and use it for build, install and launch
UDID=$(xcrun simctl list devices booted -j | python3 -c 'import json,sys;d=json.load(sys.stdin)["devices"];print(next((x["udid"] for v in d.values() for x in v),""))')
[ -z "$UDID" ] && { UDID=$(xcrun simctl list devices available -j | python3 -c 'import json,sys;d=json.load(sys.stdin)["devices"];print(next((x["udid"] for v in d.values() for x in v if "iPhone" in x["name"]),""))'); xcrun simctl boot "$UDID"; }
DEST="platform=iOS Simulator,id=$UDID" # macOS: DEST='platform=macOS'
eval "$(xcodebuild "${XC[@]}" -showBuildSettings -scheme "$SCHEME" -configuration Debug -destination "$DEST" 2>/dev/null \
| awk -F' = ' '/^ +(BUILT_PRODUCTS_DIR|FULL_PRODUCT_NAME|PRODUCT_BUNDLE_IDENTIFIER|EXECUTABLE_NAME) /{gsub(/^ +/,"",$1); print $1"=\""$2"\""}')"
All four values are used below, and the launch phase refuses to run without them.
Non-Xcode projects
- SwiftPM with a GUI app bundle — build with
swift build, then treat the produced.appexactly as below. A plain command-line executable has no bundle, noPRODUCT_BUNDLE_IDENTIFIERand nothing to look at: do not force it through this flow. Run it once, show its output, and say that tests are the real check. - Web — see Phase 6b.
Phase 3 — the stale-app census (do not skip)
This is the phase that addresses the actual complaint. Before building, find every bundle the user could plausibly be looking at:
# 1. What Spotlight, the Dock and `open -a` would actually start. Ask LaunchServices
# BY BUNDLE ID — `path to application "<name>"` matches the *display* name, which
# need not equal the executable name, and a silent miss defeats the census. Do not
# probe with `open -Ra` either: it pops a Finder window as a side effect.
osascript -e "POSIX path of (path to application id \"$PRODUCT_BUNDLE_IDENTIFIER\")" 2>/dev/null
# 2. Build products lying around. Spotlight does not index DerivedData, so `mdfind`
# finds none of these — glob the directory instead.
ls -d ~/Library/Developer/Xcode/DerivedData/*/Build/Products/*/"$FULL_PRODUCT_NAME" 2>/dev/null
# 3. Anything of this app already running, wherever it came from.
pgrep -fl "/Contents/MacOS/${EXECUTABLE_NAME}$" 2>/dev/null
Expect several — a project rebuilt under different DerivedData hashes accumulates
them. Report each with its build time (date -r "<app>/Contents/MacOS/<exec>"), then
say plainly: "the one I am about to build and open is <BUILT_PRODUCTS_DIR>/<name>;
the copy in /Applications is from <date> and does not have this fix." One sentence
— this is the misunderstanding that sends people hunting a bug that is not there. If
no other copy exists, say nothing; do not narrate an ambiguity that is absent.
Phase 4 — build the branch that is checked out
Record the commit you are about to build: VERIFIED_HEAD=$(git rev-parse HEAD). Phase 5
names it as the commit on screen, and a yes in Phase 6 is about this commit only.
xcodebuild "${XC[@]}" -scheme "$SCHEME" -configuration Debug -destination "$DEST" build
For iOS, mcp__XcodeBuildMCP__build_run_sim does build, install and launch in one
step — check availability with ToolSearch first, since these tools load on demand.
The CLI equivalent installs and launches on the same UDID that was built for:
xcrun simctl install "$UDID" "$BUILT_PRODUCTS_DIR/$FULL_PRODUCT_NAME" then
xcrun simctl launch "$UDID" "$PRODUCT_BUNDLE_IDENTIFIER".
A failing build ends the skill here. Report the first real error, fix it if it is within the current task, and never proceed to a launch that would show stale code.
A build can also exit 0 without leaving the product where the settings said it would
be. Check before launching — open on a missing path raises a macOS dialog that
explains nothing:
[ -d "$BUILT_PRODUCTS_DIR/$FULL_PRODUCT_NAME" ] || { echo "build succeeded but $FULL_PRODUCT_NAME is not in $BUILT_PRODUCTS_DIR"; exit 1; }
Phase 5 — launch that exact bundle, and prove it
Refuse to run this phase with an unset variable. The first line is not
decoration: an empty $EXECUTABLE_NAME would turn the fallback below into
pkill -f "/Contents/MacOS/", which matches every bundled process on the machine —
loginwindow included — and would log the user out. Measured, not theorised.
: "${EXECUTABLE_NAME:?resolve build settings in Phase 2 first}" \
"${PRODUCT_BUNDLE_IDENTIFIER:?resolve build settings in Phase 2 first}" \
"${BUILT_PRODUCTS_DIR:?resolve build settings in Phase 2 first}"
Quit any running instance — otherwise macOS activates the copy already up, very often the wrong one — and wait for it to actually exit, because both quit paths return before the process does:
if pgrep -f "/Contents/MacOS/${EXECUTABLE_NAME}$" >/dev/null; then
osascript -e "tell application id \"$PRODUCT_BUNDLE_IDENTIFIER\" to quit" 2>/dev/null \
|| pkill -f "/Contents/MacOS/${EXECUTABLE_NAME}$"
for _ in $(seq 1 20); do
pgrep -f "/Contents/MacOS/${EXECUTABLE_NAME}$" >/dev/null || break; sleep 0.25
done
fi
open "$BUILT_PRODUCTS_DIR/$FULL_PRODUCT_NAME"
Then prove what came up rather than assuming — and wait for it, since a cold start with signature validation takes well over a second. Match on the full executable path, not a process name, so a helper or another copy cannot answer for it:
WANT="$BUILT_PRODUCTS_DIR/$FULL_PRODUCT_NAME/Contents/MacOS/$EXECUTABLE_NAME"
PID=""
for _ in $(seq 1 40); do
PID=$(pgrep -n "$EXECUTABLE_NAME" 2>/dev/null || true)
[ -n "$PID" ] && [ "$(ps -o comm= -p "$PID" 2>/dev/null)" = "$WANT" ] && break
PID=""; sleep 0.25
done
[ -n "$PID" ] && echo "on screen: $(ps -o comm= -p "$PID")" || echo "the branch build did not come up within 10s"
Match by string equality on the full path, not by a pattern: pgrep -f treats
its argument as a regular expression, and a bundle path is not one — a + or (
anywhere in it would quietly stop the proof from ever matching.
If nothing appeared, that is a launch failure — not a verdict on the fix. Report it and stop. If a different copy is running instead, quit it, wait, and relaunch.
iOS needs its own proof, for the same reason: the simulator may still hold an
older install of the same bundle id, and launch will happily start that one.
xcrun simctl get_app_container "$UDID" "$PRODUCT_BUNDLE_IDENTIFIER" app
stat -f '%Sm' "$(xcrun simctl get_app_container "$UDID" "$PRODUCT_BUNDLE_IDENTIFIER" app)"
The timestamp must be from this run. If it is older the install did not take:
xcrun simctl uninstall "$UDID" "$PRODUCT_BUNDLE_IDENTIFIER", install again.
Report one line: which branch, which bundle path, and which commit is on screen.
Record the launched bundle's literal absolute path from that line (the expanded
<BUILT_PRODUCTS_DIR>/<FULL_PRODUCT_NAME>, for example
/Users/me/Library/Developer/Xcode/DerivedData/App-abc/Build/Products/Debug/App.app),
the way VERIFIED_HEAD is recorded: Phase 7 hands it to install as --quit-path, and
shell variables do not survive from one Bash call to the next, so $BUILT_PRODUCTS_DIR
would be empty by then.
Phase 6 — the human gate
The user is the instrument; a person looking is the entire point. Ask with
AskUserQuestion, naming the behaviour from Phase 1.
When the diff changes SwiftUI views, name each changed view file in the question and
say, per file, whether you rendered and saw it (live-swiftui, or a screenshot of the
launched bundle) or only built it, appearance unverified. A project adapted since 3.12.0
carries the five steps (look up, lint, affordance check, see, report) in the "SwiftUI view
changes" section of its CLAUDE.md; the statement above applies with or without it. A view you did not see is listed as unverified, and the user's yes then covers
what they looked at, not the whole diff. The options:
- Yes, it works — asked per observable change when there are several; only when
all of them hold → Phase 7. Phase 7 then lands without asking again, so settle now
what a yes does, and say it in this option:
- Name
VERIFIED_HEAD, recorded in Phase 4 before the build: the commit the user is looking at. Do not re-readHEADhere; a commit made since the build is not on screen. - Read the landing mode (the rule in Phase 7, step 2) and name what follows:
solo— push, then land on the default branch through/superpowers-gstack:land;pr— push, then open a pull request through/ship; no valid line — push, thenlandasks once how this project lands. With no remote configured, say that nothing is pushed and the landing stays on this machine. If/reviewhas not run on this branch in this session, say that a yes lands code no review has seen (landassumes one has). - When the default branch's
CLAUDE.mdhasInstallable: macos-app <Scheme>(read like the landing mode, outside code blocks) and the landing mode issolo, the option says: yes, make this the current version and install it in /Applications.landruns/superpowers-gstack:work-stateon its exit 0, and that starts the install. The yes also covers quitting the branch build Phase 5 opened, so Phase 7 can hand it toinstallas--quit-path. Withprthe install waits for the pull request; say so instead. With no valid line, the option does not promise the install:landfirst asks how this project lands, and only asoloanswer lands and installs in this run. - When Phase 0 skipped or blocked Phase 7 (the default branch, a detached HEAD, or uncommitted changes the user declined to commit), say instead that a yes lands nothing.
- Name
- No, it still behaves the old way → stay on the branch, land nothing. Say what
you would check next and offer
/investigate. - Something else broke → same: land nothing, investigate.
End your message at this question. Do not pre-run the landing steps and do not describe them as though they had happened.
Phase 6b — the web track, briefly
Record VERIFIED_HEAD=$(git rev-parse HEAD) first, as Phase 4 does. Then start the
project's dev script and keep its PID. A ready port does not prove the
server is yours: an older one from another branch or worktree may already own it, in
which case the new process exits and the readiness probe succeeds against stale code
— the same bug as the stale bundle, wearing a different hat. Check that the process
you started is still alive before trusting the port, and if the port was taken, say
so and use a free one. Then the same gate, unchanged. Skip Phase 3 — a dev server
serves the working tree, so no stale-bundle ambiguity exists.
Phase 7 — land it
Landing has two halves and they are not the same: pushing is backup, landing is completion. Do both, in that order. The yes in Phase 6 is the one human review; Phase 7 adds no question of its own when the project has already decided how it lands.
Check that the yes still applies. A yes covers the build the user saw and the landing they were told about, nothing else. If
git rev-parse HEADis no longerVERIFIED_HEAD, or the landing mode now reads differently from what the Phase 6 option said, land nothing and say what changed. A new commit means a new build: go back to Phase 4. A changed mode only invalidates the announcement: go back to Phase 6 and ask again with the mode as it now reads.Push first, so the verified work exists in more than one place whatever is decided next. Pass the branch as a quoted argument — refs may legally contain
$( ),;and&:branch=$(git symbolic-ref --short HEAD) git rev-parse -q --verify "@{upstream}" >/dev/null \ && git push \ || git push -u origin -- "$branch"If the push fails, land nothing: report the error and stop, so landing never runs without the backup the Phase 6 option promised. With no remote configured there is nothing to push: say plainly that the work stays on this machine, then go on.
Then land it by the project's landing mode. Read the
Landing mode:line the waylanddoes: the exact lineLanding mode: soloorLanding mode: prin theCLAUDE.mdat the root of this worktree (git rev-parse --show-toplevel), a real line outside fenced code blocks. Compare it with the same line on the default branch (git show "$DEFAULT_REF":CLAUDE.md, the ref from Phase 1). WhenCLAUDE.mdis a symlink, that prints only the link's target path: read the target the same way (git show "$DEFAULT_REF":AGENTS.mdfor a link toAGENTS.md), aslanddoes.- The branch changes the landing mode (the two differ) → land nothing
automatically: a branch must not set the policy it is landed under. Ask once with
AskUserQuestionwhich mode holds, naming both values, and end your message at it. Act on the answer asland's exit-14 row says: it is the same stop, made bylanditself when it is invoked on such a branch. solo→ invoke/superpowers-gstack:landfor this worktree, from its root, with no further question.pr→ invoke/shipinside this worktree, with no further question from this skill./shipmay ask its own questions about the pull request; those are not a second ask of the landing. When/shiphas opened the pull request, run/superpowers-gstack:work-stateand say that the work waits for a pull request before it is landed or installed; end the message with its line.- No valid line (missing, only inside a code block, or both values) → invoke
/superpowers-gstack:landanyway. Its exit-2 row owns this question: it asks the user once (solo or pull request? or, with both values, which one holds) and records the answer. Do not ask it here as well.
When
landstops with an exit code, followland's own exit-code table: it is the authority on the next step. Add no menu of your own on top of it, and never offer/superpowers:finishing-a-development-branchfrom this skill: it merges without the project's pre-merge gate, which is whylandstops at exit 8 whenwtis not installed instead of falling back to it.- The branch changes the landing mode (the two differ) → land nothing
automatically: a branch must not set the policy it is landed under. Ask once with
If the branch is checked out in a worktree, run /ship and /superpowers-gstack:land
from that worktree's root: a branch cannot be checked out twice, and git checkout fails
outright elsewhere (git worktree list gives the path). The landing skills move the
default branch themselves; do not hand-roll a merge.
After land exits 0, follow its remaining field: it prints the cleanup commands in the
order git needs them (the worktree first, then the branch). land then runs
/superpowers-gstack:work-state. When its install entry starts and the Phase 6 yes named
the install, add --quit-path <the literal bundle path recorded in Phase 5> to that
entry, written out in full (never a shell variable: install compares the path as exact
text) and as ONE argv element, without quote characters, so the commonest path does not stop at code 4 because the branch build is still
running. Without that yes in this session, pass no quit flag: code 4 is
then a question for the user, as the work-state skill says.
What this skill is not
- Not a test run. Tests answer "did I break something else"; this answers "is the
thing I fixed actually fixed".
/shipruns the tests on the way to a PR. - Not UI automation.
/superpowers-gstack:e2e-routeowns automated E2E; this is the one case where a human eye is the point. - Not a release. It builds Debug and launches locally; shipping to users is
/land-and-deploy.