| name | generate-native-extension |
|---|---|
| description | Read the approved PRD.md and generate the native sources for a third-party PAM control (the compiled `.ppmplugin` track) — iOS Obj-C `<Pascal>Module` plus optional system-frameworks podspec, Android Kotlin `<Pascal>Module` with build.gradle, AndroidManifest and ReactPackage, a dev-only private package.json (react + react-native devDeps for the builds), and the committed `./manifest.json` dispatch contract the PCF and build stage both read. No TypeScript INativeExtension layer — the contract is the manifest plus the native modules' dispatch surface. Emits the layout in shared/repo-layout.md and generates substantially complete native code (compiled later by /build-android-binary and /build-ios-binary, not here). Local only — writes files, runs no git and touches no remote or feed. PCF is generated by /generate-pcf-companion; the bundle is built by /generate-ppmplugin. |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Skill |
| model | opus |
Reads PRD.md in the working directory and writes the native sources for a third-party PAM control following the layout in shared/repo-layout.md. This is the native-only (compiled .ppmplugin) track — there is NO TypeScript INativeExtension / handleMessageAsync layer; the wrap host dispatches straight to NativeModules.<Pascal>Module.<method> per the manifest's receivers contract (see shared/ppmplugin-format.md §2). The output is substantially complete native code so the engineer starts at customizing OS-specific code, not writing boilerplate.
This skill writes the native module half of the repo (ios/, android/, optional podspec, dev-only package.json) and the committed ./manifest.json — the dispatch-contract source of truth. The manifest is authored here, alongside the native code it describes, because every field in it is derived from the names this scaffold emits (getName(), the @ReactMethod list, the package class); authoring it now means the Companion PCF (/generate-pcf-companion) reads a real contract instead of re-deriving one, so the composite key <name>/<receiver> can't drift between the PCF and the module. The build stage /generate-ppmplugin-manifest (inside /generate-ppmplugin) then validates + reconciles + stages this manifest rather than authoring it from scratch. The Companion PCF is generated separately by /generate-pcf-companion because it requires pac CLI and a different toolchain.
Before any write:
-
Apply the per-skill minimal prereq policy (
shared-instructions.md §1.5). This track is self-contained (shared-instructions §0a) and uses only the working tree and public package registries. This skill needs no toolchain to write the files — optionally Node + pnpm to seed the dev-onlypackage.json's devDeps from the public npm registry (used later by/build-android-binary//build-ios-binary, not here). Step 4's smoke check is a structural self-check — it does NOT compile anything. Run the/generate-native-extensioncheck fromprereq-check.md(git required; Node/pnpm optional — there is no "baseline" check in this self-contained track).Print the prereq status as a visible block per
shared-instructions.md §9.2before continuing:━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Prereq check — /generate-native-extension ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🟢 ✓ git installed 🟢 ✓ Node 20+ installed (optional — only to seed package.json devDeps from public npm) 🟢 ✓ pnpm installed (optional — same) 🟢 checks passed. Ready to proceed.If
gitis missing, print its→ Fix:line and STOP. Node/pnpm are optional here — if absent, note them asn/a (devDeps seed deferred to build skills)rather than failing. -
Read
shared/naming-conventions.md— the derived-identifier table is canonical, including theModule-suffix rule for the native module symbol. Derive all file paths and class names from §2 of the PRD using that table; do not invent. -
Read
shared/ppmplugin-format.md— §2 (the runtime dispatch contract:<name>/<receiver>→NativeModules.<nativeModule>.<method>, where<nativeModule>=<Pascal>Module) and §4 (the upload-compatibility checks that the native module symbol must satisfy). The native modules this skill emits dispatch straight off that contract — there is NO TSINativeExtensionlayer mediating; see §3.3 below. -
Read
shared/repo-layout.md— the exact tree, file list, andpackage.jsonshape to emit. -
Read
./PRD.mdfrom the current working directory. If missing or empty, STOP withBLOCKED: PRD.md not found — run /design-native-extension-feature first. -
Read
./.extension-state.mdif present. If the phase showsscaffold-complete, ask the user whether to regenerate (with confirm — overwrites files), resume (only fill in missing files), or abort.
The structural patterns this skill needs to emit (iOS module shape, Android module shape, podspec, package.json) are fully prescribed in this SKILL.md (§3.1–§3.7) and in shared/repo-layout.md. Do NOT fetch the reference extension repo at runtime — its lessons are already encoded here, and fetching it would risk copying PDF-specific code into a non-PDF extension.
If any read fails, STOP and report which file is missing.
Print a concise summary derived from the PRD, then gate on approval before any write.
Scaffold plan
─────────────
Repo: powerapps-<kebab>
package: <kebab>-control (dev-only, private — not published)
Class: <Pascal>
Native module: <Pascal>Module → NativeModules.<Pascal>Module (== ./manifest.json receivers[].nativeModule)
iOS class: RCT<Pascal>Module (+moduleName returns <Pascal>Module)
Android module: <Pascal>Module (com.powerapps.<lower>)
Podspec: <Pascal>Extension.podspec (optional, system-frameworks-only)
Dispatch contract: ./manifest.json (committed — written by this skill; read by the PCF + build stage)
Frameworks
iOS: <list from ARCHITECTURE §1.2>
Android: <list from ARCHITECTURE §1.3>
Operations (<count from PRD §4>): <comma-separated names>
Pattern: <one-shot | streaming | two-way>
Error codes: <count from ARCHITECTURE §5>
Target directory: <cwd> (writes <N> files; no existing files will be overwritten without confirm)
Distribution: the compiled `.ppmplugin` bundle (built later by /generate-ppmplugin). This skill is purely local — no remote, no feed, no registry.
Use AskUserQuestion (single-select):
Proceed with this scaffold?
- Yes — generate the files (recommended): write the control's sources into the current directory. This skill does not run git — no
git init, no staging, no commit (the control lives in your existing repo; you commit when you're ready).- Edit the PRD first — exit; user re-runs
/design-native-extension-featureto adjust.- Cancel
Write files in the order below. After each top-level group, print a one-line progress update (✓ wrote ios/ (3 files)). Don't dump file contents — the user sees the diff via the IDE.
Every file path is relative to the current working directory (the repo root). Names are derived per shared/naming-conventions.md.
Write:
-
.gitignore— emit exactly the following entries:- Node:
node_modules/,dist/,build/ .ppmpluginbuild staging — MANDATORY:ppmplugin/(the gitignored staging dir where/generate-ppmpluginwrites the staged copy of the manifest, the binaries, and the final bundle — never committed. NOTE: the committed source-of-truthmanifest.jsonlives at the repo root (./manifest.json, written below), NOT underppmplugin/— do not gitignore it; seeshared/ppmplugin-format.md §1)- OS / editor:
.DS_Store,.idea/,.vscode/ - Claude Code local state (per-user, not shared):
.claude/ - Env:
.env*(but allow!.env.example) - iOS build:
Pods/,*.xcworkspace,DerivedData/,*.xcodeproj/xcuserdata/ - Android build:
*.iml,.gradle/,local.properties,captures/,.externalNativeBuild/,.cxx/ - PCF build dirs only — NOT the
pcf/folder itself; source files (index.ts,ControlManifest.Input.xml,package.json,pcfconfig.json, etc.) stay tracked:pcf/**/{out,Solutions,node_modules,obj,bin,generated}/ - Test-harness artifacts:
test-harness/*.msapp - Skill-generated backups:
*.bak.*(skills that replace tracked content may save a timestamped backup; those are intentionally local-only) - Design-time previews:
.pcf-preview/(HTML mockup of the PCF as it appears in Canvas Studio — written by/design-native-extension-featureStep 8.0 for visual review; regenerated each design iteration; not a source-of-truth artifact)
- Node:
-
package.json— per the dev-only shape inshared/repo-layout.md§"package.jsonshape (dev-only)". Fill inname(a plain local name, e.g.<kebab>-control) anddescriptionfrom the PRD.versionstarts at0.1.0. Set"private": true.This manifest is never published — no
publishConfig, no feed registry, nofilesarray, nomain/types, no.npmrc. Its only job is to pin the React Native version the native builds compile against:{ "name": "<kebab>-control", "version": "0.1.0", "private": true, "description": "<from PRD §1>", "devDependencies": { "react": "18.2.0", "react-native": "0.79.7" } }The
react-nativedevDep supplies the iOS headers (/build-ios-binary) and pins thereact-androidcoordinate the Android build resolves (/build-android-binary); add any other build-time devDeps the native modules need. All deps resolve from the public npm registry — there is no internal feed. -
manifest.json(repo root, committed — the dispatch-contract source of truth) — author it now from the names this scaffold emits, pershared/ppmplugin-format.md§2 (schema) + §3 (derivation). This is the single artifact the Companion PCF (/generate-pcf-companion) and the build stage (/generate-ppmplugin-manifest) both read; authoring it here, next to the native code it describes, is what keeps the composite key<name>/<receiver>from drifting between the PCF and the module. Fields:name=kebab(<Pascal>)of the class name (not the repo/capability name) — e.g. classPenInput→pen-input.version= thepackage.jsonversion (0.1.0).abi={ "compatibleShells": ">=1.0.0", "builtAgainst": "1.0.0" }(default; the build skills don't change it).receivers[]= a single entry{ "name": "<Pascal>Extension", "nativeModule": "<Pascal>Module", "methods": [<every @ReactMethod / RCT_EXPORT_METHOD name emitted in §3.4 / §3.5>] }.nativeModuleMUST equal AndroidgetName()and the iOS+moduleNamereturn value — theModule-suffixed name (the reserved-name dodge).entrypoints= declare every platform this scaffold generated (so the committed manifest is the full contract; the build stage trims it to the shipped target):- Android →
"android": { "dex": "<Pascal>Plugin.dex", "packageClass": "com.powerapps.<lower>.<Pascal>Package" } - iOS →
"ios": { "framework": "<Pascal>Plugin", "moduleClass": "RCT<Pascal>Module" }
- Android →
This is a logical contract, not a built artifact — it lists the platforms the module supports; the per-platform binaries are compiled later and the staged copy under
ppmplugin/staging/is reconciled down to whatever actually ships. Do NOT emit anyentrypoints.js/extension.hbc/extensionClassName/jsLayerfield — those are SDK-era leakage/audit-ppmpluginrejects. (The build stage re-runs the full validator on this file, so a malformed manifest is caught either way — but emit it correctly here.) -
README.md— one-page user-facing doc tailored to the control. Sections: "What's in the box" (the compiled.ppmpluginbundle + PCF companion), "Build" (run/generate-ppmpluginto produce the.ppmplugin), "Architecture" (a Mermaid-or-ASCII diagram of Canvas formula → PCF → wrap-bridge →NativeModules.<Pascal>Module), "Development" (pnpm installto seed devDeps; native code is compiled by the build skills, not here), "Reference docs" (link toshared/ppmplugin-format.md). Use the PRD's §1 Summary verbatim. Drive every section from the PRD — never inject example values, prose, or screenshots from any other control's README. -
CHANGELOG.md— single entry:# Changelog ## 0.1.0 — <ISO date> - Initial scaffold for <Human-Readable Name> native control. - Generated by pam-native-extensions plugin from PRD.md.
-
LICENSE— MIT.
Write <Pascal>Extension.podspec at the repo root (NOT inside ios/) only if ARCHITECTURE §1.2 names additional iOS system frameworks the module links. The .ppmplugin iOS build (/build-ios-binary) compiles from a throwaway staged Xcode project and does NOT npm-autolink against this podspec — so it lists system frameworks only (no React-Core / RN-CLI autolink dependency, no remote source). It exists for local pod lib lint convenience, not the bundle build. Template:
require "json"
package_json = JSON.parse(File.read(File.join(__dir__, "package.json")))
Pod::Spec.new do |s|
s.name = "<Pascal>Extension"
s.version = package_json["version"]
s.summary = "<one-line description from PRD>"
s.description = <<-DESC
<2-3 sentence description from PRD — what it does, what it bridges to>
DESC
s.license = "MIT"
s.author = { "Author" => "" }
s.platform = :ios, "<min-deployment-target from ARCHITECTURE §1.2>"
s.source = { :path => "." }
s.source_files = "ios/**/*.{h,m}" # change to {h,m,swift} if Swift used
s.frameworks = <comma-quoted list of SYSTEM frameworks from ARCHITECTURE §1.2>
# No React-Core dependency: the .ppmplugin build resolves RN headers from the
# react-native devDep in package.json, not via CocoaPods autolinking.
endThis is the native-only track: there is no src/ TypeScript layer, no src/<Pascal>Extension.ts, no src/types.ts, no INativeExtension / handleMessageAsync implementation, and no sendAsync transport. (Those belong to the first-party SDK track — NOT in this track.) Do NOT generate any of them; reintroducing a TS contract layer here produces SDK-era leakage that /audit-ppmplugin rejects.
The contract instead is the manifest's runtime dispatch (shared/ppmplugin-format.md §2): the wrap host routes a call by the composite key <name>/<receiver> straight to NativeModules.<Pascal>Module.<method>(args, promise). There is no JS mediator. This means:
- The request shape (the
argsobject) and response shape (the object the promise resolves with) from ARCHITECTURE §4 are realized directly in the native@ReactMethod/RCT_EXPORT_METHODsignatures + their JSON responses — see §3.4 (iOS) and §3.5 (Android). The per-operation JSON parsing, request validation, operation branching, and error-code responses that a first-party TShandleMessageAsyncwould have done are emitted inside each native method instead. That dispatch logic is the valuable part this skill generates. - The
manifest.jsonthat declaresname,receivers[].method, andreceivers[].nativeModule(=<Pascal>Module) is written by this skill at the repo root (§3.1) — the native module symbols it emits and the manifest'sreceivers[]are authored together, so they can't disagree./generate-ppmplugin-manifestlater validates + reconciles + stages this file rather than re-authoring it (§2/§3 below +shared/ppmplugin-format.md §3). - The error-code set from ARCHITECTURE §5 is realized as the string codes the native
errorJson(code, message)helpers emit (§3.4 / §3.5), each paired with a human-readablemessage— there is no TS error-union type to declare. These codes are the stable strings from the canonical catalogshared/error-codes.md(Canvas formulas branch on them, so they must not drift); emit exactly the catalog spelling for any code ARCHITECTURE §5 reuses. The PCF reads both: theerrorcode to branch on, themessageto surface as itsErrorMessageoutput.
Write:
-
ios/RCT<Pascal>Module.h— minimal Obj-C header importing<React/RCTBridgeModule.h>, declaring@interface RCT<Pascal>Module : NSObject <RCTBridgeModule> @end. -
ios/RCT<Pascal>Module.m— the implementation. Generate complete working code, not TODO placeholders. For each operation in PRD §4, the per-operation §3. block prescribes every implementation decision (framework, hosting, key APIs, export shape, edge case handling). Generate the implementation verbatim from §3.:- Imports: include
RCT<Pascal>Module.h,UIKit, plus every framework named in ARCHITECTURE §3.'s "Framework / class" field for any operation (e.g.#import <PencilKit/PencilKit.h>if any §3. names PencilKit). - Module identity: do NOT emit
RCT_EXPORT_MODULE(...)in a wrap plugin framework. That macro registers via+loadand_RCTRegisterModule, which is not visible to the framework'sdlopenflat namespace. Instead emit a class method+ (NSString *)moduleName { return @"<Pascal>Module"; }— theModule-suffixed name. The Obj-C class name staysRCT<Pascal>Module(matchingentrypoints.ios.moduleClass), while+moduleNameMUST equal the manifest'sreceivers[].nativeModuleand JS seesNativeModules.<Pascal>Module. Do NOT strip the suffix. + (BOOL)requiresMainQueueSetupreturningNOunless any §3. requires main-thread init.initsafety — the module is instantiated eagerly at load via[cls new], soinitMUST NOT throw or do heavy/side-effecting work (ppmplugin-format §5). Do not acquire hardware, registerNSNotification/KVO observers, or touchAVCaptureSession/CLLocationManagerininit— defer to the firstRCT_EXPORT_METHODcall (lazy), and wrap any unavoidable init work in@try/@catch. An uncaught exception ininitcrashes the host at launch (the iOS analogue of the Android Looper-less-Handlercrash).- For each operation, write an
RCT_EXPORT_METHODtaking exactly oneNSDictionary *requestparameter, thenRCTPromiseResolveBlock resolve,RCTPromiseRejectBlock reject— e.g.RCT_EXPORT_METHOD(capturePenInput:(NSDictionary *)request resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject). This matches the wrap dispatch contract: the PCF sendsargs: [request](a one-element array) spread positionally, so the method's first positional param is the request dictionary (ppmplugin-format §2). Read fields offrequest(request[@"…"]); do NOT expand into multiple positional params. Also: the Obj-C class MUST instantiate via a no-arg[cls new]after the runtime loads it — don't add a custom designated initializer that takes arguments. The body implements §3.'s iOS spec completely:- The hosting setup ("dedicated UIViewController presented modally, full-screen" → emit a
UIViewControllersubclass or inline VC +presentViewController:animated:completion:). The presented VC'sviewDidLoadMUST constrain custom content views toview.safeAreaLayoutGuide, notviewdirectly — this prevents content from intruding under the notch / Dynamic Island / home indicator. SetmodalPresentationStyle = UIModalPresentationFullScreen(or.pageSheetper ARCHITECTURE §3.). Add aUINavigationBarwith Done / CancelUIBarButtonItems for clear action affordance — same Material-toolbar-equivalent pattern as Android. - The key API calls in the order §3. specifies (e.g.
PKCanvasViewinit,PKToolPickerattachment, drawing capture) - Each Done/Cancel/dismiss handler as §3. specifies
- The export step as §3.'s "Export" line specifies (e.g.
drawing.image(from: canvas.bounds, scale: 2.0)→ PNG → base64) - Each edge case from §3.'s "Edge cases handled" list, with the exact behavior named (e.g. "User taps Cancel → resolve with USER_CANCELLED")
- The hosting setup ("dedicated UIViewController presented modally, full-screen" → emit a
- Threading: background work on
dispatch_get_global_queue; UI presentation ondispatch_get_main_queue. Long-running native work must not block the JS thread. - Error helper: emit
- (NSString *)errorJsonWithCode:(NSString *)code message:(NSString *)messagethat builds the dict@{@"status": @"error", @"error": code, @"message": (message ?: @"")}and serializes it viaNSJSONSerialization— the SAME serializer as the success helper. Do NOT usestringWithFormat: amessage(or code) containing a",\, or newline would emit invalid JSON, which the PCF's response parse would surface as a misleadingPARSEinstead of the real failure — defeating the whole point of the message. Themessageis a human-readable diagnostic that makes the failure debuggable from the PCF without a native debugger: for a caught exception passerror.localizedDescription; for a validation failure a specific reason (e.g.@"missing required field 'uri'"); forUSER_CANCELLEDa short note. Every error path calls this with BOTH a code and a message — never a bare code. - Success helper: emit
- (NSString *)successJsonWith:(NSDictionary *)resultthat builds{"status":"ok","result":<result>}viaNSJSONSerialization. - Error propagation — wrap the operation body so every failure reaches the PCF with a code AND a message. Any framework/runtime failure must
resolvewitherrorJsonWithCode:message:carrying a specific code and reason — never throw an uncaught Obj-C exception, crash, orresolveempty. Use@try/@catcharound risky synchronous work and resolve the@catchwithINTERNAL_ERRORplusexception.reason. - UI hygiene boilerplate for each presented
UIViewController'sviewDidLoad(mirrors Android's insets handling — prevents the most common iOS issue: content under safe areas, status bar, home indicator):- (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = [UIColor systemBackgroundColor]; // Navigation bar with Done / Cancel — equivalent to Android's MaterialToolbar. UINavigationBar *navBar = [[UINavigationBar alloc] init]; navBar.translatesAutoresizingMaskIntoConstraints = NO; UINavigationItem *navItem = [[UINavigationItem alloc] initWithTitle:@"<Human-readable from PRD §2>"]; navItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel target:self action:@selector(handleCancel)]; navItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(handleDone)]; navBar.items = @[navItem]; [self.view addSubview:navBar]; // Content view — the operation-specific surface (e.g. PKCanvasView, AVCaptureVideoPreviewLayer host). // Constrain to safeAreaLayoutGuide so content doesn't extend under the notch / home indicator. UIView *contentView = [[UIView alloc] init]; // Replace with operation-specific view per ARCHITECTURE §3.<n> contentView.translatesAutoresizingMaskIntoConstraints = NO; [self.view addSubview:contentView]; [NSLayoutConstraint activateConstraints:@[ [navBar.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor], [navBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], [navBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], [contentView.topAnchor constraintEqualToAnchor:navBar.bottomAnchor], [contentView.leadingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.leadingAnchor], [contentView.trailingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.trailingAnchor], [contentView.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor], ]]; } - (UIStatusBarStyle)preferredStatusBarStyle { // Adapt to system appearance — matches Android's windowLightStatusBar in light theme. return UIStatusBarStyleDefault; // automatic light/dark per system }
- Modal helper: emit
- (UIViewController *)topViewControllerif any operation presents modally:- (UIViewController *)topViewController { UIViewController *root = UIApplication.sharedApplication.keyWindow.rootViewController; while (root.presentedViewController) { root = root.presentedViewController; } return root; }
No TODO placeholders. No
// implement this. If a §3. block is incomplete (any "Key APIs and decisions" item is vague or missing), STOP withNEEDS_CONTEXT: ARCHITECTURE §3.<n> implementation block is incomplete — re-run /design-native-extension-feature Step 7 (per-operation implementation walkthrough) to complete it. Don't paper over a vague spec with a guess. - Imports: include
Write:
-
android/build.gradle— library-only gradle config. The module is consumed by the host's managed build, which provides the root project setup. (For the standalone.ppmpluginbuild,/build-android-binarycompiles from a throwaway staged copy with pinned versions — this canonical file is never edited; seeshared/ppmplugin-format.md §5.) Do NOT emit abuildscript { ... },allprojects { ... }, or any classpath declarations — those belong to the root project, not this library module.Library-only shape (this is the entire file — no preamble, no root-project blocks):
// <kebab>-control // Android library module — consumed by the host's managed build. apply plugin: 'com.android.library' apply plugin: 'kotlin-android' def safeExtGet(prop, fallback) { rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback } android { namespace "com.powerapps.<lower>" compileSdkVersion safeExtGet('compileSdkVersion', 35) defaultConfig { minSdkVersion safeExtGet('minSdkVersion', <PRD min — default 24>) targetSdkVersion 35 } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = '17' } } dependencies { // 'react-android' (renamed from 'react-native' in RN 0.73). compileOnly + pinned: // the wrap shell provides RN at runtime, so never bundle it, and the legacy // 'react-native:+' coordinate does not resolve in the standalone build. // Read <rnVersion> from package.json devDependencies (currently 0.79.7). compileOnly "com.facebook.react:react-android:<rnVersion>" implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.core:core-ktx:1.12.0' // WindowCompat / WindowInsetsCompat for UI hygiene implementation 'androidx.constraintlayout:constraintlayout:2.1.4' // for the generated layout XML implementation 'com.google.android.material:material:1.11.0' // Material 3 theme + components // Plus any ARCHITECTURE §1.3 / §1.4-specified additions (e.g. ML Kit, FusedLocationProvider) }
-
Files NOT to generate (these are root-project / standalone-build concerns; the host's managed build — or, for the
.ppmplugin,/build-android-binary's staged copy — owns them):android/settings.gradle— root project's responsibilityandroid/gradle.properties— root project's properties;android.useAndroidXandandroid.enableJetifierare supplied ambiently by the host (and generated into the staged copy by/build-android-binary), not by the libraryandroid/gradlew+android/gradle/wrapper/*— the Gradle wrapper; library modules don't need their own wrapper- Any top-level
buildscript { ext, repositories, dependencies (classpath) }block inbuild.gradle— the host provides AGP + Kotlin classpaths
No standalone build script. The
android/directory is consumed by the host's managed build (and copied into a pinned staging dir by/build-android-binaryfor the.ppmplugin); it doesn't have to compile in isolation. Don't add a top-levelbuildscript { ... }/allprojects { ... }block — the host provides those. Standalone./gradlew assembleDebugagainst this directory is not a validation path we support (native compile happens in/build-android-binary, not here — seeshared/ppmplugin-format.md §5). -
android/src/main/AndroidManifest.xml— registers permissions from ARCHITECTURE §1.4 AND the dedicated capture Activity (if ARCHITECTURE §3. hosts in one) with a Material 3 theme:<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.powerapps.<lower>"> <!-- One <uses-permission android:name="..." /> per entry in ARCHITECTURE §1.4 Android permissions --> <application> <!-- One <activity> per ARCHITECTURE §3.<n> that hosts in a dedicated Activity. Theme references generated themes.xml; screenOrientation per ARCHITECTURE §3.<n>. --> <activity android:name=".<Pascal>CaptureActivity" android:theme="@style/Theme.<Pascal>" android:screenOrientation="portrait" android:exported="false" /> </application> </manifest>
-
android/src/main/res/values/themes.xml— Material 3 theme so all components render with proper Material styling, not the bare AppCompat defaults. Without this, generated UIs hit issues like status bar overlap and unthemed buttons.<?xml version="1.0" encoding="utf-8"?> <resources xmlns:tools="http://schemas.android.com/tools"> <style name="Theme.<Pascal>" parent="Theme.Material3.DayNight.NoActionBar"> <!-- System bars: drawn by the OS but content extends behind them; the Activity applies insets. --> <item name="android:statusBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:windowLightStatusBar" tools:targetApi="m">true</item> <item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">true</item> </style> </resources>
-
android/src/main/res/layout/activity_<lower>_capture.xml— root layout uses Material components. Toolbar at top, bounded content area in aMaterialCardView(drawing surface, camera preview, etc.):<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res/auto" android:id="@+id/root" android:layout_width="match_parent" android:layout_height="match_parent" android:background="?attr/colorSurface"> <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:elevation="4dp" app:layout_constraintTop_toTopOf="parent" app:menu="@menu/<lower>_capture_menu" app:navigationIcon="@drawable/ic_close" app:title="<Human-readable name from PRD §2>" /> <com.google.android.material.card.MaterialCardView android:id="@+id/content_card" android:layout_width="0dp" android:layout_height="0dp" android:layout_margin="16dp" app:cardCornerRadius="8dp" app:cardElevation="2dp" app:layout_constraintTop_toBottomOf="@id/toolbar" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"> <!-- The operation-specific surface goes here: drawing View, camera SurfaceView, photo preview, etc. — substituted per ARCHITECTURE §3.<n>'s "Hosting" specification. --> <View android:id="@+id/capture_surface" android:layout_width="match_parent" android:layout_height="match_parent" android:background="?attr/colorSurfaceContainerLowest" /> </com.google.android.material.card.MaterialCardView> </androidx.constraintlayout.widget.ConstraintLayout>
-
android/src/main/res/menu/<lower>_capture_menu.xml— toolbar action items.action_doneis MANDATORY for any capture-flow operation; without it the user has no way to submit. Additional actions (Clear, Undo, etc.) per ARCHITECTURE §3.'s UI actions:<menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res/auto"> <!-- MANDATORY for capture flows. NEVER omit Done — user cannot complete the operation otherwise. --> <item android:id="@+id/action_done" android:title="@string/action_done" app:showAsAction="always" /> <!-- Optional: one <item> per additional toolbar action declared in ARCHITECTURE §3.<n> (e.g. Clear All, Undo). Set app:showAsAction="ifRoom" for non-critical actions. --> </menu>
-
For multi-mode capture operations (pen/eraser, photo/video, etc.): the toolbar / mode-selection row uses
MaterialButtonToggleGroup, not plainButtons. Toggle group provides the active-state visual feedback the user needs to know which mode is currently selected. Example layout fragment to include inactivity_<lower>_capture.xml:<!-- Insert into the toolbar or just below it, when ARCHITECTURE §3.<n> has multiple modes. --> <com.google.android.material.button.MaterialButtonToggleGroup android:id="@+id/mode_toggle_group" android:layout_width="wrap_content" android:layout_height="wrap_content" app:singleSelection="true" app:selectionRequired="true"> <!-- One <Button style="?attr/materialButtonOutlinedStyle"> per mode in ARCHITECTURE §3.<n>. Example for pen/eraser/clear: --> <Button android:id="@+id/mode_pen" android:text="@string/mode_pen" style="?attr/materialButtonOutlinedStyle" /> <Button android:id="@+id/mode_eraser" android:text="@string/mode_eraser" style="?attr/materialButtonOutlinedStyle" /> </com.google.android.material.button.MaterialButtonToggleGroup>
And wire the listener in the Activity's
onCreate:val toggleGroup: MaterialButtonToggleGroup = findViewById(R.id.mode_toggle_group) toggleGroup.check(R.id.mode_pen) // default toggleGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> if (!isChecked) return@addOnButtonCheckedListener when (checkedId) { R.id.mode_pen -> captureSurface.setMode(<Pascal>Mode.PEN) R.id.mode_eraser -> captureSurface.setMode(<Pascal>Mode.ERASER) } }
Without this, the user sees a row of identical-looking buttons and has no idea which mode is active. Confirmed UX-blocking failure mode in v0 extensions.
-
android/src/main/res/values/strings.xml— string resources for the menu items + content descriptions (accessibility):<resources> <string name="action_done">Done</string> <!-- Plus one entry per ARCHITECTURE §3.<n> action; one content-description per accessible element. --> </resources>
-
android/src/main/java/com/powerapps/<lower>/<Pascal>Module.kt— Kotlin native module. Generate complete working code, not TODO placeholders. For each operation, the per-operation §3. block's "Android implementation" sub-section prescribes every implementation decision. Generate the implementation verbatim from §3.:- Class: extends
ReactContextBaseJavaModule. getName()returns"<Pascal>Module"— theModule-suffixed name (matchesNativeModules.<Pascal>Moduleon JS side, the iOS+moduleNamereturn value, and the manifest'sreceivers[].nativeModule). Do NOT strip the suffix — it's the reserved-name dodge (seeshared/ppmplugin-format.md §4).- For each operation, write a
@ReactMethodfunction taking exactly oneReadableMap requestparameter followed byPromise promise— e.g.@ReactMethod fun capturePenInput(request: ReadableMap, promise: Promise). This matches the wrap dispatch contract: the PCF sendsargs: [request](a one-element array) and the proxy doesfn.apply(mod, [request]), so the method receives the request object as its single positional param (ppmplugin-format §2). Read each field offrequest(request.getString("…"),request.getInt("…"), etc.); do NOT expand the request into multiple positional params. The body implements §3.'s Android spec completely:- The hosting (dedicated
ActivityviaIntent, orFragment, or in-place — whatever §3. specifies) - The key API calls in the order §3. specifies (e.g.
View.onTouchEventregistration;Pathaccumulation; stylus pressure handling) - Each Done/Cancel handler as §3. specifies
- The export step as §3.'s "Export" line specifies (e.g. render to
Bitmap, compress to PNG, base64-encode) - Each edge case from §3.'s "Edge cases handled" list, with the exact behavior named
- The hosting (dedicated
- If §3. requires a dedicated
Activity, emit it as a separate.ktfile under the same package (e.g.<Pascal>CaptureActivity.kt) and register it inAndroidManifest.xml. The Activity'sonCreateMUST emit the following UI hygiene boilerplate so the generated UI doesn't suffer from status bar overlap, missing Material theming, or rotation issues (these were repeat issues in v0 extensions):Required imports:override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Edge-to-edge layout; we apply system-bar padding ourselves below. WindowCompat.setDecorFitsSystemWindows(window, false) setContentView(R.layout.activity_<lower>_capture) // Pad root by status/nav bar insets so toolbar doesn't sit UNDER the status bar. // This is the fix for the most common Android UI bug in PAM extensions: // "buttons overlapping with system clock / status icons". ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.root)) { v, insets -> val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) v.setPadding(bars.left, bars.top, bars.right, bars.bottom) WindowInsetsCompat.CONSUMED } // Toolbar with Done/Cancel via Material menu. val toolbar: MaterialToolbar = findViewById(R.id.toolbar) setSupportActionBar(toolbar) toolbar.setNavigationOnClickListener { onCancelled() } // navigation icon = Cancel // Wire up the operation-specific surface (drawing View, camera preview, etc.) // — per ARCHITECTURE §3.<n>'s "Hosting" + "Key APIs and decisions" specification. val captureSurface: <PRD-§3.<n>-View-class> = findViewById(R.id.capture_surface) // ... operation-specific setup per §3.<n> ... } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.<lower>_capture_menu, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_done -> { onDone(); true } // Plus one branch per additional toolbar action declared in §3.<n>. else -> super.onOptionsItemSelected(item) } }
androidx.core.view.WindowCompat,androidx.core.view.ViewCompat,androidx.core.view.WindowInsetsCompat,com.google.android.material.appbar.MaterialToolbar. - Constructor /
init{}safety — the module is built eagerly at bridge startup on a possibly Looper-less thread, so construction MUST NOT throw (ppmplugin-format §5). Keep the constructor cheap: do NOT register system callbacks, acquire camera/sensor/location managers, or do file I/O in the constructor /init{}— defer them to the first@ReactMethodcall (lazy init). If a listener genuinely must be registered at construction, pass an explicitHandler(Looper.getMainLooper())— nevernull(anullor implicitLooperthrowsCan't create handler inside thread that has not called Looper.prepare()), and wrap it in try/catch so a subsystem hiccup leaves the module in a safe default state instead of crashing the host at launch. Example:private val mainHandler = Handler(Looper.getMainLooper()), thentry { cameraManager?.registerTorchCallback(cb, mainHandler) } catch (e: Exception) { /* best-effort */ }. Mirror the same discipline ininvalidate()(unregister in try/catch). - Runtime permissions (dangerous perms only). For any op whose ARCHITECTURE §1.4 lists a dangerous Android permission (
CAMERA,RECORD_AUDIO,ACCESS_FINE/COARSE_LOCATION, …), the@ReactMethodMUST checkContextCompat.checkSelfPermission(...)before calling the API and, if not granted,promise.resolve(errorJson("PERMISSION_DENIED", "<permission> not granted"))(a declared §5 code) rather than let the framework throwSecurityException. Declaring it inAndroidManifest.xmlis necessary but not sufficient — API 23+ requires the runtime grant. currentActivitynullability. Any method that presents UI / starts an Activity MUST guardval activity = currentActivity ?: return promise.resolve(errorJson("NO_ACTIVITY", "no foreground activity"))—currentActivityisnullwhen the app is backgrounded, and dereferencing it NPE-crashes the host.- Threading: heavy work in a coroutine (
CoroutineScope(Dispatchers.IO).launch { ... }) orThread { ... }.start(); UI work back viaHandler(Looper.getMainLooper()).post { ... }orwithContext(Dispatchers.Main). - Response JSON: must mirror iOS exactly. Both platforms must produce byte-identical JSON for the same input — this is non-negotiable, scaffold MUST verify by visual inspection of the two implementations after writing them.
- Helper
private fun errorJson(code: String, message: String): Stringbuilding{"status":"error","error":"<code>","message":"<message>"}viaJSONObject(the same serializer assuccessJson—JSONObject().put("status","error").put("error",code).put("message",message).toString()). Do NOT build it with string interpolation: amessagecontaining a",\, or newline would emit invalid JSON, which the PCF's response parse would surface as a misleadingPARSEinstead of the real failure. Themessageis a human-readable diagnostic: for a caught exception passe.message ?: e.toString(); for a validation failure a specific reason; forUSER_CANCELLEDa short note. This MUST mirror iOS byte-for-byte. Every error path calls this with BOTH a code and a message. - Helper
private fun successJson(result: Map<String, Any?>): Stringbuilding{"status":"ok","result":<json>}viaJSONObject. - Error propagation — every failure resolves the Promise with
errorJson(code, message), never a thrown/uncaught exception. Wrap the operation body (and any coroutine/Thread) in try/catch; on catch,promise.resolve(errorJson("INTERNAL_ERROR", e.message ?: e.toString())). A coroutine that throws without catching, or a path that never resolves the Promise, leaves the maker with a hung control and no code/message.
No TODO placeholders. If a §3. block is incomplete, STOP with the same
NEEDS_CONTEXT:error as iOS — don't write Android code from a vague spec. - Class: extends
-
android/src/main/java/com/powerapps/<lower>/<Pascal>Package.kt—ReactPackageimplementation that registers<Pascal>Module. It MUST have a public no-arg constructor — the wrap runtime instantiates it vialoadClass(packageClass).getDeclaredConstructor().newInstance(), so aReactPackagewith only an arg-ed constructor throwsNoSuchMethodExceptionand the plugin silently fails to load (ppmplugin-format §5). The form below is correct — Kotlin givesclass <Pascal>Package : ReactPackagean implicit no-arg constructor. Do NOT add a constructor with parameters to the package class (the module takesreactContextviacreateNativeModules— that's fine; the package must stay no-arg):class <Pascal>Package : ReactPackage { // no-arg constructor required by the wrap runtime override fun createNativeModules(reactContext: ReactApplicationContext) = listOf<NativeModule>(<Pascal>Module(reactContext)) override fun createViewManagers(reactContext: ReactApplicationContext) = emptyList<ViewManager<*, *>>() }
There is no tests/ directory to write. The first-party SDK track validates conformance with TypeScript type-level fixtures (*.test-d.ts + tsc --noEmit) against the INativeExtension interface — but that layer does not exist here (§3.3), so there is nothing to type-check.
The third-party-control analogue is structural contract verification done by /test-native-extension, not generated source: it greps the native modules' request/response/error usage against each other and against the manifest (the Android getName() ↔ iOS +moduleName ↔ receivers[].nativeModule agreement, the cross-platform JSON parity). That runs as a separate skill on the working tree — this skill does not emit test files for it. No Jest, no tsconfig, no fixtures.
Write .extension-state.md using the template in shared/repo-layout.md §".extension-state.md template". Fill:
- Identity block — from PRD §2 plus the derived names (Capability, Class, Native module =
<Pascal>Module, Android package, PRD path). - Phase:
Last completed: scaffold/Next: /test-native-extension/Status: ok. ## ppmplugin (third-party controls)block — leave at its defaults (Target: unset,Manifest: not written,Android DEX: not built,iOS framework: not built,Bundle: not assembled). The/generate-ppmpluginbuild skills fill it.
If a .extension-state.md already existed and we agreed to "resume" in Step 1.7, only update the Phase block (leave ## ppmplugin untouched — it captures any later build state).
This step exists because the smoke check (Step 4) is structural only — it compiles nothing. Generated Kotlin and Obj-C don't compile-check here; they compile later in
/build-android-binary(Gradle) and/build-ios-binary(xcodebuild). Runtime UI and UX bugs (toolbar overflows, dead-end modals, missing exit paths, asymmetric platform features) don't surface in any compile — only on a real device, often after the engineer has lost an afternoon. The self-critique protocol forces the skill to reason about its own output before declaring done, gate-by-gate, instead of trusting "looks fine."
The protocol lives at shared/self-critique-protocol.md. It is proactive reasoning, not pattern matching — each gate enumerates a category of thing (PRD-listed behaviors, user-reachable controls, mutually-exclusive modes, etc.) and forces specific questions about each enumerated item. Read the protocol file in full before running this step; what follows here is the integration contract.
Fresh-read from disk. Files of interest:
android/src/main/java/com/powerapps/<lower>/<Pascal>Module.kt(its@ReactMethodrequest/response/error surface — this IS the dispatch contract, since there is no TS layer)android/src/main/java/com/powerapps/<lower>/<Pascal>CaptureActivity.ktand any other Activitiesandroid/src/main/res/**/*.xml(layouts, menus, themes, manifest)ios/RCT<Pascal>Module.{h,m}(or.swiftif ARCHITECTURE §1.2 chose Swift)ios/*.podspecpcf/<Pascal>PCF/index.tsandControlManifest.Input.xml(if PCF was part of this run)
For each gate in the protocol — PRD coverage, User journey, Layout feasibility, State coverage, Cross-platform parity, Reversibility, Asymptotic / lifecycle, Spec-drift, Runtime safety & lifecycle, Buildability & bundle-fit feasibility, PCF ↔ native round-trip contract — execute the gate's "enumerate + ask" procedure as described in the protocol file.
Gate 10 (Buildability) is not optional for this track. It re-runs, over the generated code, the same reasoning /design-native-extension-feature Step 7.5 ran over the design — so a dependency, return shape, or construction cost that slipped past design (or was introduced during codegen) is caught here, before /build-android-binary / /build-ios-binary fail minutes later. Gate 11 (PCF round-trip) runs only when a PCF was emitted in this run; when the PCF is generated separately, /generate-pcf-companion Step 5.7 runs it instead.
Do not short-circuit. Each gate enumerates real items (PRD bullet points, user-reachable controls, horizontal container children, etc.) and answers a concrete question per item. Skipping a gate because "intuitively the code looks fine" defeats the purpose — the whole point is to force structured analysis where past intuition has failed.
Aggregate findings across all gates. Each finding follows the protocol's structure:
- Gate: <name>
- File: <path>:<line range>
- Severity: blocker | concern
- Problem: <one sentence>
- Why this is wrong: <one sentence anchored in the gate's principle>
- Suggested fix: <what to change>
- Autofix: applied | proposed | requires human review
For generation context (this skill), the user has no intermediate review checkpoint between code-emit and Step 4 smoke check. The protocol's auto-fix policy translates to:
autofix: applied(mechanical fix, no design choice) → apply silently, log to.extension-state.mdunderAuto-fixes.autofix: proposed(structural fix, unambiguous) → apply, surface in the final summary with a one-line note. The user can read.extension-state.mdto see the diff against their expected output.autofix: requires human review(design judgment) → do NOT modify code. Promote to a top-levelNEEDS_CONTEXTitem in the final summary.
(Contrast with /debug-extension Step 7.5, which gates proposed fixes on explicit user confirm because it sits inside an interactive review cadence.)
If 3.8.4 applied any fixes, repeat from 3.8.1. A fix at one gate can either unblock or surface a new finding elsewhere. Cap the loop at 3 iterations; if findings remain after iteration 3, surface them as concerns regardless of severity.
- All gates clean OR fixed → continue to Step 4. Run can return
DONE. - Concerns remain (acceptable per gate severity rules) → run returns
DONE_WITH_CONCERNS: <one-line summary>. Log every concern in.extension-state.md. - Any blocker remains → run returns
BLOCKED: self-critique found <list>. Surface the report. The scaffold is not shippable until the user resolves blockers.
The protocol's gates are derived from first principles, not from a list of past failures. By construction, it should catch:
| Gate | Bug class |
|---|---|
| PRD coverage (Gate 1) | Behaviors promised in ARCHITECTURE §3. not implemented in code (e.g. "Done button is anchored right" → no Done button at all). |
| User journey (Gate 2) | Modals with no exit, controls that don't respond, error paths that never resolve the Promise. |
| Layout feasibility (Gate 3) | Toolbar overflows on small screens, controls pushed off-edge, primary actions unreachable at narrow widths or high font scales. |
| State coverage (Gate 4) | Mode toggles without visible active state, error codes returned but not displayed, boolean toggles without observable UI. |
| Cross-platform parity (Gate 5) | iOS got 5 features, Android got 3 — with no PRD note explaining why. |
| Reversibility (Gate 6) | Destructive actions without confirmation, modals with one entry and no exit, multi-step flows with no back. |
| Asymptotic / lifecycle (Gate 7) | Rotation loses state but PRD says it shouldn't; primary action not idempotent under fast double-tap. |
| Spec-drift (Gate 8) | Code does things PRD doesn't document (drift the other direction from Gate 1). |
| Runtime safety & lifecycle (Gate 9) | Looper-less constructor Handler crashes the host at launch; a Promise path that never settles hangs the caller; an acquired listener/camera never released. |
| Buildability & bundle-fit (Gate 10) | A dependency that needs compileSdk > 34 / AGP 8.x, an iOS framework that can't ship flat, a non-JSON-serializable return, or a streaming pattern with no channel — caught before the toolchain fails. |
| PCF ↔ native round-trip (Gate 11) | PCF composite key ≠ manifest receiver, cordova.exec instead of sendAsync, a pre-stringified envelope, a bare args object, or a success path that doesn't unwrap the wrap message container — all silent-on-device otherwise. |
When a new failure mode is observed, the question to ask is "which gate should have caught this?" If the answer is "none of them" — that's a signal the protocol needs a new gate (or an existing gate's enumeration list needs expanding). Do NOT add a one-off pattern check that doesn't generalize.
This step is LLM reasoning over generated source. It is not a real compiler. The protocol is structured enough to force enumeration (and stop "looks fine" from being a valid answer), but it depends on the reasoning at each gate actually being careful. Pair with /build-android-binary / /build-ios-binary (real compile) and on-device verification for runtime errors the protocol can't simulate.
There is no TypeScript layer to compile in this track, so the smoke check does NOT run tsc. Native code is compiled later by /build-android-binary and /build-ios-binary — not here. The smoke check is a fast structural pass (each is a few seconds):
OS-neutral: the pnpm install below is a real, cross-platform command; the symbol-agreement check is read+parse — run it with the built-in Grep tool, not shell grep (the bash is illustrative; grep -R isn't on Windows).
# 1. (Optional) seed the dev-only devDeps from the public npm registry. Skip if Node/pnpm absent.
pnpm install # resolves react + react-native (RN headers + react-android coordinate) for the build skills
# 2. Structural self-check (no compile) — use the Grep tool, not shell grep:
# confirm +moduleName returns <Pascal>Module in ios/ AND getName() == "<Pascal>Module" in android/.
grep -R '+ (NSString *)moduleName' ios/ && grep -R 'return @"<Pascal>Module"' ios/ # iOS +moduleName == <Pascal>Module
grep -R 'getName()' android/ | grep '"<Pascal>Module"' # Android getName() == <Pascal>ModuleThe pnpm install here pulls only from the public npm registry and requires no organization-specific package-feed authentication. It's optional: if it fails or Node/pnpm are absent, note it and continue (the build skills will seed devDeps when they run).
Ordering precondition: ensure .gitignore is on disk first (it is, per §3.1) — pnpm install populates node_modules/, and the .gitignore keeps it (and ppmplugin/) out of the repo when you later commit. This skill doesn't stage or commit anything, but a missing .gitignore would leave node_modules/ showing as untracked. If §3.1 was skipped (e.g. resumed mid-flow), re-run from §3.1 before the smoke check.
If a step fails:
- For
pnpm installfailures: this is non-fatal here (only the build skills truly need the devDeps). Print the failing line, notedevDeps seed deferred, and continue. - For the symbol-agreement greps: a mismatch means iOS
+moduleName, AndroidgetName(), and the intended manifestreceivers[].nativeModulehave drifted. Fix so all three read<Pascal>Moduleand re-run. This is a real gate — the wrap host can't dispatch if they disagree.
DO NOT mark the scaffold as complete in .extension-state.md if the symbol-agreement check fails. Set Status: blocked with the failure reason.
Print:
Scaffold complete
─────────────────
Directory: <cwd> (<N> files written — git untouched; commit when you're ready)
Native module: <Pascal>Module (== ./manifest.json receivers[].nativeModule — both authored here, in sync)
Dispatch contract: ./manifest.json written (committed source of truth; PCF + build stage read it)
Smoke check: devDeps seed <✓ | deferred> | native symbol agreement ✓
State file: ./.extension-state.md → Phase: scaffold / Next: /test-native-extension
Next steps
──────────
1. Review the generated implementations in ios/RCT<Pascal>Module.m and android/.../<Pascal>Module.kt against ARCHITECTURE §3.<n>. Customize cosmetic details (button styling, modal chrome) if you want — but the framework wiring should be working out of the box.
2. Run /test-native-extension for the structural contract pre-flight (native module shape, Android getName ↔ iOS +moduleName ↔ ./manifest.json agreement, cross-platform JSON parity). Native iOS / Android compile is NOT validated here — it happens in /build-android-binary and /build-ios-binary.
3. Run /generate-pcf-companion to generate the Companion PCF under pcf/ — it reads ./manifest.json for the composite key so the PCF and module stay aligned.
4. When ready to ship, run /generate-ppmplugin — it validates + stages ./manifest.json, compiles the native binaries, and assembles the .ppmplugin bundle for upload to Dataverse.
Per shared/shared-instructions.md §9.1, use AskUserQuestion with all plausible next skills as options (not a Yes/No), and include an escape-hatch option.
Question: "What would you like to do next?"
Header: "Next step"
Options:
1. "Run /generate-pcf-companion"
description: "Next major scaffold step in the build flow. Runs `pac pcf init` under pcf/ and writes the Companion PCF that dispatches on the composite key <name>/<receiver> read from ./manifest.json (authored here), cross-checked against ARCHITECTURE §4 + §8 + §9."
2. "Run /test-native-extension"
description: "Structural contract pre-flight (native module shape, Android getName ↔ iOS +moduleName ↔ ./manifest.json agreement, cross-platform JSON parity). Quick sanity check before adding more code. No compile."
3. "Run /generate-ppmplugin"
description: "Build the deliverable: validates + stages ./manifest.json, compiles the native binaries (DEX / iOS framework), and assembles the .ppmplugin bundle. Usually run after the PCF exists and the contract is settled."
4. "Stay — I'll review the generated code first"
description: "Skill exits. Run git diff to inspect the scaffold and decide what to run next yourself."
For options 1, 2, or 3: invoke that skill via the Skill tool in the same turn — selecting the option IS the request to run it (Execute, don't describe — shared-instructions §9.1 HARD RULE). Do NOT stop and tell the user to run it themselves. The invoked skill runs its own Step 1 prereq check + gates, so just hand off to it.
For option 4 (stay): print one line: Scaffold complete. Run any of the suggested skills when you're ready. Then proceed to return-status.
The literal first line of your final message MUST be one of:
| Code | Meaning |
|---|---|
DONE |
All files written, smoke check (native symbol agreement) passed, state file updated. Next: /test-native-extension. |
DONE_WITH_CONCERNS: <list> |
Files written but the smoke check raised non-fatal warnings (e.g. devDeps seed deferred), OR a // TODO count higher than expected (PRD operations that are particularly complex), OR a derived-name collision was deferred to the user. |
NEEDS_CONTEXT: <missing> |
A required PRD section was incomplete; couldn't proceed without re-running /design-native-extension-feature on that section. |
BLOCKED: <reason> |
Prereq failed (git missing), a reserved-name / generic-noun collision on the native module symbol could not be resolved, or the native symbol-agreement check failed and could not be auto-fixed. |
After the first line, blank line, then the human-readable summary.
This skill generates the extension repo from a PRD. It's not a linter; it doesn't audit existing working code against the new template. When run in a directory that already has a scaffold, the regenerate / resume / abort gate asks before overwriting — and "regenerate" is destructive (loses any manual edits to generated files).
A diff between "what we'd generate now" and "what exists" is NOT a list of defects. Existing code that builds (in /build-android-binary / /build-ios-binary) and produces correct runtime behavior is fine even if the code shape differs from the current template. Flag only:
- Code that won't compile in the build skills (malformed Kotlin / Obj-C)
- Code that violates the dispatch contract such that runtime breaks (native module symbol that doesn't match
receivers[].nativeModule, wrong method signature the host can't dispatch, wrong response shape that PAM can't deliver) - Cross-platform drift (iOS and Android emitting different request field names or response shapes for the same operation)
A locally-defined helper that does the same job as a template helper is NOT a defect. A method that was originally generated with a // TODO marker and has since been filled in by the engineer is the EXPECTED state — don't flag it as "different from the template."
- There is NO TS
INativeExtensionlayer. The contract is the manifest's runtime dispatch (<name>/<receiver>→NativeModules.<Pascal>Module.<method>,shared/ppmplugin-format.md §2). Do NOT generatesrc/<Pascal>Extension.ts,handleMessageAsync, or asendAsynctransport — they're SDK-era leakage that/audit-ppmpluginrejects. - The native module symbol MUST be
<Pascal>Moduleacross all runtime surfaces. iOS+moduleNamereturns@"<Pascal>Module", AndroidgetName() = "<Pascal>Module", and the./manifest.jsonreceivers[].nativeModule(authored here in §3.1, alongside these modules) MUST agree, and<Pascal>Moduleis what JS sees asNativeModules.<Pascal>Module. The Obj-C class name staysRCT<Pascal>Moduleand matchesentrypoints.ios.moduleClass. Mismatch = the wrap host's dispatch finds nothing. - The native module symbol MUST pass the validator's reserved-prefix / reserved-name rules (
shared/ppmplugin-format.md §4). TheModulesuffix is the structural dodge for bare reserved names (DeviceInfo→DeviceInfoModule); do NOT strip it. If the derived symbol still hits a reserved prefix or the known reserved-name subset, STOP and have the user pick a vendor-prefixed class. - iOS and Android JSON response shapes MUST be byte-identical for the same input. Both implementations are derived from the same §3. response shape. Drift here causes maker-side branching bugs that are hard to diagnose.
- Request/response field names MUST match what the manifest + PCF expect. The field names each native method parses and emits must match the request/response shapes in ARCHITECTURE §4 (which the manifest and PCF both derive from). Drift = the PCF sends/reads the wrong fields.
- The native symbol-agreement smoke check (Step 4) MUST pass before marking
.extension-state.mdPhase =scaffold. On failure, setStatus: blockedwith the reason. - No invented APIs. If §3. specifies a framework/API you're uncertain exists on the named min-OS version, STOP and flag. Don't ship code referencing a method that doesn't exist.
These describe the cleanest shape for newly-generated code. They're how this skill renders fresh output. Existing code that achieves the same outcome differently is not in violation — don't flag stylistic differences as defects when auditing.
- Prefer the
Module-suffixed native symbol (<Pascal>Module) consistently across iOS, Android, and the future manifest. It satisfies the reserved-name rule and keeps the three dispatch surfaces in lockstep. - Prefer complete implementations from ARCHITECTURE §3. rather than
// TODO: implement using <framework>placeholders. Generated code should compile and run on the happy path out of the gate. - Prefer the pinned
react-nativedevDep frompackage.jsonfor the build skills to resolve RN headers / thereact-androidcoordinate, rather thanlatest. Reproducible builds.
When emitting new code from scratch, the skill follows the template above. Strict requirements:
- No file overwrites without explicit confirm — the regenerate / resume / abort gate at Step 2 is real.
- STOP with
NEEDS_CONTEXTonly when blocked. Genuine blockers: PRD §2 missing (no class name → no scaffold), PRD §4 missing (no operations → nothing to generate), or a reserved-name / generic-noun collision on the native module symbol the user won't resolve. Non-blockers that should NOT STOP: ARCHITECTURE §3. missing fine implementation detail (use a reasonable default + annotate inline), ARCHITECTURE §8 missing UX for a specific code (use default mapping), framework choice in ARCHITECTURE §1.2 / §1.3 missing a deployment-target detail (use the framework's documented minimum). - Smoke check is a real gate. If the native symbol-agreement check (Step 4) fails,
.extension-state.mdPhase stays belowscaffoldwithStatus: blocked. (The optionalpnpm installis non-fatal — deferring the devDeps seed doesn't block.) - Every function/method called from generated native code MUST be defined in the same file or imported. This is enforced by Step 3.8 (self-review). When writing
this.createFoo()in a Kotlin Activity, also writeprivate fun createFoo() { ... }in the same file. When writing[self setupBar]in an Obj-C module, also write- (void)setupBar { ... }. The smoke check (Step 4) compiles nothing — native code errors surface in/build-android-binary//build-ios-binary. The self-review catches the "called but never defined" class of bug at generation time, not later when an engineer runs the binary build. - Capture / modal-presentation operations MUST include a Done action. Any operation whose pattern is one-shot OR two-way with a confirm-and-return UX (drawing capture, photo capture, signature, scanner with manual confirm, etc.) MUST emit a toolbar Done action that triggers result submission. Cancel alone is not sufficient — the user has no way to complete the operation. On Android:
R.menu.<lower>_capture_menuMUST include<item android:id="@+id/action_done">andonOptionsItemSelectedhandles it. On iOS: theUINavigationItemMUST setrightBarButtonItemto a DoneUIBarButtonItem. Non-negotiable. - Multi-mode operations (mutually exclusive UI modes — e.g. capture/playback, photo/video, edit/preview) MUST include active-state visual feedback. On Android: use
MaterialButtonToggleGroupwithapp:singleSelection="true"for the mode toggle row; toggled-on button showscolorPrimaryContainer, toggled-off transparent. On iOS: rely on the platform's tool-picker (e.g. PencilKit,AVCaptureSession's built-in UI) which handles this natively, OR useUISegmentedControlfor custom toggles. Without active-state feedback, the user can't tell which mode is selected.
Allowed in generated code (these are not defects):
// TODO:or// Customize:comments for items that legitimately need engineer judgment — non-trivial UI details that ARCHITECTURE §3. doesn't specify (button corner radius, animation curves, edge case handling that the PRD intentionally defers). Clear annotated TODOs beat fabricated defaults.- Reasonable defaults for unspecified detail. If ARCHITECTURE §3. says "Done button right side" but doesn't specify the button's tint color, use the platform default (e.g.
.systemBlueon iOS,?attr/colorAccenton Android). Add a// Customize: tint per designcomment if it's likely the engineer will want to change it. - Fallback error responses when something at runtime doesn't match the contract. If a native method receives args it can't parse, resolving the promise with an error JSON (
{"status":"error","error":"INVALID_INPUT","message":"<the offending field / parse reason>"}) via theerrorJson(code, message)helper is the right answer — not a thrown exception. Always include themessage— same forINTERNAL_ERRORcatch-alls (carry the underlying reason).
Other operational rules:
- No reference-repo fetch at runtime — structural patterns are encoded in this SKILL.md +
shared/*.md. - No PCF generation (that's
/generate-pcf-companion). - No manifest, binary build, or bundle assembly (that's
/generate-ppmpluginand its stages). - No
src/TS layer, no tests (there's nothing to type-check; structural verification is/test-native-extension). - No remote, feed, or registry access — this track is self-contained and local-only.
These are correctness-positive — make the control resilient against unexpected runtime conditions:
- Construction must not throw (crash-at-launch guard). The module is built eagerly at bridge startup on a possibly Looper-less thread (
ppmplugin-format §5); an uncaught throw in the constructor /init{}(Android) orinit(iOS) takes down the whole host before any UI. Defer listener/hardware registration to first method call; pass an explicitHandler(Looper.getMainLooper())(nevernull); wrap unavoidable init in try/catch. See §3.4 (iOS) / §3.5 (Android). - Runtime permission +
currentActivityguards (Android): check a dangerous permission before the API call and resolvePERMISSION_DENIEDon denial; guardcurrentActivity != nullbefore presenting UI and resolveNO_ACTIVITYon null — never letSecurityException/ NPE crash the host. See §3.5. - Native methods try/catch around argument parsing → resolve with
{status: 'error', error: 'INVALID_INPUT', message: '<offending field / parse reason>'}instead of throwing. The wrap bridge expects a resolved promise (an error JSON), not a rejected/thrown one. - Native error responses go through the
errorJson(code, message)helper (code + human-readable reason), not as thrown exceptions. The native side resolves the Promise with an error JSON string carrying both fields; the wrap bridge delivers it to the PCF verbatim (which surfacesmessageas itsErrorMessageoutput). - Native main-thread guarding for UI presentation (
dispatch_async(dispatch_get_main_queue(), ...)on iOS,Handler(Looper.getMainLooper()).post { ... }on Android). Presenting UI off main thread crashes; the dispatch is required. - Unknown error codes from native that the PCF didn't anticipate → pass through to the response verbatim. Don't drop or remap; let the maker's Power Fx formula see the actual code.
These are runtime safety nets. They're allowed/encouraged regardless of other style decisions.