Prototype Loop Skill
Codifies the leveraged-prototype dev loop for closing framework gaps during
high-feedback UI parity work.
When to use this skill
Trigger this skill when the user is doing any of:
- Porting a UI to Pulp and wants tight visual-feedback iteration (Spectr's WebView editor → native via
@pulp/react is the canonical example).
- Gap-finding — they suspect there are framework gaps and want to enumerate them via an AOT analyzer.
- Multi-PR upstream coordination — they're filing a batch of framework PRs and want a tight loop while upstream merges land.
- Hitting slow cross-platform configure cost (Skia/Dawn/threejs FetchContent) on changes that only need single-platform validation during iteration.
Do not trigger for: bugfixes, cross-platform refactors, or final-landing flows. Those want the cross-platform default.
When NOT to use this skill
- Pre-merge / landing the consumer's PR — always exit focus mode (or run
shipyard pr / pulp pr) before landing. The ship path validates cross-platform regardless, but exiting focus mode keeps subsequent local iteration honest.
- Bugfixes that touch platform-specific code — a cross-platform build is the cheapest correctness check for those.
- Refactors that span subsystems — single-platform configure can mask a build break on a sibling platform.
The loop in one breath
analyze → file → prototype (ar-swap) → monitor → bump → ship
Each step has tooling. The CLI loop is available now; deeper archive-splice, issue-monitoring, and analyzer-lift work should land separately.
Current surface
| Surface | Status |
|---|
pulp loop CLI + skill + slash command + docs | available |
pulp-ar-swap.sh ABI-checked archive splice | deferred |
pulp loop --watch-issues PR-monitor | deferred |
Lift @pulp/css-adapt, pulp-css-analyze, extract-html-bundle | deferred |
Step 0 — Start a new worktree warm, not cold
A fresh worktree's first build is a full configure plus every compile and
link, even when the worktree you branched from built the same tree minutes
ago. On macOS/APFS, pulp build --seed-build (or PULP_SEED_BUILD=1 in the
environment so every first build does it) runs
tools/scripts/seed_build_dir.py before the first configure. It clonefiles
the warm Ninja build dir of the sibling worktree closest to HEAD (shared
blocks, no data copied) and retargets it: text files that name the donor
path are rewritten, .ninja_deps is re-emitted, .ninja_log command hashes
are recomputed, binaries that embed the donor path are dropped, and unchanged
clean sources get the donor's mtimes. The first build is then only what
differs from the donor. Receipt: build/.pulp-seed-receipt.json (edges left
to build, path-tainted binaries dropped, timings).
What to know before trusting it:
- It only ever removes gratuitous work. Anything Ninja cannot prove up to
date is rebuilt, and it reads the donor without writing it (the suite
audits the donor's inodes and mtimes).
- Eligible donors are Ninja, built, same
CMAKE_BUILD_TYPE and
PULP_BUILD_EXAMPLES, same APFS volume, and not mid-build. Anything else
is refused with exit 3 and nothing left behind, and pulp build configures
from scratch as usual. Cross-volume never silently copies.
- A Release donor built through ccache with
base_dir gains almost
everything; a Debug donor (-g) embeds the path in every object and gains
only the configure. Targets whose -D defines carry the source dir
recompile regardless.
dirty_edges in the receipt is Ninja's planned total from a dry run of a
copy of build.ninja. A plain ninja -n stops at CMake's always-dirty
glob check and reports 2, and counting its status lines undercounts,
because a dry run skips the status line of edges that finish together.
When cmake_rerun_pending is non-empty (a CMakeLists.txt differs from
the donor's, e.g. a VERSION bump) CMake re-runs first and the count is a
lower bound.
pulp build in a fresh worktree has an empty diff, so its focused
selector widens to all; on a seeded dir that is exactly the leftover
edges, not a full build.
Never start the worktree under /tmp or $TMPDIR. Configure and
governed-build.sh refuse it (exit 3), because a temporary tree misses the
shared ccache and starts every build cold; create it under
$PULP_WORKTREES_ROOT or beside the primary checkout. A second pulp build
into a tree that is still building exits 75 and names the running build:
attach to that one instead of relaunching. pulp loop takes the same lock for
each rebuild, so a loop and a manual pulp build in one tree cannot race; a
rebuild that finds another build holding the tree reports 75 and the loop keeps
watching.
Step 1 — AOT analyze the consumer's bundle
Run pulp-css-analyze over the consumer's pre-built React bundle. The output is a coverage report listing unmapped CSS props with occurrence counts.
Reference output shape:
Unmapped CSS props (8 total):
fontFamily 14× examples: src/Label.tsx:23, src/Header.tsx:8, ...
textAlign 6× examples: src/Caption.tsx:12, ...
...
The occurrence counts are how you prioritize — file the 14× issue first.
Step 2 — File framework issues with the right shape
Each gap deserves its own issue. Use this shape:
- One-line title — e.g. "Label.font_family_ accessor missing from public surface".
- Occurrence count — "Used in 14 places across the Spectr bundle (see analyzer report URL)".
- Acceptance criteria — concrete, testable. "
label.set_font_family("Inter") followed by label.font_family() == "Inter" returns true".
- Bridge-fn signature suggestion — "
label.set_font_family(string) mirroring set_font()".
- Cross-link to the analyzer report that surfaced it.
Filing 6 well-shaped issues with concrete signatures cuts upstream ramp-up dramatically. If you're going to file an umbrella + sub-issues, link the sub-issues from the umbrella's body so the dashboard view is coherent.
Step 3 — Enter focus mode (pulp loop)
pulp loop # auto-detect host platform
pulp loop --platform=macos # explicit override
pulp loop --status # report current state
pulp loop --off # restore cross-platform mode
The CLI persists [loop] focus_platform = "..." in ~/.pulp/config.toml. Subsequent invocations stay pinned until explicitly cleared.
The watch loop is also focused on the working diff by default: before every
rebuild it re-runs the affected-target selector (pulp affected, script
tools/scripts/affected_targets.py) and passes cmake --build --target only
the targets that own the diff plus their companion test programs
(test_<stem>*.cpp), following add_dependencies and CTest fixture edges. With
--test it runs only those tests via ctest --tests-from-file. A one-line
.cpp edit in core/view rebuilds pulp-view-core + pulp-test-widgets in
seconds instead of relinking ~1,400 programs. The banner it prints is the
contract:
FOCUSED: building 3/1708 targets affected by your diff - run 'pulp build --all' before opening a PR
pulp loop --all (and pulp dev --all, pulp build --all) restores the full
build; --target and --test-filter always win; PULP_BUILD_FOCUS=0
disables focus for a shell.
- The selector falls back to
all and says why for an empty diff, a
build-system change, an unmapped C/C++ file, or a selection above ~40% of the
target graph. Header edits focus only when a dependency database exists
(ninja -t deps or Makefile .o.d files).
- The first focused run in an existing build dir configures once (~80 s) to
record the CMake file-API codemodel it selects from.
- Focused green is not landing green. Pre-push and Shipyard still build
all; run pulp build --all && pulp test --all before shipyard pr.
A fresh source-checkout configure leaves the example projects off (-DPULP_BUILD_EXAMPLES=OFF) and pins Ninja + Release, like pulp build. When the prototype lives under examples/, pass pulp loop --examples so the first configure (or a reconfigure of a tree that has examples off) includes it. An existing tree an older CLI left on Makefiles, Debug, or examples ON is migrated on the first pulp loop: its cache moves to build/.pulp-pre-migration/, one Reconfiguring … (was: …) line prints, and the tree configures fresh (a one-time full rebuild). A prototype under examples/ must therefore pass --examples on every run, or the migration turns examples back off; PULP_KEEP_BUILD_CONFIG=1 keeps a tree as it is.
--no-watch flips state and exits without entering the watch loop — this is what tests use, and it's also useful when you want the marker but plan to drive builds yourself.
Step 4 — Local prototype via ar-swap
Pick the simplest gap from your filed issues. Build the framework patch in another worktree:
git worktree add ../pulp-fix-issue-X feat/fix-issue-X
cd ../pulp-fix-issue-X
cmake --build build --target pulp-view
The planned pulp loop --ar-swap-from ../pulp-fix-issue-X helper will splice the changed .o files into the pinned SDK's static archive after validating header/library ABI. The validator refuses on vtable mismatch — the failure mode where a consumer compiles against stale layout assumptions such as Label::font_family_.
Until that helper lands, do the ar-swap by hand:
- Build the patched object file in the other worktree.
nm -gU the object — make sure exported symbols match what your consumer's compile expects.
ar -r <pinned-sdk>/lib/libpulp-view.a <patched.o> — splice.
- Visually validate via
pulp-screenshot or another explicit capture path.
- DELETE the local archive after validation. Otherwise you'll forget it's spliced and ship a binary that doesn't match the upstream pin.
Step 5 — Monitor upstream PR state flips
When automatic upstream monitoring is available:
pulp loop --watch-issues 924,927,931,932
will poll gh pr list for state flips on PRs referencing the named issues. It fires a notification when each PR transitions to MERGED.
Until then, run this in a side terminal:
watch -n 60 'gh pr list --state merged --search "924 OR 927 OR 931 OR 932" --json number,title,mergedAt'
Step 6 — Bump SDK pin and validate cross-platform
After the upstream batch merges and auto-releases:
- Bump the consumer's SDK pin in one shot (e.g.
0.52.0 → 0.56.0 if v0.53/v0.54/v0.55/v0.56 each came from one of the merged PRs). Update pulp.toml / find_package(Pulp …).
- Run
pulp loop --off — restore cross-platform mode.
- Run
shipyard pr (or pulp pr) — full cross-platform validation gates the merge. This is the contract: focus mode for iterating, cross-platform for landing.
Filing follow-up issues
When you discover a new framework gap while in focus mode, file it the same way as Step 2. Don't fix it locally and forget — the loop's discipline is upstream-first.
If you fixed something locally to keep iteration moving and the upstream issue isn't merged yet, leave a // TODO(issue-NNN) marker and a planning doc note in the consumer. The skill is "file framework issues immediately", not "fix and forget".
Switching modes
- Enter:
pulp loop --platform=macos → persists the focus marker and runs the normal project watch/rebuild loop.
- Exit:
pulp loop --off → restores cross-platform mode.
- Land:
shipyard pr (or pulp pr) → still runs full cross-platform validation before merge regardless of focus state.
The mode is advisory at the build layer today. The marker is read by tooling that wants to know "is the developer iterating or landing?", but it does not silently hide cross-platform breakage. Keep the marker semantically clean: "I am iterating, please don't surprise me with cross-platform configure cost."
Common pitfalls
- Forgetting to exit focus mode before landing —
pulp loop --off is one extra keystroke, but it's how you preserve the "single-platform for iterating, cross-platform for landing" separation. The ship path validates regardless, but the marker should match reality.
- Locally hacking around a framework gap instead of filing it — easy to fall into, especially when you're in flow. The skill prompts upstream issue-filing as the preferred path; resist the local-fix temptation.
- Drifting past merged upstream PRs — the planned
--watch-issues monitor is the structural answer. Until then, set a 5-minute timer or pin the gh pr list watch in a side terminal.
- ar-swap leaving SDK in inconsistent state — header vs
.a vtable mismatch is the trap. The planned helper refuses on mismatch; until then, nm -gU the patched object before splicing and delete the local archive after validation.
- Multiple worktrees on different focus platforms — the marker is per-
PULP_HOME, so two worktrees pointing at the same ~/.pulp/config.toml share the marker. If you're working on macOS in one worktree and want a Linux focus check in another, use PULP_HOME=/tmp/pulp-linux pulp loop --platform=linux.
Slash command
The Claude Code slash command lives at .claude/commands/prototype-loop.md. It asks the user to confirm the focus platform, then orchestrates the loop.
Reference
- Planning issue: leveraged-prototype dev loop.
- Validation example: Spectr native React editor coverage using focused Pulp framework-gap issues.
- Coverage report: spectr
feature/native-react-editor branch, planning/spectr-style-coverage-report.md
- Companion docs:
docs/guides/focus-mode.md