Skip to content

Latest commit

 

History

History
775 lines (604 loc) · 72.4 KB

File metadata and controls

775 lines (604 loc) · 72.4 KB
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

/generate-native-extension

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.


Step 1 — Read the shared docs and the PRD

Before any write:

  1. Read shared/shared-instructions.md.

  2. 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-only package.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-extension check from prereq-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.2 before 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 git is missing, print its → Fix: line and STOP. Node/pnpm are optional here — if absent, note them as n/a (devDeps seed deferred to build skills) rather than failing.

  3. Read shared/naming-conventions.md — the derived-identifier table is canonical, including the Module-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.

  4. 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 TS INativeExtension layer mediating; see §3.3 below.

  5. Read shared/repo-layout.md — the exact tree, file list, and package.json shape to emit.

  6. Read ./PRD.md from the current working directory. If missing or empty, STOP with BLOCKED: PRD.md not found — run /design-native-extension-feature first.

  7. Read ./.extension-state.md if present. If the phase shows scaffold-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.


Step 2 — Confirm the scaffold plan with the user

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-feature to adjust.
  • Cancel

Step 3 — Generate the files

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.

3.1 Top-level repo files

Write:

  • .gitignore — emit exactly the following entries:

    • Node: node_modules/, dist/, build/
    • .ppmplugin build staging — MANDATORY: ppmplugin/ (the gitignored staging dir where /generate-ppmplugin writes the staged copy of the manifest, the binaries, and the final bundle — never committed. NOTE: the committed source-of-truth manifest.json lives at the repo root (./manifest.json, written below), NOT under ppmplugin/ — do not gitignore it; see shared/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-feature Step 8.0 for visual review; regenerated each design iteration; not a source-of-truth artifact)
  • package.json — per the dev-only shape in shared/repo-layout.md §"package.json shape (dev-only)". Fill in name (a plain local name, e.g. <kebab>-control) and description from the PRD. version starts at 0.1.0. Set "private": true.

    This manifest is never published — no publishConfig, no feed registry, no files array, no main/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-native devDep supplies the iOS headers (/build-ios-binary) and pins the react-android coordinate 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, per shared/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. class PenInputpen-input.
    • version = the package.json version (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>] }. nativeModule MUST equal Android getName() and the iOS +moduleName return value — the Module-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" }

    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 any entrypoints.js / extension.hbc / extensionClassName / jsLayer field — those are SDK-era leakage /audit-ppmplugin rejects. (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 .ppmplugin bundle + PCF companion), "Build" (run /generate-ppmplugin to produce the .ppmplugin), "Architecture" (a Mermaid-or-ASCII diagram of Canvas formula → PCF → wrap-bridge → NativeModules.<Pascal>Module), "Development" (pnpm install to seed devDeps; native code is compiled by the build skills, not here), "Reference docs" (link to shared/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.

3.2 The podspec (optional, at repo root)

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.
end

3.3 No TypeScript layer — the dispatch contract

This 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 args object) and response shape (the object the promise resolves with) from ARCHITECTURE §4 are realized directly in the native @ReactMethod / RCT_EXPORT_METHOD signatures + 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 TS handleMessageAsync would have done are emitted inside each native method instead. That dispatch logic is the valuable part this skill generates.
  • The manifest.json that declares name, receivers[].method, and receivers[].nativeModule (= <Pascal>Module) is written by this skill at the repo root (§3.1) — the native module symbols it emits and the manifest's receivers[] are authored together, so they can't disagree. /generate-ppmplugin-manifest later 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-readable message — there is no TS error-union type to declare. These codes are the stable strings from the canonical catalog shared/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: the error code to branch on, the message to surface as its ErrorMessage output.

3.4 iOS (ios/)

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 +load and _RCTRegisterModule, which is not visible to the framework's dlopen flat namespace. Instead emit a class method + (NSString *)moduleName { return @"<Pascal>Module"; } — the Module-suffixed name. The Obj-C class name stays RCT<Pascal>Module (matching entrypoints.ios.moduleClass), while +moduleName MUST equal the manifest's receivers[].nativeModule and JS sees NativeModules.<Pascal>Module. Do NOT strip the suffix.
    • + (BOOL)requiresMainQueueSetup returning NO unless any §3. requires main-thread init.
    • init safety — the module is instantiated eagerly at load via [cls new], so init MUST NOT throw or do heavy/side-effecting work (ppmplugin-format §5). Do not acquire hardware, register NSNotification/KVO observers, or touch AVCaptureSession/CLLocationManager in init — defer to the first RCT_EXPORT_METHOD call (lazy), and wrap any unavoidable init work in @try/@catch. An uncaught exception in init crashes the host at launch (the iOS analogue of the Android Looper-less-Handler crash).
    • For each operation, write an RCT_EXPORT_METHOD taking exactly one NSDictionary *request parameter, then RCTPromiseResolveBlock 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 sends args: [request] (a one-element array) spread positionally, so the method's first positional param is the request dictionary (ppmplugin-format §2). Read fields off request (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 UIViewController subclass or inline VC + presentViewController:animated:completion:). The presented VC's viewDidLoad MUST constrain custom content views to view.safeAreaLayoutGuide, not view directly — this prevents content from intruding under the notch / Dynamic Island / home indicator. Set modalPresentationStyle = UIModalPresentationFullScreen (or .pageSheet per ARCHITECTURE §3.). Add a UINavigationBar with Done / Cancel UIBarButtonItems for clear action affordance — same Material-toolbar-equivalent pattern as Android.
      • The key API calls in the order §3. specifies (e.g. PKCanvasView init, PKToolPicker attachment, 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")
    • Threading: background work on dispatch_get_global_queue; UI presentation on dispatch_get_main_queue. Long-running native work must not block the JS thread.
    • Error helper: emit - (NSString *)errorJsonWithCode:(NSString *)code message:(NSString *)message that builds the dict @{@"status": @"error", @"error": code, @"message": (message ?: @"")} and serializes it via NSJSONSerialization — the SAME serializer as the success helper. Do NOT use stringWithFormat: a message (or code) containing a ", \, or newline would emit invalid JSON, which the PCF's response parse would surface as a misleading PARSE instead of the real failure — defeating the whole point of the message. The message is a human-readable diagnostic that makes the failure debuggable from the PCF without a native debugger: for a caught exception pass error.localizedDescription; for a validation failure a specific reason (e.g. @"missing required field 'uri'"); for USER_CANCELLED a short note. Every error path calls this with BOTH a code and a message — never a bare code.
    • Success helper: emit - (NSString *)successJsonWith:(NSDictionary *)result that builds {"status":"ok","result":<result>} via NSJSONSerialization.
    • Error propagation — wrap the operation body so every failure reaches the PCF with a code AND a message. Any framework/runtime failure must resolve with errorJsonWithCode:message: carrying a specific code and reason — never throw an uncaught Obj-C exception, crash, or resolve empty. Use @try/@catch around risky synchronous work and resolve the @catch with INTERNAL_ERROR plus exception.reason.
    • UI hygiene boilerplate for each presented UIViewController's viewDidLoad (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 *)topViewController if 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 with NEEDS_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.

3.5 Android (android/)

Write:

  • android/build.gradlelibrary-only gradle config. The module is consumed by the host's managed build, which provides the root project setup. (For the standalone .ppmplugin build, /build-android-binary compiles from a throwaway staged copy with pinned versions — this canonical file is never edited; see shared/ppmplugin-format.md §5.) Do NOT emit a buildscript { ... }, 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 responsibility
    • android/gradle.properties — root project's properties; android.useAndroidX and android.enableJetifier are supplied ambiently by the host (and generated into the staged copy by /build-android-binary), not by the library
    • android/gradlew + android/gradle/wrapper/* — the Gradle wrapper; library modules don't need their own wrapper
    • Any top-level buildscript { ext, repositories, dependencies (classpath) } block in build.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-binary for the .ppmplugin); it doesn't have to compile in isolation. Don't add a top-level buildscript { ... } / allprojects { ... } block — the host provides those. Standalone ./gradlew assembleDebug against this directory is not a validation path we support (native compile happens in /build-android-binary, not here — see shared/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 a MaterialCardView (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_done is 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 plain Buttons. Toggle group provides the active-state visual feedback the user needs to know which mode is currently selected. Example layout fragment to include in activity_<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" — the Module-suffixed name (matches NativeModules.<Pascal>Module on JS side, the iOS +moduleName return value, and the manifest's receivers[].nativeModule). Do NOT strip the suffix — it's the reserved-name dodge (see shared/ppmplugin-format.md §4).
    • For each operation, write a @ReactMethod function taking exactly one ReadableMap request parameter followed by Promise promise — e.g. @ReactMethod fun capturePenInput(request: ReadableMap, promise: Promise). This matches the wrap dispatch contract: the PCF sends args: [request] (a one-element array) and the proxy does fn.apply(mod, [request]), so the method receives the request object as its single positional param (ppmplugin-format §2). Read each field off request (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 Activity via Intent, or Fragment, or in-place — whatever §3. specifies)
      • The key API calls in the order §3. specifies (e.g. View.onTouchEvent registration; Path accumulation; 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
    • If §3. requires a dedicated Activity, emit it as a separate .kt file under the same package (e.g. <Pascal>CaptureActivity.kt) and register it in AndroidManifest.xml. The Activity's onCreate MUST 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):
      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)
          }
      }
      Required imports: 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 @ReactMethod call (lazy init). If a listener genuinely must be registered at construction, pass an explicit Handler(Looper.getMainLooper()) — never null (a null or implicit Looper throws Can'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()), then try { cameraManager?.registerTorchCallback(cb, mainHandler) } catch (e: Exception) { /* best-effort */ }. Mirror the same discipline in invalidate() (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 @ReactMethod MUST check ContextCompat.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 throw SecurityException. Declaring it in AndroidManifest.xml is necessary but not sufficient — API 23+ requires the runtime grant.
    • currentActivity nullability. Any method that presents UI / starts an Activity MUST guard val activity = currentActivity ?: return promise.resolve(errorJson("NO_ACTIVITY", "no foreground activity"))currentActivity is null when the app is backgrounded, and dereferencing it NPE-crashes the host.
    • Threading: heavy work in a coroutine (CoroutineScope(Dispatchers.IO).launch { ... }) or Thread { ... }.start(); UI work back via Handler(Looper.getMainLooper()).post { ... } or withContext(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): String building {"status":"error","error":"<code>","message":"<message>"} via JSONObject (the same serializer as successJsonJSONObject().put("status","error").put("error",code).put("message",message).toString()). Do NOT build it with string interpolation: a message containing a ", \, or newline would emit invalid JSON, which the PCF's response parse would surface as a misleading PARSE instead of the real failure. The message is a human-readable diagnostic: for a caught exception pass e.message ?: e.toString(); for a validation failure a specific reason; for USER_CANCELLED a 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?>): String building {"status":"ok","result":<json>} via JSONObject.
    • 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.

  • android/src/main/java/com/powerapps/<lower>/<Pascal>Package.ktReactPackage implementation that registers <Pascal>Module. It MUST have a public no-arg constructor — the wrap runtime instantiates it via loadClass(packageClass).getDeclaredConstructor().newInstance(), so a ReactPackage with only an arg-ed constructor throws NoSuchMethodException and the plugin silently fails to load (ppmplugin-format §5). The form below is correct — Kotlin gives class <Pascal>Package : ReactPackage an implicit no-arg constructor. Do NOT add a constructor with parameters to the package class (the module takes reactContext via createNativeModules — 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<*, *>>()
    }

3.6 Tests — none generated in this track

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 +moduleNamereceivers[].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.

3.7 State file

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-ppmplugin build 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).


Step 3.8 — Self-critique against the proactive protocol

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.

3.8.1 Re-read every file emitted in this run

Fresh-read from disk. Files of interest:

  • android/src/main/java/com/powerapps/<lower>/<Pascal>Module.kt (its @ReactMethod request/response/error surface — this IS the dispatch contract, since there is no TS layer)
  • android/src/main/java/com/powerapps/<lower>/<Pascal>CaptureActivity.kt and any other Activities
  • android/src/main/res/**/*.xml (layouts, menus, themes, manifest)
  • ios/RCT<Pascal>Module.{h,m} (or .swift if ARCHITECTURE §1.2 chose Swift)
  • ios/*.podspec
  • pcf/<Pascal>PCF/index.ts and ControlManifest.Input.xml (if PCF was part of this run)

3.8.2 Walk the protocol gates in order

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.

3.8.3 Build the self-critique report

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

3.8.4 Apply auto-fixes inline

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.md under Auto-fixes.
  • autofix: proposed (structural fix, unambiguous) → apply, surface in the final summary with a one-line note. The user can read .extension-state.md to see the diff against their expected output.
  • autofix: requires human review (design judgment) → do NOT modify code. Promote to a top-level NEEDS_CONTEXT item 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.)

3.8.5 Re-loop after fixes

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.

3.8.6 Return-status impact

  • 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.

What this step specifically catches

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.


Step 4 — Run a smoke check

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>Module

The 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 install failures: this is non-fatal here (only the build skills truly need the devDeps). Print the failing line, note devDeps seed deferred, and continue.
  • For the symbol-agreement greps: a mismatch means iOS +moduleName, Android getName(), and the intended manifest receivers[].nativeModule have drifted. Fix so all three read <Pascal>Module and 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.


Step 5 — Summary

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.

Step 6 — Offer next-step skills

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.


Return-status protocol

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.


Scope of this skill — generating vs auditing

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."

Hard rules — correctness (these must be true for the control to work)

  • There is NO TS INativeExtension layer. The contract is the manifest's runtime dispatch (<name>/<receiver>NativeModules.<Pascal>Module.<method>, shared/ppmplugin-format.md §2). Do NOT generate src/<Pascal>Extension.ts, handleMessageAsync, or a sendAsync transport — they're SDK-era leakage that /audit-ppmplugin rejects.
  • The native module symbol MUST be <Pascal>Module across all runtime surfaces. iOS +moduleName returns @"<Pascal>Module", Android getName() = "<Pascal>Module", and the ./manifest.json receivers[].nativeModule (authored here in §3.1, alongside these modules) MUST agree, and <Pascal>Module is what JS sees as NativeModules.<Pascal>Module. The Obj-C class name stays RCT<Pascal>Module and matches entrypoints.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). The Module suffix is the structural dodge for bare reserved names (DeviceInfoDeviceInfoModule); 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.md Phase = scaffold. On failure, set Status: blocked with 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.

Recommended template style (preferences for new scaffolds; existing working code is fine)

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-native devDep from package.json for the build skills to resolve RN headers / the react-android coordinate, rather than latest. Reproducible builds.

Things the skill enforces at generation time

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_CONTEXT only 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.md Phase stays below scaffold with Status: blocked. (The optional pnpm install is 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 write private 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_menu MUST include <item android:id="@+id/action_done"> and onOptionsItemSelected handles it. On iOS: the UINavigationItem MUST set rightBarButtonItem to a Done UIBarButtonItem. 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 MaterialButtonToggleGroup with app:singleSelection="true" for the mode toggle row; toggled-on button shows colorPrimaryContainer, 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 use UISegmentedControl for 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. .systemBlue on iOS, ?attr/colorAccent on Android). Add a // Customize: tint per design comment 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 the errorJson(code, message) helper is the right answer — not a thrown exception. Always include the message — same for INTERNAL_ERROR catch-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-ppmplugin and 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.

Runtime fallbacks the generated control SHOULD have

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) or init (iOS) takes down the whole host before any UI. Defer listener/hardware registration to first method call; pass an explicit Handler(Looper.getMainLooper()) (never null); wrap unavoidable init in try/catch. See §3.4 (iOS) / §3.5 (Android).
  • Runtime permission + currentActivity guards (Android): check a dangerous permission before the API call and resolve PERMISSION_DENIED on denial; guard currentActivity != null before presenting UI and resolve NO_ACTIVITY on null — never let SecurityException / 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 surfaces message as its ErrorMessage output).
  • 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.