generate-native-extension · git:20260824.3e1606b · 2026-08-24 · sha256 bbfe4aabb5cf23c7

generate-native-extension git:20260824.3e1606bA

Immutable. This exact content is served forever at /api/v1/blob/bbfe4aabb5cf23c7.

---
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`](../../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`](../../shared/ppmplugin-format.md)). 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`](../../shared/shared-instructions.md).
2. Apply the **per-skill minimal prereq policy** ([`shared-instructions.md §1.5`](../../shared/shared-instructions.md)). This track is **self-contained** ([`shared-instructions §0a`](../../shared/shared-instructions.md)) 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`](../../shared/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`](../../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`](../../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`](../../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`](../../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`](../../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`](../../shared/ppmplugin-format.md))
  - 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`](../../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:
  ```json
  {
    "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`](../../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 `PenInput` → `pen-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:
  ```markdown
  # 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:

```ruby
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`](../../shared/ppmplugin-format.md)): 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`](../../shared/ppmplugin-format.md)).
- 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`](../../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.<n> block prescribes every implementation decision (framework, hosting, key APIs, export shape, edge case handling). Generate the implementation verbatim from §3.<n>:

  - Imports: include `RCT<Pascal>Module.h`, `UIKit`, plus every framework named in ARCHITECTURE §3.<n>'s "Framework / class" field for any operation (e.g. `#import <PencilKit/PencilKit.h>` if any §3.<n> 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.<n> 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`](../../shared/ppmplugin-format.md)). 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`](../../shared/ppmplugin-format.md)). 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.<n>'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.<n>). Add a `UINavigationBar` with Done / Cancel `UIBarButtonItem`s for clear action affordance — same Material-toolbar-equivalent pattern as Android.
    - The key API calls in the order §3.<n> specifies (e.g. `PKCanvasView` init, `PKToolPicker` attachment, drawing capture)
    - Each Done/Cancel/dismiss handler as §3.<n> specifies
    - The export step as §3.<n>'s "Export" line specifies (e.g. `drawing.image(from: canvas.bounds, scale: 2.0)` → PNG → base64)
    - Each edge case from §3.<n>'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):
    ```objc
    - (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:
    ```objc
    - (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.<n> 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.gradle`** — **library-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`](../../shared/ppmplugin-format.md).) 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):
  ```gradle
  // <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`](../../shared/ppmplugin-format.md)).

- **`android/src/main/AndroidManifest.xml`** — registers permissions from ARCHITECTURE §1.4 AND the dedicated capture Activity (if ARCHITECTURE §3.<n> hosts in one) with a Material 3 theme:
  ```xml
  <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
  <?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
  <?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.<n>'s UI actions:
  ```xml
  <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 `Button`s. 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`:
  ```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`:
  ```kotlin
  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):
  ```xml
  <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.<n> block's "Android implementation" sub-section prescribes every implementation decision. Generate the implementation verbatim from §3.<n>:
  - 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`](../../shared/ppmplugin-format.md)).
  - 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`](../../shared/ppmplugin-format.md)). Read each field off `request` (`request.getString("…")`, `request.getInt("…")`, etc.); do NOT expand the request into multiple positional params. The body implements §3.<n>'s Android spec **completely**:
    - The hosting (dedicated `Activity` via `Intent`, or `Fragment`, or in-place — whatever §3.<n> specifies)
    - The key API calls in the order §3.<n> specifies (e.g. `View.onTouchEvent` registration; `Path` accumulation; stylus pressure handling)
    - Each Done/Cancel handler as §3.<n> specifies
    - The export step as §3.<n>'s "Export" line specifies (e.g. render to `Bitmap`, compress to PNG, base64-encode)
    - Each edge case from §3.<n>'s "Edge cases handled" list, with the exact behavior named
  - If §3.<n> 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):
    ```kotlin
    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`](../../shared/ppmplugin-format.md)). 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 `successJson` — `JSONObject().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.<n> 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.kt`** — `ReactPackage` 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`](../../shared/ppmplugin-format.md)). 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):
  ```kotlin
  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`](../test-native-extension/SKILL.md)**, 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.**

### 3.7 State file

Write **`.extension-state.md`** using the template in [`shared/repo-layout.md`](../../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`](../../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.<n> 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).

```bash
# 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`](../../shared/ppmplugin-format.md)). 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`](../../shared/ppmplugin-format.md)). The `Module` suffix 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.<n> 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.<n> 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.<n>** 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.<n> 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.<n> 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.<n> 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`](../../shared/ppmplugin-format.md)); 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.