Stages the manifest.json that goes inside a .ppmplugin bundle — the small descriptor the wrap runtime and upload service read to identify the plugin and route calls into it. In the normal flow the committed ./manifest.json already exists (authored by /generate-native-extension next to the code it describes); this stage's job is to validate it, reconcile its entrypoints to the shipped target(s), and write the staged build copy ppmplugin/staging/manifest.json. It is the "get the strings right" gate: it runs instantly, needs no build tools, and catches common upload failures before any Android build. If the repo has no ./manifest.json (a hand-authored native module that skipped the scaffold), this stage authors it from source as a fallback — see Step 2.
Read shared/ppmplugin-format.md — it is the source of truth for the schema, the derivation table, and the validation rules this skill enforces.
Two manifests, one contract. ./manifest.json (repo root, committed) is the source-of-truth contract — it declares every platform the module supports. ppmplugin/staging/manifest.json (gitignored) is the build copy this stage produces — same contract, but entrypoints trimmed to the platform(s) actually being shipped. The build/assemble skills only ever read the staged copy; the committed root file is what the PCF and humans read.
/build-android-binary./assemble-ppmplugin.entrypoints; /assemble-ppmplugin reconciles them against the binaries actually staged and gates on any mismatch../manifest.json on the normal path — it reads it. It only writes ./manifest.json in the fallback case (no committed manifest existed). It never touches src/, ios/, android/, PRD, or package.json beyond reading them.shared/shared-instructions.md, shared/naming-conventions.md, and shared/ppmplugin-format.md.Prereq check — /generate-ppmplugin-manifest: skipped (skill does no installs / auth / network — failures surface at validation).
package.json (the dev-only, private, plain <kebab>-control name — NOT a published @powerapps/extension-* scope; this track ships a binary, not an npm package — see repo-layout.md) and an android/ and/or ios/ native module exist. If not, STOP with NEEDS_CONTEXT: not a third-party-control repo (no package.json / native module)..extension-state.md — if it carries a ## ppmplugin (third-party controls) block, note the last target choice and last manifest write (the re-run mode below uses them).This stage has two modes, decided by whether the committed ./manifest.json exists:
Validate-and-stage mode (the normal flow). ./manifest.json exists at the repo root (authored by /generate-native-extension). This is the source of truth — do not re-author it. Read it, validate it (Step 3), reconcile its entrypoints to the chosen target (Step 2 → Target only), and write the staged copy (Step 4). Step 2's field-derivation is skipped — the contract is already authored; you're verifying and staging it, not regenerating it. (Optionally re-derive methods from the Android module source and, if they've drifted from ./manifest.json — e.g. a @ReactMethod was added by hand after scaffold — surface the diff and offer to update the committed file; never silently rewrite it.)
Author-from-source mode (fallback). No ./manifest.json exists — a hand-authored native module that skipped the scaffold. Derive every field mechanically from source (Step 2 in full), then write both the committed ./manifest.json and the staged copy (Step 4).
Re-run within a build session. If the staged copy ppmplugin/staging/manifest.json already exists from a prior run, don't blindly overwrite — diff the target/entrypoints against the committed source and ask via AskUserQuestion: Update (re-stage from the current ./manifest.json + target) / Keep as-is (report and stop). Default the target to the prior choice in .extension-state.md; don't re-ask an answered question.
The structure preflight + target selection below run in both modes (you always need to know which platforms are viable and which to ship). The field-derivation sub-section (items 1–6 + the compute block) runs only in author-from-source mode (Step 1.5) — in the normal validate-and-stage mode the fields already live in ./manifest.json; read them from there and skip derivation, keeping only the target choice.
First, run a structure preflight. The ppmplugin path expects the canonical PAM-extension layout — the shape /generate-native-extension produces (see shared/repo-layout.md). A hand-rolled, foreign, or drifted repo may be missing pieces; flag exactly what, here and now, rather than failing later with a cryptic Gradle/Xcode error or a silently-wrong manifest. Print a visible ✓/✗ block:
package.json (dev-only <kebab>-control name — there is no @powerapps/extension-* scope and no src/ TS layer in this track); class name resolvable from the native module (android/.../<Pascal>Module.kt / ios/RCT<Pascal>Module.m), ./manifest.json, or .extension-state.md.android/ present): android/build.gradle; a Kotlin module class extending ReactContextBaseJavaModule with an override fun getName(); a ReactPackage class; ≥1 @ReactMethod.ios/ present): ios/RCT<Pascal>Module.h declaring <RCTBridgeModule>; .m with + (NSString *)moduleName (NOT RCT_EXPORT_MODULE) and ≥1 RCT_EXPORT_METHOD.A platform whose structure is incomplete is not a valid target — exclude it and say which element is missing. If neither platform is structurally complete, STOP with NEEDS_CONTEXT: repo doesn't match the expected PAM-extension layout — missing <list> pointing at shared/repo-layout.md. (Working on a staged copy protects the source; it does NOT make a missing module appear — that's what this preflight is for.)
Then determine which platform(s) this bundle targets (only from the structurally-complete ones) — it controls which entrypoints get declared:
AskUserQuestion: Android-only / iOS-only / Both — offer only the targets that passed the preflight. Default to what's present, or — on a re-run in Update mode — to the prior target recorded in .extension-state.md./build-android-binary is stable; /build-ios-binary is v0 (Mac-only; known limitation: its React-Core weak-link config still needs validation against a live PAM/wrap shell). If the user targets iOS/Both, the manifest declares entrypoints.ios; /assemble-ppmplugin will still gate if the iOS binary isn't staged at packaging time.Then derive fields from the actual files, not from assumptions (author-from-source mode only — in validate-and-stage mode skip to Step 3 with the fields read from ./manifest.json):
Class name <Pascal> — from the native module (the Android <Pascal>Module.kt filename / its getName() = "<Pascal>Module", or the iOS RCT<Pascal>Module), or the .extension-state.md Identity block. There is no src/<Pascal>Extension.ts in this track. This is the basis for name, nativeModule, receivers[].name.
version — package.json version.
nativeModule — read the Android module's override fun getName(): String = "<X>". Use <X> verbatim.
packageClass — read the ReactPackage .kt file: combine its package <...> line with the class name → FQN (e.g. com.powerapps.peninput.PenInputPackage).
methods — scan the Android module (via the Read/Grep tools, not a shell-specific command — this skill is OS-neutral) for @ReactMethod and collect each annotated function name. Cross-check the count against the operations in ARCHITECTURE.md §3 / PRD §4; if a documented operation has no matching @ReactMethod, surface it as a warning (the manifest reflects what the binary actually exposes, but the mismatch usually means an operation wasn't wired).
iOS entrypoint fields (only when targeting iOS / Both) — read ios/RCT<Pascal>Module.h for the class name (@interface RCT<Pascal>Module : NSObject <RCTBridgeModule>) → that is entrypoints.ios.moduleClass. Read ios/RCT<Pascal>Module.m for + (NSString *)moduleName { return @"<X>"; } and assert <X> equals the nativeModule from step 3 — it's the same bridge symbol on both platforms; a mismatch means the iOS and Android modules disagree. (The .m must not use RCT_EXPORT_MODULE — that macro's +load registration is invisible to the framework's dlopen flat namespace, so the module never loads on device.)
Then compute, per ppmplugin-format.md §3:
name = kebab(className) — from the class name, not the capability/repo name. If kebab(className) differs from the repo's capability kebab (e.g. repo powerapps-pdf-control but class PdfViewer → name: pdf-viewer), print a one-line note so the user knows the .ppmplugin filename won't match the repo name. This is required for the canonical-prefix rule to pass.receivers[].name — take it from the PCF if one exists, do NOT blindly default. If a sibling PCF is present (pcf/<…>/index.ts), grep it for the dispatch key — COMPOSITE_KEY = "<name>/<receiver>" (or the ReceiverKey it binds) — and use that <receiver> value, because the PCF already dispatches to it; a manifest that registers a different receiver name will fail on first dispatch (real bug: PCF dispatched to Snapshot while the manifest defaulted to DeviceInfoExtension). Only if no PCF exists, fall back to <Pascal>Extension. Either way, surface the chosen value as a confirmation point: "PCF dispatches to <name>/<receiver>; the manifest will register receiver <receiver> — confirm?" (Audit re-checks this — see /audit-ppmplugin pcf-composite-key-matches-receiver.)
receivers[]IS the runtime dispatch contract (ppmplugin-format §2 — Runtime dispatch contract). The wrap host routes a call by the composite key<name>/<receivers[].name>toNativeModules.<nativeModule>.<method>— there is no TShandleMessageAsync/sendAsynclayer in a native-only bundle. SonativeModulemust equal the module'sgetName(), and every entry inmethodsmust be a real@ReactMethod/RCT_EXPORT_METHODname (the host calls it directly; an absent method =method '<m>' not foundon device). Derivemethodsfrom the module source, never guess.
entrypoints — declare ONLY the chosen target(s):
entrypoints.android = { dex: "<Pascal>Plugin.dex", packageClass: "<FQN from step 4>" }
entrypoints.ios = { framework: "<Pascal>Plugin", moduleClass: "RCT<Pascal>Module" }
Run every rule from ppmplugin-format.md §4 against the derived manifest. For each, pass or fail with the exact rule:
name matches ^[a-z0-9][a-z0-9-]{0,63}$
nativeModule starts with the canonical prefix of name — computed precisely as split name on -/_, PascalCase each segment, join (ppmplugin-format §3), Ordinal/case-sensitive. (Note the helloworld → Helloworld subtlety; kebab(className) must hyphenate at camelCase boundaries so the round-trip holds.)nativeModule starts with a reserved prefix (case-insensitive list in §4)nativeModule is a known incompatible exact name — block (Ordinal, case-sensitive) if it matches the locally checked subset in §4 (DeviceInfo, AuthenticationHelper, NetworkClient, DataverseOfflineProvider, IntuneMAM). The subset is non-exhaustive: passing it locally does not guarantee the upload service will accept the name. Rename the module's getName() (and iOS +moduleName) to a non-reserved form — add a Module suffix (DeviceInfo → DeviceInfoModule) or a vendor prefix (ContosoDeviceInfo) — and re-derive nativeModule to match.nativeModule looks like a generic platform noun → WARNING. If nativeModule matches ^(Device|Network|File|Audio|Camera|Sensor|Location|Storage|Notification|Bluetooth|Wifi|Media|Photo|Contact|Calendar|Battery), warn that generic single-noun names are both upload-conflict-prone and collision-prone (NativeModules is a shared namespace across every plugin the wrap host loads). Recommend a Module suffix or vendor prefix. Surface it; let the user proceed if deliberate.methods non-empty; ≤32; each matches ^[a-zA-Z_$][a-zA-Z0-9_$]{0,127}$
receivers[].name matches the same JS-identifier regexentrypoints.js, entrypoints.ts, extension.js, extension.hbc, extensionClassName, jsLayer (the bundle is native-only; these are leakage the wrap runtime no longer reads — §4). Since this skill authors the manifest it won't emit them, but the re-run mode reads an existing manifest, so assert it.Print the result as a visible block:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
manifest.json validation
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🟢 ✓ name 'pen-input' — valid kebab identifier
🟢 ✓ nativeModule 'PenInputModule' starts with canonical prefix 'PenInput'
🟢 ✓ nativeModule 'PenInputModule' — no reserved prefix
🟢 ✓ nativeModule 'PenInputModule' — not a known reserved name
🟢 ✓ methods ['capturePenInput'] — non-empty, valid identifiers
🟢 ✓ receiver name 'PenInputExtension' — valid identifier
🟢 ✓ no SDK-era / JS-layer fields (native-only manifest)
🟢 7 checks passed, 0 failed.
ⓘ Known incompatible-name subset checked locally; the upload service may apply additional checks.
If any check fails, STOP with BLOCKED: manifest validation — <rule> and the concrete fix (e.g. "rename the Kotlin module's getName() so it starts with PenInput"). Do NOT write an invalid manifest.
Show the manifest (the validated source + the target-reconciled entrypoints) and wait for confirmation (shared-instructions §7.1). On approval:
ppmplugin/ is in .gitignore (append the line if absent). Do not add manifest.json (the committed root file stays tracked).ppmplugin/staging/manifest.json — the contract with entrypoints trimmed to the chosen target(s). This is what /assemble-ppmplugin zips../manifest.json at the repo root (the full contract, all viable platforms) — this is the source of truth the scaffold would normally have produced. In validate-and-stage mode, leave ./manifest.json untouched (it's already the source) unless the user accepted a drift-update offer in Step 1.5.## ppmplugin (third-party controls) block in .extension-state.md (create it if absent — schema in repo-layout.md): set Target, Manifest: staged <ISO timestamp>. This is what the re-run mode (Step 1.5) and the build/assemble skills read for state.Return DONE with the manifest path. Then surface next steps via AskUserQuestion (shared-instructions §9.1), offering the build skill(s) for the chosen target(s):
(For a Both target, list both build skills. Cap at 4 options per AskUserQuestion.)
When the user picks a Run /… option, immediately invoke that skill via the Skill tool in the same turn (sub-skill invocation, shared-instructions §8 + §9.1 "Execute, don't describe"). Do NOT print a "run it when ready" instruction and stop — selecting the option IS the request to run it. Only "Stay" ends the run.
{
"name": "pen-input",
"version": "0.1.4",
"abi": { "compatibleShells": ">=1.0.0", "builtAgainst": "1.0.0" },
"entrypoints": {
"android": { "dex": "PenInputPlugin.dex", "packageClass": "com.powerapps.peninput.PenInputPackage" }
},
"receivers": [
{ "name": "PenInputExtension", "nativeModule": "PenInputModule", "methods": ["capturePenInput"] }
]
}
(This example targets Android-only, so entrypoints.ios is omitted. For a Both target it would also carry "ios": { "framework": "PenInputPlugin", "moduleClass": "RCTPenInputModule" }. name = pen-input = kebab of class PenInput; nativeModule = PenInputModule (the Module suffix avoids reserved bare names) and starts with canonical prefix PenInput ✓; not a reserved prefix ✓.)