git:20261024.aed70bf to git:20261026.ea1e2e7
1 added, 1 removed. Audit B to B.
# Power Pages Plugin
A plugin for creating, deploying, and managing Power Pages SPA sites and non-SPA sites.
SPA sites support static SPA frameworks (React, Vue, Angular, Astro) with Dataverse integration,
Web API access, and browser-based previews via Playwright. Non-SPA sites use PAC-downloaded
native metadata, Liquid, Bootstrap, and Design Studio-compatible content.
**Server-rendered frameworks (Next.js, Nuxt, Remix, SvelteKit) are NOT supported.**
Read `PLUGIN_DEVELOPMENT_GUIDE.md` for UX and reliability standards when creating new skills and agents.
## Non-SPA creation and shared design quality
`scripts/telemetry-config.js` delegates preference changes to the bundled shared CLI and adds the non-SPA creation disclosure.
The telemetry skill uses this Power Pages-only entry point so the shared library and other plugins' configs stay unchanged.
Keep reusable visual principles in `references/site-design-quality.md` and native application in
`skills/edit-site/references/styling/design-quality.md`. The SPA `references/design-aesthetics.md`,
`references/page-blueprints.md`, `references/design-critique.md` and existing SPA review workflow
retain their established behavior; non-SPA adoption must not change their fonts, imagery priority,
plan fields, scores, rendering requirements or completion gates. Do not import their SPA theme
replacement, font installation, dev-server, or live-preview requirements into non-SPA creation.
The old `create-site/references/design-aesthetics.md` is a compatibility pointer only.
Reuse blueprint patterns through the native content-composition adapter and the shared rubric's
two tables through `skills/edit-site/references/styling/design-critique.md`; do not duplicate the tables or load
the SPA capture/fix instructions for source-only work. Non-SPA read-only critique branches into
`exceptional-web-design/workflows/non-spa-site.md` before the unchanged SPA review body.
Carry the brief in existing
approved `newSiteDesign.composition`/`designContext` and native operation inputs, not a new mandatory
schema or a second design workflow. Review once at the final coordinated local verification.
Non-SPA source-only conclusions remain provisional; status/plan HTML and structural wireframes
are not runtime evidence. Non-SPA image-network checks are opt-in; unchecked images remain
explicitly unverified. Reuse any exact-plan image-check receipt rather than probing at every
owner handoff. Do not add a mandatory browser review or verification-choice gate.
Selected non-SPA creation templates supply data-model/domain and requirements context, not
constraints on appearance, page composition, layout, branding, or modern presentation. Follow
the template-role rule in `references/site-design-quality.md` through the new-site handoff,
native composition and final styling. Preserve required capabilities/content, native integrity
and explicit user preservation preferences, not the starter arrangement by default. Custom design
and layout are required: inventory reusable forms/lists/snippets separately from the new page
composition, map both into native owner operations, and compare the source result with that plan
at the existing final review. Copy/image swaps and CSS over the unchanged starter layout are not
completion. Do not broaden unrelated existing-site edits into redesigns.
Unneeded out-of-box sections and component placements may be removed for the approved fresh
design. Inventory retain/relocate/replace/remove decisions against actual requirements; template
presence alone does not make a component mandatory. Keep removal rationale in existing plan
summaries/inputs, preserve required dependencies and explicit exceptions, and verify source
removal rather than CSS hiding. Removing a placement does not delete its backing records or data.
New non-SPA creation requires Enhanced with Bootstrap 5, with explicit model/admin approval,
the helper's Bootstrap-filtered template catalog, and downloaded Bootstrap verification using
the shared inspector. Existing Standard and Enhanced sites remain customizable and deployable, but
new Standard website records must not be created through create-site or deploy-site. Never add
an undocumented Create Website property, change environment flags, silently migrate, or recreate
an accepted site to resolve a mismatch.
The creation validator accepts PAC `website.yml` identities as either `id` or `adx_websiteid`;
reject conflicting identities rather than rewriting a downloaded record to pass validation.
The `creationIntent: "new-site"` handoff requires a schema-1 `newSiteDesign` brief and meaningful
content imagery unless explicitly declined. Existing-site plans remain preservation-first and
backward-compatible. New creation sets `newSiteDesign.imageDelivery: "external-url"` and passes
approved HTTPS image URLs directly to native content owners, without image downloads, staging,
Web File imports or fake output bindings. Reuse template images only when they fit the approved design. Explicit
file delivery outside that route uses `prepare-non-spa-asset.js` and the
web-file authoring reference.
Keep final `style-site` guards and the plan's visual context hash-bound and visible in approval
HTML. Review external-host availability, license, privacy and CSP; do not silently relax policies.
Align external Unsplash sourcing with SPA's WebSearch-to-CDN flow: no mandatory photo-page fetch
or photographer lookup. Retain photo-page/photographer metadata when reliably known; require the
license basis and valid direct URL. Retain exact-plan `--imageChecks` evidence when requested. Do not
mistake a source-page bot challenge for a CDN failure or invent missing attribution. Explicit
Web File imports retain their existing source metadata and staging requirements.
The plan library validates completeness/dependencies, not visual fidelity. Status/approval HTML
is not a site preview; local verification leaves Studio/runtime rendering pending.
New non-SPA designs and full-site redesigns explicitly plan header branding, navigation styling
and responsive treatment in the existing brief and owner operations, or record why the header is
preserved. Every header update includes an explicit website-appropriate logo or wordmark decision:
replace an unsuitable starter logo, or record why the verified current logo meets the requirements
or must be preserved. Resolve desktop/mobile callers and requested locales; verify the approved
logo outcome, not just header CSS. Template selection is not a visual constraint or a preservation
reason. Coordinate snippet/template work before final styling, retain native header behavior and binding safety,
and keep narrow edits scoped. Do not add a header schema, no-op task, approval gate or SPA change.
Customization-plan presentation preserves the approved JSON: neutral informational notes,
structured navigation, distinct labeled Modify/Replace accents, and section/column/element
wireframes. Omit the maker-facing technical implementation trace; retain complete execution data
in the canonical approved JSON and its archived history for tooling and maintainer diagnostics.
Hosted asset previews load lazily in the browser with no referrer, source attribution,
and an explicit load-failure fallback/link; rendering HTML does not stage or import images.
The review CLI makes no image-network requests by default and labels external images unverified.
Only `--verifyImages true` checks unique external URLs with bounded, public-only HTTPS requests.
Keep this in `scripts/lib/non-spa-image-verification.js`; it reuses the asset inspector where
applicable and never persists image bytes. Requested checks fail explicitly, never silently
downgrading to unchecked review. Publication accepts unchecked plans; any supplied `--imageChecks`
report must match the exact plan, is retained in the execution receipt, and passes through
operation resolution without probing again. Revisions need fresh review and approval, plus fresh
checks if requested. Legacy receipts remain resumable without fabricated verification claims.
Keep unknown layouts and unavailable local-image previews explicit rather than inventing data.
Non-SPA section recipes are defaults, not an exhaustive layout catalogue. Resolve optional
`columns[].span` geometry through `scripts/lib/non-spa-native-layout.js` and the section-layout
reference, retaining native boundaries and explicit Studio-editing-unverified warnings.
Do not add a live-check gate or imply that a valid grid proves Studio control mapping.
Keep this extension in the non-SPA authoring path; SPA/shared design references stay unchanged.
## Non-SPA component authoring
`edit-site` authors pages and components and coordinates broader site customization.
Coordinated work uses seven phases and explicit approval gates. Individual page or component edits follow
`skills/edit-site/references/non-spa-site-authoring.md` and only relevant component references.
Component instructions live under `skills/edit-site/references/`.
Site markers use `site-marker-authoring.md` and the `author-site-marker` reference operation,
not a new skill. Preserve downloaded storage/field conventions and same-site page identities;
new-page bindings depend on verified webpage outputs. Marker edits do not enable search or
alter navigation, and local removal is not proof of remote deletion.
Page-element markup and composition instructions live under `skills/author-webpage-content/references/`.
Use the shared authoring reference's dispatch table to select component workflows.
Identifiers in that table select references, not `Skill` invocations. Preserve approved
`operation.skill`, `ownerSkill`, hashes, receipts, and actual output bindings.
`author-webpage-content` is a model-only skill (`user-invocable: false`) that owns localized body HTML.
Keep it callable by `edit-site` and `integrate-webapi` with resolved page, locale, and target-file inputs.
The `style-site` reference workflow owns guarded styling.
Reference workflows own PAC serialization, stable identities, locale/dependency scope, and
verification; callers must not duplicate those rules.
New pages update navigation by default unless the user explicitly asks not to.
Resolve placement before writing and include it in new customization plans; preserve approved execution scope.
Individual page or component edits show **Inspect → Author → Independently verify → Report** without mandatory
phase tasks, plan HTML/receipts, Git initialization, commits, or deployment prompts.
Additional unresolved approval needs and current approved executions use the coordinated route.
Preview/version handling and
usage tracking belong to the entry skill, not reference reads. Preserve existing tracking records.
## Non-SPA styling — local-only exception
`edit-site`'s [styling workflow](skills/edit-site/references/style-site-workflow.md) is **non-SPA site only**: downloaded server-rendered Power Pages content in VS Code Desktop, not a SPA framework. Show **Inspect/propose → Approve → Apply → Independently verify → Report** without mandatory phase-task bookkeeping. Ordinary broad declarations on one localized page, at most 3 components/10 style groups in one section, inline-only/stylesheet-only/inline+CSS with at most one existing stylesheet and guarded same-page class additions qualify for the small-change route. Require known Bootstrap and verified source/cascade. Any raw `style.css`, `global: true`, nonempty `externalResources`/`importantReason`, shared templates/metadata, new files, Studio-only/source-free descriptors or broader/ambiguous scope take expanded review. Generated-source and other true blockers need specific local next steps, never successful zero-write handoffs. Native components/forms/lists/Liquid are supported; PCF/third-party internals are not.
This workflow replaces the normal **"Ready to deploy?"** handoff with a **verified local report**, not a final-completion confirmation. Initial scope confirmation is conditional on expanded/ambiguous work; small-change scope is reviewed with exact-hash approval. All revisions require an explicit host-tool answer; runtime consent is separate, and unattended execution never grants approval. Do not auto-invoke deployment/ALM skills, PAC, Dataverse authentication, remote writes, Studio Sync, activation, or cache clearing. The Desktop site's Preview action uses uploaded content and clears cache; do not invoke it. Do not generate HTML previews, start a local server, or require browser review. VS Code for the Web saves remotely and is not an alternative local apply path.
Optional runtime URL discovery is a narrowly scoped exception to offline inspection, not to local-only application. Obtain `style-site:2.runtime` approval, then run the browser function generated by `inspect-runtime-dom.js` on that exact page. Collect bounded structural IDs/classes and computed styles, not content, form values, credentials, storage, or embedded/PCF internals. Normal portal scripts/requests run on page load; do not initiate authoring, submissions, authentication, or crawling. Reconcile the private external snapshot through `inspect-style-context.js --runtimeSnapshot ... --pageId ...`; matches are advisory and cannot bypass verified local source hooks. Own this logic in `scripts/lib/runtime-style-context.js` and `skills/edit-site/references/styling/runtime-dom-discovery.md`.
Default to VS Code/local authoring for **both listed and unlisted Studio properties**. Required `owner: "custom"` means local authoring, not custom-component-only support. Honor verified native serialization/cascade: prefer the actual static inline declaration in selected-locale Page Copy or explicitly site-scoped reachable templates; otherwise use effective CSS in the locale sidecar or reusable Web File. For Web File changes, mention that custom CSS has higher priority than `theme.css` and lower priority than `portalbasictheme.css`. This is advisory, not a `displayorder` condition or a requirement to prove runtime load order. Preserve existing ordering metadata; new CSS Web Files omit `displayorder` rather than allocating slots or renumbering defaults. Quoted style additions are allowed where appropriate, not for blanket-inlining reusable styles. Preserve protected defaults, Bootstrap, native DOM and unrelated source. Update winning inline declarations rather than output ineffective stylesheet CSS or blindly escalate specificity/importance. Existing exact-property inline importance is preserved; authored priority requires nonempty `importantReason` and expanded review, never a root-conflict bypass. Unknown Bootstrap still blocks; local validation is not rendering proof.
Classify component Design-panel support using the unchanged canonical map in `skills/edit-site/references/styling/studio-component-capabilities.md` and `scripts/lib/studio-style-capabilities.js`; it describes availability, not exclusive ownership or authority to force a handoff. Do not infer from descriptor kind or a parent's family. Unknown components/Flex stay unverified; site themes/other Studio dialogs are outside the map. Optional `studioComponent`/`studioFlex` describe actual targets/current availability. Retain unlisted/unknown/conditional warnings in review, approval and final report without promising local values populate native controls. Parser grammar and source/cascade checks are separate. Microsoft's Studio recommendation is not a VS Code prohibition.
Keep the request contract in `skills/edit-site/references/styling/proposal-and-verification.md`. General CSS declarations include custom properties, font stacks, shadows, layout/functions, transforms, transitions and every gradient kind, not a skill-specific property/value/part allowlist. Preserve authored/insertion order, not alphabetical sorting; pinned mdn-data supplies shorthand effects with logical-side safety. Pinned bundled css-tree parses syntax; unknown or semantic-grammar-unverified values yield warnings, while malformed/injected/legacy executable CSS fails. Fix invalid values or verify newer browser support; parser support is not rendering proof, and missing parser grammar is not a Power Pages prohibition.
Alternative `style.css` is full local stylesheet text instead of `declarations`, with no `part`: responsive rules, arbitrary stable descendants/states/pseudo-elements, keyframes/reduced motion, fonts, tokens, `@property` and layers use normal CSS. Default selectors stay within the verified `pp-*` component subtree. Explicit raw `global: true` permits global rules in the existing page/site/section placement scope, with expanded review of **all matching elements**. Source-free descriptors are allowed only for global stylesheets or user-requested Studio handoffs; global themes need no fictitious source/class. Namespace new keyframe/font/property/layer names with `pp-`/`--pp-`, or review shared names under `global: true`. Raw CSS cannot bypass root inline conflicts; generated descendants still need source/cascade inspection.
Declare exact external HTTPS CSS URL references in `externalResources` and retain hashed review warnings. Relative/root-relative/fragment Web File URLs need no external declaration; reject protocol-relative, JavaScript/file schemes, unreviewed external tracking/font/image URLs and data payloads (use approved Web Files). Review CSP, availability, licensing and privacy; compiler/runtime fetching is not performed or required. Imports require `global: true`, declared external URLs when applicable and valid beginning-of-stylesheet placement; a new custom file may avoid late imports. Never fetch imports automatically.
Local `location` is optional `stylesheet` (default) or `inline`; inline requires exact static `inlineTarget` (max 8,000 characters), no nonempty `part` or raw/global/stylesheet/handoff placement fields, and the same general declarations. Static tags and CSS values may span lines; preserve exact whitespace/newline matching and keep Liquid out of target tags. Preserve Page Copy locale and explicitly site-scoped templates (potentially every page). Inline-only components may omit `className`; supplied hooks must identify actual tags, existing or approved through `classEdits`. Parse/encode quoted fonts, HTML entities, CSS comments/escapes safely, preserving unrelated source/attributes. Original tag/context class/inline changes reconstruct as `kind: "markup"`; class-only stays `kind: "class"`. Reject overlapping properties, ambiguous/malformed edits and conflicting important shorthands with local remediation. Reserve `owner: "studio"` plus `handoffReason: "user-requested"`/`studioAction` for explicit instructions-only work.
Inline `declarations: {"property": null}` explicitly removes that exact existing inline property; removal-only inline groups are valid. Combine removal and scoped CSS in the same approved plan for responsive/state authoring, not ineffective overrides or blind importance. Null is not CSS and is rejected in stylesheet/Studio groups. Preserve other properties, attributes/markers and priority; removing a shorthand removes its whole declaration, so preserve needed longhands in planned styles and review the actual diff. This resolves general inline ownership, not a gradient special case.
Static editing supports real rendered tags, including input, textarea, SVG and outer local iframe/embedded elements, not a fixed wrapper list. Only class/style attributes change; embedded contents are never entered. The source tokenizer skips iframe/noembed/noframes/xmp contents like textarea/script/style contents; tag-shaped text there is not a local DOM target. Runtime-discovery exclusions are unchanged. The entities codec may normalize entity spelling only inside the style attribute; preserve all other tag/document bytes except separately approved class additions.
Keep the mandatory styling guidance (`skills/edit-site/references/style-site-workflow.md` + `skills/edit-site/references/styling/quick-start.md`) within 12,000 UTF-8 bytes; aim for the workflow at most 6,000 characters. Load policy/version/deep request/CLI references only when applicable. Routine CSS uses bundled verified guidance; fetch web docs only for unknown/conflicting/stale guidance or explicit requests. Reuse the normal version-check result once per installed plugin version in the current conversation; do not change the checker.
Missing classes/repeated wrappers alone are not blockers: `classEdits.context` / `styles[].inlineContext` supply exact adjacent original `before`/`after` text (combined window at most 16,000 characters). `style-site-source-target.js` requires one unique source window containing a real parsed tag, never a first match/global ordinal. Reuse the same tag/context when composing class/inline edits; retain context in full reviews and both fresh preflights. The three-column surface example illustrates general CSS, not a gradient-specific exception.
The pinned parser is vendored under `scripts/vendor/css-tools` with manifests/lockfile, `css-tools.cjs` and licenses, including mdn-data's license; plugin users need no npm installs. Maintainers rebuild from that bundle directory using `npm ci --ignore-scripts` then `npm run build`; see `PLUGIN_DEVELOPMENT_GUIDE.md`. Do not turn maintainer tooling into runtime/site dependencies.
For palette/typography/redesign work, load `skills/edit-site/references/styling/design-quality.md` and its shared `references/site-design-quality.md` source, not the SPA adapter's SPA theme replacement or font installation. Inspect child foreground rules against their surfaces and update the actual local declaration; native colors are not a Studio prerequisite. The coordinator flags readability review, without generating a browser checker. `check-style-contrast.js` uses shared `scripts/lib/style-site-contrast.js` math for supplied resolved pairs only; it does not verify the cascade or assume a background. Resolve known failures and local declaration dependencies before approval; unknown rendering remains pending, never a synthetic pass. This is not full WCAG/runtime/Studio proof.
Own deterministic operations in `scripts/style-site/`. Use `inspect-style-context.js --summary --out ...` for bounded discovery with full external evidence; candidates are advisory. Normal `style-site-workflow.js --operation prepare` validates and writes only plan/review JSON into a fresh external revision directory; `--operation apply` performs guarded writes plus independent validation using the plan-bound root. Read full review artifacts before approval if summaries truncate. Individual commands remain diagnostics, not extra normal round trips. Keep shared logic under `scripts/lib/` and contracts under `skills/edit-site/references/styling/`. Retain schema 2 without rendering payloads; regenerate legacy/invalid requests and obtain new approval, never edit plans or migrate consent. General CSS does not authorize component creation, DOM replacement, unsafe side effects or generated-source compilation.
Reference reads do not trigger skill hooks. Coordinator validation of the exact plan/receipt is authoritative for arbitrary revision paths. The standalone no-args validator uses `runValidation` and discovers only `<host cwd>/.powerpages-style/style-site.plan.json` plus optional `style-site.receipt.json`, outside the uploadable site. Missing plans no-op; malformed/inconsistent artifacts block; no-artifact success is not completion evidence. Keep independent file verification as a distinct step; post-change desktop/mobile rendering, computed values, accessibility and Studio save/reopen remain pending separate live checks. Retain catalog gates `2.runtime`, conditional `3.scope`, `5.approve`, and `6.reapprove` in the styling workflow; no final-review gate. The entry skill records `EditSite` once after successful verified local or explicitly requested handoff completion, never canceled drafts or blocked local work. Preserve existing counters, the supported-directory no-op and separate tracking diffs; never create a tracking tree in a non-SPA export.
## Key Conventions
- **DRY** — Never duplicate logic. Shared scripts live in `scripts/` (e.g., `generate-uuid.js`, `scripts/lib/validation-helpers.js`, `scripts/lib/discover-site-components.js`). Shared reference docs live in `references/`. Always check for existing helpers before writing new code.
- **Validation scripts** must import from `scripts/lib/validation-helpers.js` for boilerplate, path finders, auth helpers, and constants.
- **UUID generation** must use the shared `scripts/generate-uuid.js` — never copy it into skill-specific directories.
- **Power Pages config loading** must reuse `scripts/lib/powerpages-config.js` anywhere a script reads `.powerpages-site` table-permission or site-setting YAML. Keep that module focused on loading/parsing SPA config only; put validation or business rules in separate validator modules.
- **Script changes require tests** — Whenever you add a new script or modify an existing script, add or update `node:test` coverage under `scripts/tests/`. Prefer one `*.test.js` file per script/module being tested, and keep the test command passing: `node --test plugins/power-pages/scripts/tests/` (Node's built-in runner discovers `*.test.js` files under the given directory). Validator changes are not an exception; they must always ship with test coverage.
- **Secure process validation** — After changing Power Pages `child_process` usage, run `node scripts/validate-secure-process-execution.js` from the repository root. Its fixture suite is `node --test scripts/tests/validate-secure-process-execution.test.js`.
- **Dataverse-backed validation** must stay opt-in for local runs only. Do not require live Dataverse connectivity in CI workflows or default test runs; gate it behind explicit local flags such as `--validate-dataverse-relationships`.
- **Azure CLI `--allow-no-subscriptions`** — this flag is only valid on `az login`. Other `az` subcommands (`az account get-access-token`, `az account show`, etc.) reject it as an unrecognized argument and exit 2, so do NOT add it to anything other than `az login`. When the user is not logged in to the Azure CLI, suggest plain `az login` first; only suggest `az login --allow-no-subscriptions` as a fallback if they don't have any associated Azure subscription, since that variant lets subscription-less accounts sign in and still mint AAD-scoped Dataverse/Power Platform tokens via subsequent `az account get-access-token` calls. Reuse the shared `getAuthToken` helper in `scripts/lib/validation-helpers.js` instead of shelling out to `az` directly.
- **Reference docs** shared across skills live in `references/` — reference via `${PLUGIN_ROOT}/references/` paths, don't duplicate.
- **Local scaffold templates** use `__PLACEHOLDER__` tokens (e.g., `__SITE_NAME__`) replaced during from-scratch scaffolding. The `gitignore` file is stored without the dot prefix and renamed to `.gitignore` during scaffolding.
- **Batch browser work into one script call** - every tool call re-sends the whole conversation, and each Playwright MCP `browser_navigate` also returns a full page snapshot, so per-page MCP calls multiply cost and trigger context compactions. `create-site` captures every route at both widths with one `capture-design-review.js` call and opens the screenshots in one turn; prefer that pattern whenever a skill needs the same browser work across several pages. Keep the design review inline rather than in a subagent: a fresh context re-loads every screenshot, which raises cost without improving the review.
- **Browser review scripts** (`capture-design-review.js`, `axe-audit.js`) share their boundaries through `scripts/lib`, so add new browser work there rather than beside them:
- *Input* - URLs and routes arrive only as a JSON request on stdin (`--input -`, sent in a quoted heredoc), never as shell text, and every field is type-checked (`parseRequest`, `normalizeSiteUrl` in `review-navigation.js`).
- *Code they run* - Playwright comes only from the pinned `@playwright/mcp` package (`load-playwright.js`, `playwright-mcp-package.js`), resolved by npm in an empty private folder; a project's `node_modules` is never loaded. A third-party script injected into a page is downloaded through `pinned-download.js` and refused unless it matches the SRI hash committed beside its URL (`AXE_SCRIPT`).
- *Output* - URLs in results and errors are cut to origin and path (`redactUrl`, `redactUrlsInText`), and screenshots go to a private temp directory (`private-temp-dir.js`).
- *Navigation* - `gotoSettled` completes at `load` with a best-effort network-idle wait, because `networkidle` never arrives on sites that long-poll or send frequent beacons.
- **Playwright screenshots** go to a private per-launch temp directory: `scripts/launch-playwright-mcp.js` passes `--output-dir` (unless `PLAYWRIGHT_MCP_OUTPUT_DIR` is set), falls back to a private `~/.cache/power-pages` directory and otherwise fails closed rather than writing into the project, removes the directory on server exit or a host termination signal, and on each launch sweeps this user's launcher directories untouched for an hour, because hosts such as Copilot CLI SIGKILL the launcher before any in-process cleanup can run. Skills that call `browser_take_screenshot` leave `filename` unset so output never lands in the user's project.
- **Hooks** are defined centrally in `hooks/hooks.json`, using `PostToolUse` with matcher `Skill` so validation runs when a tracked Power Pages skill completes.
- **ALM split-decision thresholds** are intentionally tighter than the platform hard caps. `scripts/lib/alm-thresholds.js` recommends a split at 75 MB / 4000 components (vs platform caps of 95 MB / 6000), reserving ~20 MB / ~2000-component growth headroom in each split child. Override per-project via `.alm-config.json` if you have a justified reason to push closer to the caps.
- **OAuth credential-style site settings** (ConsumerKey / ClientId / ClientSecret / etc.) are NOT excluded from solutions. `setup-solution` Phase 5 prompts per credential to choose between (a) Secret-typed env var (Key Vault per stage), (b) String-typed env var (plain text per stage), or (c) skip. The site-setting record is added to the solution and routed to an env var so secret values never ship in the solution zip. Plans generated before 2026-05-08 use the older `excluded` bucket — setup-solution's preloadedSettings handler treats those as `credentialNeedsDecision` for backward compatibility.
- **MCP Learn grounding for ALM skills** — solution and pipeline skills (`setup-solution`, `export-solution`, `import-solution`, `diagnose-deployment`, `setup-pipeline`, `deploy-pipeline`, `ensure-pipelines-host`, `force-link-environment`) include a Phase 1.5 step that grounds the agent in current Microsoft Learn ALM docs before proceeding. The shared discovery pattern lives in `references/alm-docs-grounding.md`. Add the same Phase 1.5 + the two `mcp__plugin_power-pages_microsoft-learn__microsoft_docs_search/fetch` tools to `allowed-tools` when introducing a new ALM skill.
- **ALM artifacts live under `docs/alm/`** — every ALM-only state file (5 plan/decision JSONs and 9 `last-*.json` skill-run markers, including `last-export.json` written by `export-solution` Phase 7.1) writes to `<projectRoot>/docs/alm/`, not the project root. Always resolve paths through `scripts/lib/alm-paths.js` (`almPath(root, 'lastDeploy')`, `almPath(root, 'planContext')`, etc.) and call `ensureAlmDir(root)` once before the first write. Never inline a raw path string. Files that intentionally stay at the project root: `.solution-manifest.json` (referenced by non-ALM skills too), `.datamodel-manifest.json` (owned by `setup-datamodel`, not ALM), `.alm-config.json` (user-authored override), `.alm-deferred` (opt-out marker), `deployment-settings.json` (Microsoft-standard schema). When you add a new ALM artifact, add the key + filename to `FILE_NAMES` in `alm-paths.js`, then write through the helper.
- **New skills must be added to `README.md`** — Whenever you add a new user-invocable skill under `skills/`, you must also document it in [`README.md`](README.md) under the appropriate section (Site scaffolding and deployment / Data modeling / Backend integration / Security and access / ALM and CI/CD / Polish / Support), update the skill count in the `## Skills` intro, and — if the skill is part of the recommended end-to-end flow — update the **Typical Workflow** code block. The README is the user-facing source of truth for what the plugin can do; an undocumented skill is effectively invisible to users browsing the marketplace.
## Skill Development Conventions
```
.plugin/plugin.json ← Open Plugins metadata (name, version, keywords)
.mcp.json ← MCP server config (Playwright for browser automation)
agents/
data-model-architect.md ← Agent: proposes Dataverse data models (read-only)
webapi-integration.md ← Agent: implements Web API integration in frontend code
table-permissions-architect.md ← Agent: proposes table permissions plan (read-only)
webapi-settings-architect.md ← Agent: proposes Web API site settings with validated column names (read-only)
ai-webapi-integration.md ← Agent: implements generative-AI summarization service code + UI wiring
ai-webapi-settings-architect.md ← Agent: proposes Summarization/* site settings (read-only)
scripts/
generate-uuid.js ← Shared UUID v4 generator (used by multiple skills)
check-activation-status.js ← Checks if site is already activated (used by deploy-site, activate-site)
poll-async-operation.js ← Polls Dataverse asyncoperations until terminal state (used by export-solution, import-solution)
encode-solution-file.js ← Base64-encodes a solution zip for OData request bodies (used by import-solution)
parse-deployment-errors.js ← Parses PAC CLI stderr + OData errors into structured findings (used by diagnose-deployment)
capture-design-review.js ← One-call design capture: every route at desktop + mobile, mobile sheets, font/overflow/page-error/sign-in-redirect checks (create-site, exceptional-web-design)
axe-audit.js ← axe-core WCAG 2.2 AA audit of a dev server or deployed site; downloads axe-core and injects it only when it matches the committed hash (create-site, exceptional-web-design)
references/ ← Shared reference docs used by multiple skills
odata-common.md ← Auth headers, token refresh, error handling, retry patterns
dataverse-prerequisites.md ← PAC CLI check, Azure CLI token, API access verification
framework-conventions.md ← Framework detection, paths, route discovery
datamodel-manifest-schema.md ← .datamodel-manifest.json format spec
solution-api-patterns.md ← OData body templates for publisher/solution CRUD, export/import async actions, manifest format
deployment-error-catalog.md ← Known deployment failure patterns with root cause, severity, and fix procedures
cicd-pipeline-patterns.md ← PAC CLI SP auth syntax, ADO YAML stage structure, GitHub Actions env job structure
site-design-quality.md ← Common design principles, experience brief, exact contrast and evidence boundaries
design-aesthetics.md ← Design system: experience brief, brand sourcing, tokens, verified Google Fonts, motion, states, aesthetic x mood map
page-blueprints.md ← First screen, hero patterns, page narratives per site type, section rhythm, copy, honest proof
design-critique.md ← Capture, two-pass critique, 10-category rubric, compact scorecard (create-site Phases 5.2/5.7, exceptional-web-design)
skills/
create-site/
SKILL.md ← Routes SPA and Enhanced-only non-SPA creation
workflows/non-spa-site.md ← Environment-backed non-SPA provisioning and download workflow
assets/{react,vue,angular,astro}/ ← Framework templates with __PLACEHOLDER__ tokens
references/design-aesthetics.md ← Compatibility pointer to the shared principles and SPA adapter
scripts/list-site-templates.js ← Curated Create Website template metadata and model-toggle disclosure
scripts/render-non-spa-status.js ← Generates the live-refreshing non-SPA creation page with template previews and current status
scripts/resolve-non-spa-context.js ← Non-secret environment and Dataverse organization resolver
scripts/validate-site.js ← Node script validating SPA sites or downloaded non-SPA sites
exceptional-web-design/
SKILL.md ← Read-only, platform-aware critique of a non-SPA/SPA site or authorized URL
workflows/non-spa-site.md ← Native evidence/route workflow; the SPA review body remains unchanged
deploy-site/
SKILL.md ← Deployment skill definition
setup-datamodel/
SKILL.md ← Dataverse data model creation skill definition
references/odata-api-patterns.md ← OData API body templates for table/column/relationship creation
scripts/validate-datamodel.js ← Node script validating Dataverse data model creation
add-sample-data/
SKILL.md ← Sample data insertion skill definition
references/odata-record-patterns.md ← OData API patterns for record creation and lookups
add-seo/
SKILL.md ← SEO essentials skill definition (robots.txt, sitemap.xml, meta tags)
scripts/validate-seo.js ← Node script validating SEO assets (robots.txt, sitemap.xml, meta tags)
activate-site/
SKILL.md ← Site activation/provisioning skill definition
scripts/activate-site.js ← Activates a site via PP API + polls status
scripts/generate-subdomain.js ← Random subdomain suggestion generator
scripts/validate-activation.js ← Validates site was provisioned via PP API
create-webroles/
SKILL.md ← Web roles creation skill definition
scripts/validate-webroles.js ← Node script validating web role YAML files were created
integrate-webapi/
SKILL.md ← Web API integration skill definition
scripts/validate-webapi-integration.js ← Node script validating Web API integration code
add-ai-webapi/
SKILL.md ← Generative-AI summarization integration skill (Layer 3; preview)
references/ai-api-reference.md ← Canonical Search/Data Summarization API shapes, headers, error codes
references/explore-prompt.md ← Phase 2 Explore-agent prompt body + manifest shape
references/scope-classification.md ← Phase 3 list-trigger / scope-confirmation question mapping
references/agent-invocation-prompt.md ← Phase 5 ai-webapi-integration prompt template
references/framework-equivalents.md ← Vue/Angular/Astro safe-markdown + citation rendering snippets
scripts/validate-ai-webapi.js ← Node script validating summarization code, headers, and Summarization/* settings
setup-auth/
SKILL.md ← Authentication & authorization skill definition
references/authentication-reference.md ← Login/logout flow, auth service, framework patterns
references/authorization-reference.md ← Role-based access control, guards, directives
scripts/validate-auth.js ← Node script validating auth service and authorization code
setup-solution/
SKILL.md ← Solution creation skill definition
scripts/validate-solution.js ← Validates .solution-manifest.json and queries Dataverse to confirm solution exists
export-solution/
SKILL.md ← Solution export skill definition
scripts/validate-export.js ← Validates solution zip exists, non-empty, contains Solution.xml
import-solution/
SKILL.md ← Solution import skill definition
scripts/validate-import.js ← Validates docs/alm/last-import.json marker and checks for component failures
diagnose-deployment/
SKILL.md ← Deployment diagnostics skill definition (no validator — no artifacts created)
setup-pipeline/
SKILL.md ← CI/CD pipeline setup skill (Power Platform Pipelines — full implementation; GitHub/ADO coming soon)
scripts/validate-pipeline.js ← Validates docs/alm/last-pipeline.json marker (PP Pipelines) or pipeline YAML (GitHub/ADO)
deploy-pipeline/
SKILL.md ← Deployment run skill — creates stage runs, validates package, deploys via PP Pipelines API
scripts/validate-deploy-pipeline.js ← Validates docs/alm/last-deploy.json marker for required fields; blocks on Failed status
plan-alm/
SKILL.md ← ALM orchestrator skill definition (8-phase: detect, gather, plan, approve, execute skills in sequence)
assets/alm-plan-template.html ← HTML template with __PLACEHOLDER__ tokens for the ALM plan document
scripts/render-alm-plan.js ← Renders alm-plan-template.html from planData JSON (stages diagram, checklist, risks)
scripts/validate-plan-alm.js ← Validates docs/alm-plan.html exists and is > 500 bytes; gracefully exits 0 if not a plan-alm session
```
## ALM intent routing — `plan-alm` is the front door
When the user expresses an **ALM intent** in natural language — *promote this site to {env}, ship to staging, deploy to production, set up CI/CD, move to next environment, push out a release, run the pipeline, export and import to staging* — invoke **`/power-pages:plan-alm` first**, before any individual ALM skill. `plan-alm` is a **planner**: it detects the project state, runs the pre-plan completeness check, asks about promotion strategy, and writes a rendered HTML plan (whose `steps[]` array is the recommended execution sequence). **It does not deploy anything.** After the user approves the plan, *the user runs* the individual skills (`setup-solution`, `setup-pipeline`, `deploy-pipeline`, or `export-solution`/`import-solution`, plus `activate-site`/`test-site`) in the plan's order. Each detects the approved plan via its Phase 0 gate, proceeds without re-nagging, refreshes the plan on completion, and points the user at the next step — but never auto-chains. This separation keeps `plan-alm` safe to run unattended (no single answer can trigger an irreversible deployment).
**Do not** jump straight to `/power-pages:setup-pipeline`, `/power-pages:deploy-pipeline`, `/power-pages:export-solution`, or `/power-pages:import-solution` in response to an ALM intent. Those are individual building blocks; running them without a plan first misses the planner's analysis (completeness check, host resolution, deployment-strategy selection, size/split decisions, rendered HTML plan).
**Skip `plan-alm` only when the user is explicit about the individual skill.** Phrases like *"just run setup-pipeline"*, *"skip planning, just deploy"*, *"I only need to export the solution zip"* are direct invocations — honor them. Anything ambiguous about deployment intent → `plan-alm` first.
Every ALM execution skill enforces this with a Phase 0 ALM-plan gate. If a user invokes one directly without a plan, the skill recommends running `plan-alm` first (option 1, recommended) with a *"continue without a plan"* escape hatch; choosing to plan runs `plan-alm` (which only plans) and then the skill proceeds. The Phase 0 gate is meant to fail closed — don't bypass it on the user's behalf.
## Plugin Components
### Agents
Auto-triggered by the main conversation when relevant:
- `data-model-architect`: Read-only agent that analyzes SPA source or active non-SPA site
evidence (Forms, Advanced Forms, Lists, Liquid, permissions, Web API settings, reachable
templates/snippets, and JavaScript), discovers existing Dataverse tables via OData API, and
proposes a data model (new/modified/reused tables + evidence matrix + Mermaid ER diagram). Uses
`pac env who` + Azure CLI auth to query Dataverse. Non-SPA components remain read-only
evidence; the agent never edits them. Renders the ER diagram visually in the browser via
Playwright before entering plan mode. Does NOT create, modify, or delete any tables — purely
advisory. The main conversation uses its output to create tables.
- `webapi-integration`: Implementation agent that creates production-ready Web API integration code for a single Dataverse table in a Power Pages SPA site. Detects the frontend framework (React/Vue/Angular/Astro), creates a shared `powerPagesApi.ts` client (token management, retry logic, OData URL builder) if one doesn't exist, then generates TypeScript entity types, a domain mapper, and a CRUD service layer for the target table. Also creates framework-specific hooks (React), composables (Vue), or injectable services (Angular). Follows Power Pages Web API best practices: `/_api/` endpoints, dual token headers, `@odata.bind` for lookups, explicit `$select` (never `*`), formatted value annotations, exponential backoff retry, and 8-minute token TTL. Handles one table per invocation — invoke separately for multiple tables.
- `table-permissions-architect`: Read-only agent that analyzes SPA source or active non-SPA site Forms, Lists, Liquid and JavaScript, discovers existing web roles and table permissions, and proposes a least-privilege table-permissions plan (web roles → table permissions with CRUD flags and scopes) rendered as a Mermaid flowchart. Checks for `.powerpages-site` to verify SPA deployment; non-SPA sites may instead use a nested or directly opened PAC root. Presents the plan via plan mode; after approval, creates web role and table-permission YAML files using deterministic scripts. Supports an **AI-only read posture** (invoked transitively by `/add-ai-webapi` via `/integrate-webapi`) that proposes `read: true` only, with Parent scope + `appendTo` for `$expand` targets. Invoked by `/integrate-webapi` and `/audit-permissions`.
- `webapi-settings-architect`: Read-only agent that analyzes SPA or non-SPA API callers, queries Dataverse for exact field and relationship metadata, and proposes `Webapi/<table>/enabled` and `Webapi/<table>/fields` site settings. Uses LogicalNames for ordinary columns, `_<LogicalName>_value` for lookup reads, and the exact Navigation Property used by `@odata.bind` for lookup writes. Resolves nested or direct PAC roots. Presents the plan via plan mode; after approval, creates site-setting YAML files using `create-site-setting.js`. Invoked by `/integrate-webapi`.
- `ai-webapi-integration`: Implementation agent that creates production-ready generative-AI summarization service code for a Power Pages SPA site — Search Summary (`/_api/search/v1.0/summary`) and Data Summarization (`/_api/summarization/data/v1.0/...`). Uses raw `fetch` (never the OData wrapper), attaches the `__RequestVerificationToken` CSRF header, groups all functions in a single `aiSummaryService.*` file, emits a framework-idiomatic wrapper (React hook / Vue composable / Angular service / Astro util), and wires real UI call sites with loading/error/content/empty branches, citation rendering, and a safe-markdown renderer. Invoked **sequentially per target** by `/add-ai-webapi` (every target shares the one service file, so parallel runs would conflict).
- `ai-webapi-settings-architect`: Read-only agent that proposes the three Layer-3 summarization settings — `Summarization/Data/Enable`, per-prompt `Summarization/prompt/<identifier>`, and `Summarization/Data/ContentSizeLimit` (mandatory `200000` for list summaries). Cross-checks that Layer 1/2 prerequisites (`Webapi/<table>/*`, table permissions) exist for every summarised table and `$expand` target. Presents the plan via plan mode; after approval, creates site-setting YAMLs (script path, or hand-written block-literal YAML for long/complex prompts). Invoked by `/add-ai-webapi` Phase 6.
### Skills
User-invocable via `/power-pages:<skill-name>`:
- `create-site`: Routes between two eight-phase workflows. The existing SPA path retains requirements
discovery, early React/Vue/Angular/Astro scaffolding, component and design planning, implementation
with live Playwright preview, experience brief, narrative blueprints, batched visual critique,
validation, review, Git checkpoints, and deployment handoff. The
non-SPA path confirms Enhanced with Bootstrap 5, confirms the environment and
administrator-verified enabled EDM toggle state, selects a supported template
identifier, provisions through the Power Platform API, verifies the resulting model, downloads
with the explicit `--modelVersion`, validates identity/assets, and creates a Git baseline. After
a successful non-SPA baseline it can optionally hand off to `edit-site`.
- `exceptional-web-design`: Read-only critique of non-SPA or code-site source and/or an authorized
running URL. Reuses the approved brief, platform adapter, and shared rubric; captures at most
eight key pages with one capture/axe call. It executes no project code, performs no fixes,
and uses a non-SPA-only workflow for provisional source-only findings, unavailable evidence,
sign-in and query-dependent views. The SPA review contract is unchanged. This is separately requested review, not a required
non-SPA styling stage or an expansion of `style-site:2.runtime`.
- `edit-site`: Declarative-only entry point for focused component edits, styling-only
requests, and broad customization of PAC downloads created by `create-site` or
manually in Design Studio. Focused requests follow their reference workflow
directly, without the broad seven-phase workflow.
Styling-only requests follow
`skills/edit-site/references/style-site-workflow.md`, not a separate skill:
Broad customization keeps one canonical approved
JSON/HTML plan plus `current-execution.json` under `docs/edit-site/`, archives
its predecessor in timestamped history, resolves typed cross-operation output bindings, and resumes
pending work without mutating the approved plan. It derives safe content and supported
`section -> columns -> elements` structures, represents card-like layouts with columns rather
than an unsupported native card type. New-site imagery uses approved external HTTPS URLs with
optional source checks and explicit unverified status when unchecked; exact-plan reports remain
validated when supplied. Explicit file delivery outside
that route uses secure staging and verified Web File bindings. It follows the component
authoring references, invokes `author-webpage-content`, and runs the
`style-site` reference workflow last when requested, verifies the combined local diff, commits coherent changes,
and optionally hands off to `deploy-site`.
- `deploy-site`: Routes by project type. SPA sites retain the 6-step build,
`pac pages upload-code-site`, blocked-JavaScript, verification, and activation workflow.
Non-SPA sites use an isolated no-build workflow that validates local identity, confirms an
exact environment and website match when one exists, or requires Enhanced for first-upload
creation. Existing verified Standard sites remain uploadable. It reviews component changes,
obtains final upload consent, and runs `pac pages upload` with an explicit model.
- `setup-datamodel`: 8-phase workflow for SPA and non-SPA sites — verify authentication and
resolve site context, choose an uploaded or agent-derived model, analyze framework source or
active non-SPA components, review the evidence-backed reuse/extend/create proposal,
preflight the exact target environment, create approved tables/columns/relationships via OData,
publish and verify. Writes `.datamodel-manifest.json` for hook validation and never edits
non-SPA site components.
- `add-sample-data`: 6-step workflow — verify prerequisites, discover tables (from `.datamodel-manifest.json` or OData API), select tables & configure record count, generate & review sample data plan, insert records via OData API with relationship handling, verify & summarize.
- `activate-site`: 5-step workflow — verify prerequisites (PAC CLI auth + Azure CLI token + cloud-aware API URL resolution + activation status check via shared script), gather parameters (site name, subdomain, website record ID), confirm with user, activate & poll via `skills/activate-site/scripts/activate-site.js`, present summary with site URL.
- `add-seo`: 7-step workflow — verify site exists, gather SEO config (production URL, exclusions, meta description), plan & approve, create robots.txt, generate sitemap.xml from discovered routes, add meta tags (title, description, viewport, Open Graph, Twitter Card, favicon) to index.html, verify via Playwright & commit.
- `analyze-performance`: 6-phase (+1.5 Learn grounding and 2.5 AI mitigation pass) workflow — locate an existing local non-SPA site folder (reject `powerpages.config.json`, the authoritative SPA marker; accept `.powerpages-site` or root-level `.portalconfig` / `website.yml` exports), scan the entire local codebase via the deterministic `skills/analyze-performance/scripts/analyze-performance.js` for performance anti-patterns (Power Pages lists without page size, FetchXML/entity-backed webpages used as JSON/XML APIs, FetchXML `all-attributes`/no-columns/many-columns/no-paging/large-count/N+1-in-loop/`returntotalrecordcount`/advanced query hints/cache bypassing/leading-wildcard filters/related-column ordering, Web API `$select=*`/no-`$select`/many-columns/no-page-bound/large-`$top`/`$count=true`/expanded rows without nested `$select`/contains-or-endswith filters/request-time date cache bypassing/related-column ordering/N+1, main-thread-blocking client JS — sync XHR, sync jQuery AJAX, `document.write`, short-interval `/_api/` polling — render-blocking `<head>` scripts, runtime CSS `@import`, header/footer output caching disabled, sign-in & page/file tracking, web-file/web-role counts, oversized assets), enrich each static finding with AI-generated `aiSuggestion` and `mitigation` text grounded in the affected customer-code snippet, present findings by severity, offer safe auto-fixes for the site-setting/tracking findings with explicit per-fix consent (surfacing the `{% substitution %}` output-cache caveat), re-scan to verify, then persist an HTML report under `<site>/docs/` via the shared `build-review-data.js` + `render-review.js` pipeline (`analyze-performance.json` → Performance section) and route the manual list/webpage/FetchXML/Web API/JS/CSS fixes. Read-only until consent; supports `--review <out-dir>` mode, including using that directory as the project root when it contains site source. Validator: `skills/analyze-performance/scripts/validate-analyze-performance.js`. Rule catalog: `skills/analyze-performance/references/performance-reference.md`.
- `add-localization`: 8-phase workflow — detect framework/existing i18n, enforce centralized mode availability, validate BCP-47 locales and package choices, approve exact file delta, configure framework localization, extract/generate translations, add runtime selection, validate/build/browser-test, review, and conditionally deploy. React/Vue/Angular runtime are active; Angular static and Astro static remain documented dormant capabilities and must not enter a plan or pass validation.
- `create-webroles`: 6-step workflow with site-type routing. SPA sites retain the existing `.powerpages-site/web-roles/` prerequisite, UUID generation, direct YAML creation, validation, caller-suppress contract, and deploy prompts. Non-SPA sites resolve nested or directly opened PAC roots, inventory active role references, preserve aggregate `webrole.yml` or per-record `web-roles/` storage through the deterministic `create-web-role.js` writer, enforce the same anonymous/authenticated uniqueness constraints, validate, and optionally deploy through the non-SPA `/deploy-site` route.
- `integrate-webapi`: 7-step workflow with site-type routing and request-focused or discovery invocation. SPA sites retain Explore-agent discovery plus per-table `webapi-integration` generation of the API client/types/services/hooks and framework build. Non-SPA sites inspect active Forms, Advanced Forms, Lists, Liquid, templates, JavaScript, settings and permissions; prefer existing native capabilities, route approved Liquid/Web File/caller changes through `edit-site` and `author-webpage-content`, and allow reuse-only/no-change outcomes. Both routes invoke `table-permissions-architect` and `webapi-settings-architect` to configure least-privilege permissions and explicit fields, verify, then optionally deploy. The `[AI-READ-ONLY]` delegated sentinel remains SPA-only.
- `add-ai-webapi`: 8-phase workflow — verify site/deployment, Explore-agent scan for search/data summarization candidates, review plan with user, **delegate Layer 1/2** (Web API site settings + table permissions) to `/integrate-webapi` in AI-only read mode and to `/create-webroles`, invoke `ai-webapi-integration` agent **sequentially per target** to create the summarization service + framework wrapper + UI wiring, invoke `ai-webapi-settings-architect` for Layer 3 (`Summarization/*` settings), verify (header-contract grep, `$select` grep, build, validator), review & deploy. This skill owns **Layer 3 only** and delegates everything else. Validator: `skills/add-ai-webapi/scripts/validate-ai-webapi.js`. AI summarization APIs are a **preview** feature gated by a three-level admin hierarchy.
- `setup-auth`: 8-step workflow — verify prerequisites (site deployed + web roles), gather auth requirements and plan, create auth service with Entra ID login/logout (anti-forgery token + form POST), create authorization utilities (role checking), create auth UI (AuthButton component), apply role-based access control to components, verify auth setup (validate files, build, auth UI renders), create `ProfileRedirectEnabled` site setting and deploy.
- `audit-permissions`: 7-phase read-only workflow with site-type routing. SPA sites retain the existing deployment lookup, source-code analysis, A-K table checklist, HTML report, and final fix offer. Non-SPA sites resolve nested or directly opened PAC roots, obtain Standard/Enhanced only from authoritative PAC output, normalize aggregate/per-record role, permission, setting, and page-rule storage independently, follow active Forms, Advanced Forms, Lists, Liquid, templates and Web API callers, cross-check native permission enforcement and inherited page access, then render the same report. Deterministic fixes preserve verified storage/field conventions; mixed storage remains blocked.
- `setup-solution`: 7-step workflow — verify prerequisites, gather publisher/solution configuration (publisher prefix is irreversible — requires explicit confirmation), check existing publishers/solutions to avoid duplicates, create publisher + solution via OData API, add Power Pages website and web role components via `AddSolutionComponent`, verify components and write `.solution-manifest.json`, present summary. Reuses `references/solution-api-patterns.md`.
- `export-solution`: 7-step workflow — verify prerequisites, identify solution (from `.solution-manifest.json` or user input), confirm managed vs unmanaged export (irreversible choice), trigger `ExportSolutionAsync`, poll via `scripts/poll-async-operation.js`, download and decode solution zip via `DownloadSolutionExportData`, verify zip contains `Solution.xml`. Reuses `scripts/poll-async-operation.js` and `references/solution-api-patterns.md`.
- `import-solution`: 7-step workflow — verify prerequisites and confirm target environment, locate and validate solution zip, configure import (staged vs direct, overwrite options), optionally stage via `StageSolution` to check missing dependencies, import via `ImportSolutionAsync` and poll, verify solution exists in target and write `docs/alm/last-import.json` marker, present component results. Reuses `scripts/poll-async-operation.js`, `scripts/encode-solution-file.js`, and `references/solution-api-patterns.md`.
- `diagnose-deployment`: 7-step workflow — verify prerequisites and locate project, collect artifacts (config, manifests, build output), surface upload errors by re-running `pac pages upload-code-site` in capture mode and parsing via `scripts/parse-deployment-errors.js`, query recent Dataverse async operation failures, pattern-match against `references/deployment-error-catalog.md`, offer auto-fixes for fixable errors with explicit per-fix user confirmation, present findings table (severity/type/status). Never auto-applies any fix without user permission.
- `setup-pipeline`: 7-phase workflow — detect project context (`powerpages.config.json`, `.solution-manifest.json`, `pac env who`, `pac env list`, `RetrieveSetting('DefaultCustomPipelinesHostEnvForTenant')` on dev env to auto-discover host environment), select platform (Power Platform Pipelines = full; GitHub/ADO = coming soon), confirm pipeline configuration with auto-filled values (pipeline name, host env URL, target environments), run preflight checks (Pipelines installed, solution exists, no name conflict), create `deploymentenvironments` records for source + each target (poll `validationstatus` until Succeeded), create `deploymentpipelines` record + `$ref` associate source env (relative path + `@odata.context`) + create `deploymentstages` per target, verify and write `docs/alm/last-pipeline.json` + `docs/pipeline-setup.md` + commit. Uses `references/cicd-pipeline-patterns.md` for all HAR-confirmed API patterns.
- `deploy-pipeline`: 8-phase workflow — verify prerequisites (`docs/alm/last-pipeline.json`, az login, host env token), select target stage (from stages in `docs/alm/last-pipeline.json`; warn if last deploy failed), **pre-flight check on the target env's `blockedattachments` setting** via `fix-blocked-attachments.js --dry-run` (Phase 2.5, Power Pages projects only — prompts the user to unblock `.js`/`.css` proactively when they're on the env's blocklist, saving the ~50-75 min wasted import for sites with thousands of bundle chunks; complementary to the reactive Phase 7.6 handler), resolve pipeline info via `RetrieveDeploymentPipelineInfo` (v9.1) to get `SourceDeploymentEnvironmentId` and available artifacts, create `deploymentstageruns` record + call `ValidatePackageAsync` (204) + poll `operation` field until not `200000201` (surface `validationresults` issues), optionally PATCH `deploymentsettingsjson` for env var / connection reference overrides, **final deploy consent gate at Phase 6.0** (explicit `Deploy now / Cancel` `AskUserQuestion` before either `DeployPackageAsync` or the `pac pipeline deploy` fallback — closes a gap where Phase 5 → Phase 6.1 could fire without a final confirmation when validation passes cleanly), call `DeployPackageAsync` + poll `stagerunstatus` until terminal (handle approval gates with user pause), write `docs/alm/last-deploy.json` + present deployment summary.
- `force-link-environment`: 6-phase workflow — verify prerequisites (Azure CLI token for the target host, PAC CLI auth) and ground in Microsoft Learn (`custom-host-pipelines#using-force-link…`), identify host env URL (from `docs/alm/last-host-check.json`, `docs/alm/last-pipeline.json`, or user input) and source dev env's BAP env GUID, resolve or create the `deploymentenvironments` record on the new host (re-querying by `environmentid` to recover the record ID when `create-deployment-environment.js` throws on the "already associated" validation failure), require explicit `AskUserQuestion` consent for the destructive cross-host stamp move (makers in the previous host lose pipeline access for this env; previous host's record is left with stale `validationstatus`; reversible by re-running from the previous host), call `scripts/lib/force-link-environment.js` to POST `ManageEnvironmentStamp` + re-poll `validationstatus` until Succeeded, write `docs/alm/last-force-link.json` marker. Auto-fix entry point for Pattern 15 in `references/deployment-error-catalog.md`.
- `plan-alm`: 4-phase **planner** workflow — detect project state (powerpages.config.json, existing manifests, pac env who), gather ALM strategy via branched question flow (PP Pipelines or Manual export/import path), generate HTML ALM plan (docs/alm-plan.html with pipeline diagram and a recommended-execution checklist), then save it (Approved or Draft) and commit. **It does not execute any deployment.** The user runs the individual ALM skills afterward — `setup-solution`, `setup-pipeline`/`export-solution`, `deploy-pipeline`/`import-solution`, `activate-site`, `test-site` — each of which detects the plan (Phase 0 gate), proceeds, and refreshes the plan on completion (via `refresh-alm-plan-data.js`, which also reports the next recommended step). This keeps `plan-alm` safe under autopilot: it never triggers an irreversible action.
For small mid-cycle changes (one file, one snippet, one site setting) that previously used a separate hotfix solution: instead, run `setup-solution` in sync mode to adopt the modified components into the existing base solution, bump the solution version, and use `deploy-pipeline` to ship. This keeps a single solution lineage (cleaner audit trail, simpler dependency management) and avoids solution sprawl. Power Platform Pipelines computes incremental imports internally, so re-deploying the base after a small fix is fast.
Skills are defined in `SKILL.md` files with YAML frontmatter (name, description, allowed-tools, model). Skill-specific `hooks:` blocks are not used — hook registration is centralized.
### Hooks
Hook registration is centralized in `hooks/hooks.json` — a single PostToolUse hook (matcher `Skill`) runs `hooks/run-skill-posttool-validation.js` after every Skill tool call. The runner derives tracked skills directly from `skills/*/SKILL.md` via `scripts/lib/powerpages-hook-utils.js`, looks up an optional `skills/<skill>/scripts/validate*.js` validator for the skill that just completed, and invokes it with the current cwd.
**ALM plan reconcile backstop (auto-heal).** After any **ALM plan skill** completes (`powerpages-hook-utils.js → ALM_PLAN_SKILLS` / `isAlmPlanSkill`) and a `docs/.alm-plan-data.json` exists in the cwd, the runner also `spawnSync`s `refresh-alm-plan-data.js --reconcile --render`. The `refresh-alm-plan-data.js` calls in each SKILL.md are advisory — silently dropped on session fragmentation, manual execution, or oversight — so the reconcile *performs* any refresh whose marker (`docs/alm/last-*.json`) is newer than the plan. This is **best-effort and non-blocking**: it never changes the hook's exit code, honors `.alm-deferred`, and is idempotent. Because it fires on *any* ALM skill (not just the marker's writer), a skip in skill A is healed when the next ALM skill (B) completes. Skills keep their explicit per-phase refresh calls as defense-in-depth + immediate render; the hook is the backstop.
To wire a new skill into validation:
1. Write the validator at `skills/<skill>/scripts/validate-<skill>.js` using the `runValidation((cwd) => { ... })` pattern from `scripts/lib/validation-helpers.js`.
2. No manual tracked-skill registration is needed. Any folder with `skills/<skill>/SKILL.md` is automatically tracked for telemetry and hook detection.
3. Add or update test coverage in `scripts/tests/powerpages-hook-utils.test.js` if you introduce a new validator naming pattern.
All skill folders are tracked. Skills without a `scripts/validate*.js` file are tracked for telemetry/detection but skip validation.
**Anti-patterns** (see `PLUGIN_DEVELOPMENT_GUIDE.md` for the rationale): do not add `hooks: Stop:` blocks to individual SKILL.md frontmatter — they duplicate the centralized PostToolUse hook and fire too often. Do not use `type: prompt` Stop hooks for skill-completion checks — they create runaway forced-continuation loops.
### Shared Scripts
Shared utility scripts live at `scripts/` and are referenced by multiple skills and agents via `${PLUGIN_ROOT}/scripts/`.
- `generate-uuid.js`: Generates a random UUID v4. Self-contained, no dependencies. Used by `create-webroles` and the main agent when creating table permission / site setting files from the `webapi-permissions` agent plan.
- `update-skill-tracking.js`: Updates skill usage tracking site settings. Takes `--projectRoot`, `--skillName`, and `--authoringTool` args. The agent passes its own name as `--authoringTool` (e.g., `ClaudeCode`, `GitHubCopilot`). Creates/increments a per-skill counter (`Site-AI-Skills-<SkillName>.sitesetting.yml`) and records the authoring tool (`Site-AI-Tools-AuthoringTool.sitesetting.yml`). Resolves nested SPA metadata or directly opened non-SPA PAC roots, preserves aggregate `sitesetting.yml` or per-record `site-settings/` storage, no-ops for missing storage, and rejects mixed serialization before writing. Used by every user-invocable skill (each skill calls it in its final phase per the skill-tracking convention).
- `check-activation-status.js`: Checks whether a Power Pages site is already activated (provisioned) in the environment. Takes `--projectRoot` arg. **Site-identity resolution (`resolveSiteIdentity()`, exported + injectable for tests): (1) `powerpages.config.json` (code/SPA sites) → `siteName` + optional `websiteRecordId`; (2) else `.powerpages-site/website.yml` (non-SPA/data-model sites) → `name`→`siteName`, `id`→`websiteRecordId`; (3) `pac pages list` ONLY when the GUID is still unknown** — non-SPA sites (and SPA sites whose config included the GUID) skip the `pac pages list` exec entirely. Then queries the Power Platform GET websites API and matches by both `websiteRecordId` and `name`. Outputs JSON: `{ activated: true/false, siteName, websiteRecordId, websiteUrl }` or `{ error }`. The CLI flow is guarded by `require.main === module`; `module.exports = { resolveSiteIdentity, getWebsites }`. Used by `deploy-site` and `activate-site`.
- `poll-async-operation.js`: Polls a Dataverse `asyncoperations` record until it reaches a terminal state (Succeeded/Failed/Canceled) or times out. Args: `--asyncJobId`, `--envUrl`, `--token` (optional, refreshed via Azure CLI if omitted), `--intervalMs` (default 5000), `--maxAttempts` (default 60). Outputs JSON status. Used by `export-solution` and `import-solution`.
- `encode-solution-file.js`: Base64-encodes a solution zip file for use in Dataverse OData request bodies (`ImportSolutionAsync`, `StageSolution`). Args: `--zipPath`. Outputs `{ encoded, fileSizeBytes, fileName }`. Used by `import-solution`.
- `parse-deployment-errors.js`: Parses PAC CLI stderr output or OData error JSON into structured findings array. Each finding has `{ patternId, type, severity, message, rawMatch, autoFixAvailable, suggestedFix }`. Reads from `--input`, `--file`, or stdin. Used by `diagnose-deployment`.
Shared lib modules live at `scripts/lib/` and are imported by other scripts via `require('./validation-helpers')` or sibling requires. Never inline their logic in skill scripts — always require from `scripts/lib/`.
#### ALM Prerequisites & Context
- `scripts/lib/verify-alm-prerequisites.js`: Verifies all prerequisites for ALM skills — PAC CLI installed + authenticated (`pac env who`), Azure CLI installed + logged in, Dataverse API reachable (`WhoAmI`). Args: `--envUrl` (opt, overrides env from PAC CLI), `--require-manifest` (fails if `.solution-manifest.json` not found), `--expectedEnvUrl` (opt — **env-drift guard**: assert the resolved env matches this origin and HARD-STOP on mismatch). Output: `{ envUrl, token, userId, organizationId, tenantId }`. Exit 0 on success, exit 1 on any failure. **`--expectedEnvUrl` is the recommended guard for any ALM skill that runs against the project's source/dev env**: since `getEnvironmentUrl()` now parses PAC 2.8.x's `Org URL:` successfully, a drifted PAC context resolves silently instead of failing loudly (the old parse-miss had been an accidental safety net), so an ALM op could target the wrong environment (e.g. PROD). Skills pass the project's env URL (from `.solution-manifest.json` top-level `environmentUrl` / `powerpages.config.json` `environmentUrl` / the approved plan's source env) so a mismatch stops the run before any token/write. Prefer this over pinning `--envUrl`, which only redirects the Dataverse-API calls while later PAC-CLI ops (`pac pipeline deploy`, `pac env select`) still follow the ambient context. Used by `setup-solution`, `export-solution`, `import-solution`, `setup-pipeline`, `deploy-pipeline`, `plan-alm`.
- `scripts/lib/detect-project-context.js`: Reads Power Pages project context from the project root. The `siteType` discriminator is the **build axis** — SPA vs non-SPA (Design Studio) site — NOT the Dataverse data-model axis (a non-SPA site can be on the Standard OR Enhanced data model; both download to a `.powerpages-site/` tree). Canonical output is `siteType: "spa" | "non-spa"`; consumers normalize released aliases `"code"`, `"declarative"`, and `"data-model"` when reading older plans or direct helper input. Resolves identity in order: (1) `powerpages.config.json` → `siteType: "spa"`; (2) `.powerpages-site/` → `siteType: "non-spa"` (Standard or Enhanced, with no `powerpages.config.json`). The authoritative non-SPA marker is `.powerpages-site/.portalconfig/`; `website.yml` supplies identity but does not establish the type because both site types carry it. `environmentUrl: null` for non-SPA sites because callers re-confirm through `pac env who`. Also reads `.solution-manifest.json` and `.datamodel-manifest.json`. Args: `--projectRoot` (opt). Output: `{ projectRoot, siteType, siteName, websiteRecordId, environmentUrl, solutionManifest, datamodelManifest }`. Exit 0 on success, exit 1 only if neither site marker is found.
- `scripts/lib/alm-paths.js`: Single source of truth for ALM artifact paths. Exports `ALM_DIR` (always `docs/alm`), `FILE_NAMES` (frozen object mapping logical key → filename for all 14 ALM artifacts), `almDir(projectRoot) → path`, `almPath(projectRoot, key) → path`, `ensureAlmDir(projectRoot) → path` (mkdir -p idempotent). Every ALM-only state file (5 plan/decision JSONs + 9 `last-*.json` skill-run markers including `last-export.json`) writes under `<projectRoot>/docs/alm/`. **Always resolve through this helper** — never inline a raw `docs/alm/...` path in a script. Files intentionally NOT moved here (and not in `FILE_NAMES`): `.solution-manifest.json`, `.datamodel-manifest.json`, `.alm-config.json`, `.alm-deferred`, `deployment-settings.json`. Adding a new ALM marker means adding its key + filename to `FILE_NAMES` first; `almPath` throws on unknown keys to catch typos at call-site.
- `scripts/lib/check-alm-plan.js`: Phase 0 gate helper used by every ALM skill to detect (a) whether an ALM plan exists for this project, (b) whether the user has explicitly deferred ALM via the `.alm-deferred` marker, and (c) whether an existing plan is stale (the source solution was modified after the plan was generated). Args: `--projectRoot`, `--envUrl` (opt — required for staleness check), `--token` (opt), `--solutionId` (opt — required for staleness check). Output: `{ exists, deferred, deferral, planPath, htmlPath, stale, staleness: { reason, detail }, generatedAt, planStatus, solution: {...} }`. Without env/solution context the helper does an existence-only check; with them it queries Dataverse for `solutions(solutionId)?$select=modifiedon` and compares against `planData.generatedAt`. Used by `setup-solution`, `setup-pipeline`, `deploy-pipeline`, `export-solution`, `import-solution`, `configure-env-variables`, `ensure-pipelines-host`, `force-link-environment` Phase 0 gates — the "fail closed when no plan" pattern. **PLAN_STATUS lifecycle — promotes `Approved` → `In Execution`:** plan-alm is plan-only and leaves the plan `Approved`; this helper performs the `Approved` → `In Execution` transition (and writes the first heartbeat) the **first time an execution skill's Phase 0 runs** — it is the only thing that sets `In Execution`, so without it the heartbeat/active-chain machinery (multi-hour-deploy `stale-heartbeat` reclassification) never engages. Gated on heartbeat-write: read-only callers pass `--no-heartbeat` (plan-alm's own deferral check, audits, tests) and are never promoted. The terminal `In Execution` → `Completed` transition is owned by `refresh-alm-plan-data.js` (completion evaluator).
- `scripts/lib/set-plan-status.js`: **The single deterministic owner of the creation-time `Draft` / `Approved` write** — the one PLAN_STATUS transition that used to be done by hand-authored `Edit`s in plan-alm Phase 4 (to the HTML spans *and* the JSON), with no helper. Because the badge + `approved-by` / `approval-date` spans are re-derived from `docs/.alm-plan-data.json` on every render, the old manual HTML Edit was non-durable (reverted on the next refresh) and a partial write left the plan "approver recorded but PLAN_STATUS=Draft" — stuck forever, since `check-alm-plan.js` only promotes from `Approved`. This helper writes `PLAN_STATUS` + `PLAN_MODE` + `APPROVED_BY` + `APPROVAL_DATE` **together** (atomic temp+rename) and optionally re-renders (reuses `refresh-alm-plan-data.js → findRendererPath`/`invokeRenderer`). Enforced invariants: only `Draft` / `Approved` are settable here (`In Execution` is owned by `check-alm-plan.js`, `Completed` by `refresh-alm-plan-data.js`); `Approved` **requires** a non-empty `--approver`; `Draft` **clears** the approver fields; a plan already `In Execution` / `Completed` is **not** re-drafted without `--force`. Args: `--projectRoot`, `--status Draft|Approved`, `--approver`, `--approvalDate` (opt — defaults to now), `--force`, `--render`, `--rendererPath` (opt). Output: `{ ok, previousStatus, status, mode, approver, approvalDate, rendered }`. Called by plan-alm Phase 4 (both save options) and the Phase 1 step-0b in-place Draft→Approved fast-path. The `validate-plan-alm.js` consistency guard blocks the two half-written states (`Draft`+approver, `Approved`+no-approver) for plans created the old way or hand-edited.
- `scripts/lib/resolve-target-solution.js`: Resolves "which solution should this new Dataverse record land in?" Implements the strict 3-step order from the ALM-aware-by-default principle: (1) explicit `--solutionUniqueName` (or equivalent caller arg) wins; (2) `.solution-manifest.json` in the project root; (3) neither → throw `NoSolutionConfiguredError`. **The module NEVER auto-picks from Dataverse** — interactive prompt UX is the caller's responsibility (catch the error, present an `AskUserQuestion` list, re-invoke with `explicit` populated). Callers that need to confirm the solution still exists in Dataverse can pass `verifyExists: true`; the module then enriches the result with `{ solutionId, version, ismanaged }`. Component-creation scripts must require this helper and pass through `--solutionUniqueName` so records land in the user's solution instead of `Default`.
#### Solution Splitting Decision Tree (v1.3.0+)
- `scripts/lib/alm-thresholds.js`: Central default threshold constants for the split decision tree. Loads optional `.alm-config.json` from project root and merges over defaults. Exports `DEFAULTS`, `DEFAULT_CONFIG`, `loadConfig(projectRoot)`, `classifyTier(value, greenUpperExclusive, yellowUpperExclusive)`, `deepMerge(target, source)`. Used by `estimate-solution-size.js` and `compute-split-plan.js`.
- `scripts/lib/estimate-solution-size.js`: Estimates solution size + component counts by querying Dataverse. Args: `--envUrl`, `--websiteRecordId`, `--token` (opt), `--publisherPrefix` (opt), `--siteName` (opt), `--solutionId` (opt — scopes env var count to the target solution; without it falls back to a publisher-prefix tenant-wide query that overcounts when prefix is shared), `--datamodelManifest` (opt), `--projectRoot` (opt — enables disk cross-check: walks the local build-output directory (`dist/`, `public-output/`, `build/`, `.output/`) and surfaces the byte total). Output: `{ totalSizeMB, componentCountSiteTotal, componentCountSiteActionable, componentCountInSolution, orphansOnSite, tableCount, tableCountScope, schemaAttrCount, webFilesAggregateMB, webFilesIndividual[], webFileCount, webFileSampleSize, webFilesDiskMeasuredMB, webFilesDiskMeasuredPath, webFilesDiskFileCount, cloudFlowCount, botCount, envVarCount, envVarCountScope, envVarCountTenantWide, mediaRatio, siteType, tables[], tableRelationships[], breakdown, estimationMethod, estimationAccuracyPct, truncationSuspected, truncationWarnings, ppcGroundTruthCount }`. **Table discovery is site-referenced, NOT publisher-prefix:** `tableCount`/`tables[]` are scoped to the custom tables the site actually references — its `.powerpages-site/table-permissions/` (+ datamodel manifest) intersected with the env's custom-unmanaged tables (via `resolve-site-tables.js` + `query-metadata.js`). `tableCountScope` ∈ `"site-referenced" | "manifest-only" | "unavailable"` (the last → 0 tables, never an env-wide prefix dump). `--publisherPrefix` now scopes ONLY the env var count, not tables. `tableRelationships[]` are `[a,b]` dependency edges (lookups + N:N, via `query-table-relationships.js`) among the scoped tables, consumed by `compute-split-plan.js` to cluster related tables into the same solution. Metadata-based estimation with ±15% caveat. Web-file size is measured via stratified sample (first 50 + middle 50 + last 50, cap 150) scaled to full count. Disk fields are null unless `--projectRoot` was passed AND a build-output directory was found. Truncation canaries fire when Dataverse pagination disagrees with `@odata.count`, when ppcs land on a page-size boundary, when sampled average bytes/file < 1 KB at scale, or when the disk total exceeds the Dataverse total by >2× — any signal flips `truncationSuspected: true` with a per-cause `truncationWarnings[]` entry. Used by `plan-alm` Phase 1 Step 10.
- `scripts/lib/compute-split-plan.js`: Runs the split decision tree against a size-estimate blob. Args: `--estimate <path>`, `--projectRoot` (opt — for `.alm-config.json` overrides), `--siteName` (opt), `--publisherPrefix` (opt). Output: `{ sizeAnalysis, assetAdvisory, splitStrategy, appliedStrategies, compositeSubPartitioned, proposedSolutions[], recommendations[], truncationSuspected, truncationWarnings }`. Evaluates strategies in priority order: Strategy 3 (Schema Segmentation) → Strategy 1 (Layer Split) → Strategy 2 (Change-Frequency) → Strategy 4 (Config Isolation). **Schema Segmentation is dependency-aware + capacity-bounded:** it builds connected-component clusters from `estimate.tableRelationships` (union-find), then bin-packs whole clusters (never splitting a relationship) into the fewest solutions that keep each under `maxTableCount`/`maxSchemaAttrs` **where possible** — capped at `maxSchemaSplitSolutions` (default 8). This replaced the old one-solution-per-table-name-stem heuristic that produced ~one solution per table. Two cases CAN exceed a per-solution cap, and BOTH raise an `recommendations[]` warning rather than failing silently: (a) an indivisible dependency cluster larger than `maxTableCount` stays whole (oversized-cluster table-count warning); (b) when MORE than `maxSchemaSplitSolutions` independent attr-heavy clusters must share the capped solution count, the FFD least-loaded fallback co-locates clusters and a solution's summed columns exceed `maxSchemaAttrs` (oversized-schema attr-cap warning). The split trigger + thresholds are unchanged — only the packing. Strategy 4 stacks additively. Strategy 1 also runs a composite sub-partition pass: when Core still exceeds the size OR component-count cap after Web Assets are peeled off, Core is replaced with change-frequency-shaped sub-children (`_Foundation`/`_Config`/`_Content`, plus `_Integration` whenever the parent had any flows or bots — coverage takes priority over the `changeFreqMinFlows` heuristic, which governs only the TOP-LEVEL strategy choice). When additive Strategy 4 is firing concurrently (a top-level `_EnvVars` solution), `_Config` drops `Environment Variable` from its componentTypes to avoid double-claim; when it isn't, `_Config` absorbs env vars so they have an owner. Sub-partitioning sets `compositeSubPartitioned: true` and appends `composite-sub-partition` to `appliedStrategies`. `validateSplits` checks BOTH the size AND component-count cap per split (skipping `isFutureBuffer` solutions). Supports `.alm-config.json` overrides including `strategyOverride` to bypass the tree. See `solution-splitting-logic.md` spec in design docs for full logic.
- `scripts/lib/resolve-site-tables.js`: Single source of truth for "which custom tables does this site actually use." `collectReferencedEntityNames({ projectRoot, datamodelManifestPath })` reads `.powerpages-site/table-permissions/*.tablepermission.yml` (`entitylogicalname`, via `powerpages-config.js → loadTablePermissions`) + the datamodel manifest → `{ names:Set, available, sources }`. `scopeCustomTables(referencedNames, customUnmanagedTables)` intersects that set with the env's custom-unmanaged tables. SME-confirmed: table permissions are the complete signal ("if a table is used in the site there will be permissions for it"), so forms/lists are NOT scanned. Used by `estimate-solution-size.js` and `discover-site-components.js` to replace the publisher-prefix table dump.
- `scripts/lib/query-metadata.js`: `queryCustomUnmanagedTables(envUrl, token, makeRequest?)` → `[{ logicalName, metadataId, schemaName, displayName }]` (the single `EntityDefinitions?$filter=IsCustomEntity` query, `IsManaged===false` filtered). Consolidates the formerly-triplicated custom-table query (estimator, discover-site-components, setup-solution). Reuses `odataGetAll` from `validation-helpers.js`.
- `scripts/lib/query-table-relationships.js`: `fetchTableRelationships(envUrl, table, token, makeRequest?)` → `{ oneToMany[], manyToMany[] }`. Extracted from `skills/audit-permissions/scripts/query-table-relationships.js` (now a thin CLI wrapper over this lib) and extended with ManyToMany. OneToMany errors propagate; ManyToMany is best-effort. Used by the estimator to build `tableRelationships[]` and by audit-permissions for relationship-scope validation.
- `scripts/lib/validation-helpers.js` also exports `odataGet(url, token, makeRequest?)` + `odataGetAll(url, token, makeRequest?, maxPages?)` — the shared, injectable OData GET + `@odata.nextLink` pagination used by the new metadata/relationship helpers (avoids each lib rolling its own paginator).
#### Solution Management
- `scripts/lib/verify-solution-exists.js`: Checks whether a Dataverse solution exists by unique name via OData `solutions?$filter=uniquename eq '...'`. Args: `--envUrl`, `--uniqueName`, `--token` (opt). Output: found → `{ found: true, solutionId, uniqueName, version, isManaged }`, not found → `{ found: false, uniqueName }`. Exit 0 regardless of found/not-found; exit 1 on API error.
- `scripts/lib/create-solution.js`: Creates a Dataverse solution via OData POST to `/solutions`. Handles 409 (already exists) by re-querying and returning the existing record's ID. Args: `--envUrl`, `--token`, `--uniqueName`, `--friendlyName`, `--version`, `--publisherId`, `--description` (opt). Output: `{ solutionId, uniqueName, created }` where `created: false` means it already existed.
- `scripts/lib/bump-solution-version.js`: Bumps the patch segment (4th segment) of a Dataverse solution's version and PATCHes it back. **Single source of truth for the bump rule** — pads missing trailing segments with `0` (so `1.0` → `1.0.0.1`), uses integer arithmetic (`1.0.0.9` → `1.0.0.10`, not lexical), rejects non-numeric or negative segments, rejects more than 4 segments. Used by `setup-solution` Phase 4 sync-mode bump AND `export-solution` Phase 4 Step 4.0 (always-on pre-export bump so every produced zip carries a strictly-increasing version label for the manual export/import path — eliminates the "exported zip carries same version as previous export" failure for managed-solution upgrades). Args: `--envUrl`, one of (`--uniqueName` OR `--solutionId`), `--token` (opt — refreshed via `getAuthToken` if omitted), `--dryRun` (opt — computes the next version without PATCHing). Output: `{ solutionId, uniqueName, previous, next, bumped }`. Exit 0 on success, exit 1 on missing args / solution not found / PATCH rejected. **Also exports `compareVersions(a, b) → -1|0|1` and `parseVersionToSegments(v) → number[4]`** as programmatic helpers — `compareVersions` is the canonical way for any caller to compare two Dataverse version strings (`import-solution` Phase 3.0 uses it for the version-skew advisory). Same segment-wise integer rules — `compareVersions('1.0.0.9', '1.0.0.10')` correctly returns `-1`, where raw string `>` would say `1.0.0.9 > 1.0.0.10` is true and label the 10th deploy of the day as a downgrade. Never compare version strings with raw `>`/`<`/`===` in SKILL.md prose — always shell out to `node -e "console.log(require('.../bump-solution-version').compareVersions(...))"`. **Do not inline the bump or comparison rule in any SKILL.md** — both setup-solution and export-solution must call this helper so divergent semantics cannot happen.
- `scripts/lib/create-solutions-batch.js`: Parallel bulk creation of Dataverse solutions sharing one publisher. Used by `setup-solution` Phase 4 Step 2 in `MULTI_SOLUTION_MODE` when the split plan recommends N solutions. Fans out via `Promise.allSettled` so independent failures don't poison the batch — typical 5-6 solution splits complete in ~2s vs ~10s for a serial agent loop. Args: `--envUrl`, `--publisherId`, `--solutionsFile <path>` (JSON array of `{ uniqueName, friendlyName, version, description, isFutureBuffer? }`), `--token` (opt; refreshed once at batch start via `getAuthToken` if omitted). Skips entries with `isFutureBuffer: true` (reserved 0/0 slots that exist as data only). Output: `{ total, success, skipped, failed, results: [{ uniqueName, solutionId, created } | { uniqueName, skipped: true, reason: "futureBuffer" } | { uniqueName, error }] }`. Exit 0 always (caller inspects `failed` + per-entry `error`); exit 1 only on fatal setup errors (missing required args, unparseable JSON).
- `scripts/lib/discover-component-types.js`: Resolves Dataverse solution component type integers at runtime by querying `solutioncomponents` for known object IDs — never hardcodes component types. Args: `--envUrl`, `--token`, `--websiteRecordId`, `--powerpageComponentId` (opt), `--siteLanguageId` (opt), `--objectIds` (opt, comma-separated for generic lookup). Output: `{ websiteComponentType, subComponentType, siteLanguageComponentType, resolved[] }`.
- `scripts/lib/add-components-to-solution.js`: Bulk-adds solution components via `AddSolutionComponent` OData action. Refreshes the Azure CLI token every `--batchSize` calls (default 20). Treats "already in solution" as success (idempotent). Args: `--envUrl`, `--componentsFile` (path to JSON array of `{ componentId, componentType, addRequired?, description? }`), `--solutionUniqueName`, `--batchSize` (opt), `--token` (opt). **Input shape validated upfront** — keys must be camelCase; PascalCase entries (`ComponentId`/`ComponentType`) are rejected with a targeted error before any Dataverse call (closes a silent-failure mode where the destructure returned `undefined` and produced a stream of HTTP 400 "missing parameters" responses). Per-entry validation surfaces the first malformed row with its array index. Output: `{ total, success, skipped, failed, failures[] }`. Progress goes to stderr; exits 0 always (caller inspects `failures`); exits 1 on fatal setup errors (missing required args, malformed input).
- `scripts/lib/classify-site-settings.js`: Single source of truth for the credential regex + tier classification used by `plan-alm` Phase 1 Step 7 and `setup-solution` Phase 5. Exports `classify`, `bulkClassify`, `autoClassifyCredential`, plus the four named regexes (`CREDENTIAL_REGEX`, `AUTH_PREFIX_REGEX`, `CREDENTIAL_SECRET_REGEX`, `CREDENTIAL_STRING_REGEX`). CLI mode reads JSON array from stdin, emits the four-bucket `{ keepAsIs, authNoValue, promoteToEnvVar, credentialNeedsDecision }` shape. **Do not inline the regex** in any SKILL.md — both skills must require this module so a regex change propagates to plan time AND execution time.
- `scripts/lib/generate-env-var-schema-name.js`: Single source of truth for the canonical env var schema name rule `{prefix}_{settingName.replace(/[^A-Za-z0-9]+/g,'_').toLowerCase()}`. Used by `setup-solution` (creates definitions) and `configure-env-variables` (references them). Inlining the rule risks divergent schema names across skills — call the helper. Args: `--publisherPrefix`, `--settingName`. Output: `{ schemaName, sanitized }`.
- `scripts/lib/create-env-var-definition.js`: Creates an `environmentvariabledefinition` record in Dataverse. Handles 409 (duplicate) by returning the existing definition's ID. Args: `--envUrl`, `--token`, `--schemaName`, `--displayName`, `--type` (opt — canonical Dataverse option-set codes: `100000000`=String, `100000001`=Number, `100000002`=Boolean, `100000003`=JSON, `100000004`=DataSource, `100000005`=Secret), `--defaultValue` (opt). Output: `{ definitionId, schemaName, created }`. **Note**: earlier revisions of this helper and `discover-env-var-definitions.js` had Secret/JSON swapped (Secret=100000003); both are now correct per the canonical mapping verified against live tenant data.
- `scripts/lib/link-site-setting-to-env-var.js`: Links an `mspp_sitesetting` record to an `environmentvariabledefinition` via OData PATCH on the v9.0 API (not v9.2). HAR-confirmed: navigation property is `EnvironmentValue@odata.bind`; headers `if-match: *` and `clienthost: Browser` are required (omitting causes 400). Args: `--envUrl`, `--token`, `--siteSettingId`, `--definitionId`, `--schemaName`. Output: `{ ok, verified, siteSettingId, definitionId }`.
- `scripts/lib/install-pipelines-app.js`: Installs the Power Platform Pipelines application package on an existing Dataverse env (replaces ensure-pipelines-host Phase 4.B's manual PPAC click-through). Resolution: BAP `applicationPackages` LIST + `/install` POST → 200 sync / 202 + Location poll, with PAC CLI fallback (`pac application install --environment-id ... --application-list msdyn_AppDeploymentAnchor`) on 401/403/5xx. 409 on install POST treated as idempotent (already-installed). Args: `--bapToken`, `--envId`, `--instanceApiUrl` (opt — for verification probe), `--hostToken` (opt), `--no-pac-fallback` (opt), `--correlationId`, `--timeoutSec`, `--apiVersion`, `--bapBase`. Output: `{ status, alreadyInstalled, installPath: 'bap'\|'pac'\|'cached', packageUniqueName, pipelinesSolutionVersion, durationSec, correlationId, pollAttempts, locationHeader, pacFallbackReason }`.
- `scripts/lib/discover-env-var-definitions.js`: Enumerates env var definitions matching a publisher prefix and joins each with its bound `mspp_sitesetting` (if any). Used by `plan-alm` Phase 1 Step 10b to populate `planData.envVars[]` with row-level metadata so the rendered plan's Env Variables tab shows schema name, type, default value, and bound site setting per definition (instead of just a count). Args: `--envUrl`, `--publisherPrefix`, `--websiteRecordId`, `--token` (opt). Output: `{ envVars: [{ schemaName, type, defaultValue, siteSetting }], count }`. Degrades gracefully (empty array, exit 0) on auth failure or query errors so the renderer's count-summary fallback can take over.
- - `scripts/lib/refresh-alm-plan-data.js`: Updates `docs/.alm-plan-data.json` with post-run state from the marker files written by setup-pipeline / deploy-pipeline / ensure-pipelines-host / test-site / import-solution / activate-site / configure-env-variables / setup-solution / export-solution, then optionally re-renders `docs/alm-plan.html`. Driven by the execution skills' final-phase refresh (and the PostToolUse `--reconcile` backstop) — NOT by plan-alm, which is now a plan-only 4-phase planner that only renders the initial plan in Phase 3 — so the rendered Pipelines tab, Validation tab, hostResolution card, env var values matrix, checklist, and risks list reflect actual run state instead of frozen pre-run intent. Args: `--projectRoot`, `--phase` (`setup-solution`/`setup-pipeline`/`configure-env-variables`/`deploy-pipeline`/`export-solution`/`import-solution`/`activate-site`/`test-site`/`ensure-pipelines-host`/`finalize`) **OR `--reconcile`** (mutually exclusive with `--phase`), `--render` (also invoke renderer), `--stageName` (required for `test-site`; preferred for `import-solution`/`activate-site` though both can resolve via marker URL match). Output: `{ ok, phase, dataPath, htmlPath, rendered }`. Returns `ok:false` (soft no-op) when `docs/.alm-plan-data.json` is missing — caller should preserve that file across phases for the helper to work. Plan-alm Phase 3 must NOT delete the file after the initial render — it's read by `check-alm-plan.js` for downstream Phase 0 ALM-plan gates and by this helper for post-run refreshes. **Cross-cutting behaviors**: (a) `setStepStatus` flips the matching entry in `planData.steps[]` to `completed` (or `failed` when the phase's marker indicates failure) — case-insensitive keyword match + stage filter, respects `skip: true`, never regresses completed→pending; (b) `deploy-pipeline` AND `configure-env-variables` both backfill `planData.envVars[i].values{}` from the project root's `deployment-settings.json` so the rendered plan's "Values by Environment" matrix auto-populates (accepts both top-level-stage and nested-`stages` shapes; `SchemaName`/`Value` and camelCase variants; never overwrites a populated cell — manual override wins); (c) `configure-env-variables` and `setup-solution` both re-ingest `docs/alm/last-env-vars.json` (when present) so freshly-created definitions appear in `planData.envVars[]` and `plannedEnvVarCount` zeros out; (d) `export-solution` ingests `docs/alm/last-export.json` into `planData.manualMeta.lastExport` (all 10 marker fields: solutionUniqueName/solutionId/previousVersion/version/managed/sourceEnvironmentUrl/zipPath/fileSizeBytes/asyncOperationId/exportedAt) so the Manual-path tab can show the most recent export. Marker absence is a silent step-sync-only no-op (no `manualMeta.lastExport: null` row in the rendered plan); (e) `deploy-pipeline` ingests the `batchValidation` block from `last-deploy.json` into `planData.pipelineMeta.lastDeploy.batchValidation` (totalSolutions/succeeded/failed/pendingApproval/timedOut/elapsedSeconds/perSolutionStageRunIds) so the rendered plan can show the Phase 3.6 parallel-validation outcome distinct from the serial deploy outcome. Explicitly set to `null` for single-solution / legacy v2 deploys so renderers can branch on it; legacy `elapsedSecondsApprox` field name is accepted and normalized to `elapsedSeconds` on ingest. **`ensure-pipelines-host` phase**: host-only update of `planData.hostResolution` from `last-host-check.json` (drops NoHost risks) WITHOUT touching `pipelineMeta` or the `Setup pipeline` step — for when the host was resolved but the pipeline doesn't exist yet. **`--reconcile` mode**: the enforcement backstop — scans the `last-*.json` markers and, for each one newer than `docs/.alm-plan-data.json` (a skipped refresh), applies the mapped phase (`MARKER_TO_PHASE`; `lastPipeline`→setup-pipeline supersedes the host-only phase; `lastEnvVars`→configure-env-variables if `deployment-settings.json` exists else setup-solution) against a single loaded planData, writes once, renders once. Honors `.alm-deferred`, soft no-op when no plan, idempotent. Output `{ ok, reconciled:[phases healed], failed:[{phase,error}], rendered }` — a phase whose refresh throws (e.g. a marker schema it can't parse) is captured in `failed` (and written to stderr) instead of being silently swallowed, while the remaining phases still heal. Invoked by the PostToolUse hook after every ALM skill (see Hooks). **Completion evaluator (`In Execution` → `Completed`):** after every phase's step-sync (both `refresh()` and `reconcile()`), `evaluatePlanCompletion` flips `PLAN_STATUS` to `Completed` + stamps `COMPLETED_AT` once every non-`skip` step is `completed` and none is `failed`. This is what makes the LAST execution skill terminate the plan automatically — no skill calls `--phase finalize` (the explicit `finalize` phase / `refreshFinalize` exists but nothing invoked it, so the lifecycle previously never reached `Completed`). Only advances from `In Execution` (the normal post-promotion state — see `check-alm-plan.js`) or `Approved` (defensive fallback); never regresses a `Draft` or already-`Completed` plan, and a `failed` step blocks completion so a failed deploy can't look "done".
+ - `scripts/lib/refresh-alm-plan-data.js`: Updates `docs/.alm-plan-data.json` with post-run state from the marker files written by setup-pipeline / deploy-pipeline / ensure-pipelines-host / test-site / import-solution / activate-site / configure-env-variables / setup-solution / export-solution, then optionally re-renders `docs/alm-plan.html`. Driven by the execution skills' final-phase refresh (and the PostToolUse `--reconcile` backstop) — NOT by plan-alm, which is now a plan-only 4-phase planner that only renders the initial plan in Phase 3 — so the rendered Pipelines tab, Validation tab, hostResolution card, env var values matrix, checklist, and risks list reflect actual run state instead of frozen pre-run intent. Args: `--projectRoot`, `--phase` (`setup-solution`/`setup-pipeline`/`configure-env-variables`/`deploy-pipeline`/`export-solution`/`import-solution`/`activate-site`/`test-site`/`ensure-pipelines-host`/`finalize`) **OR `--reconcile`** (mutually exclusive with `--phase`), `--render` (also invoke renderer), `--live` (opt — merge live Dataverse env var values, see (b)), `--stageName` (required for `test-site`; preferred for `import-solution`/`activate-site` though both can resolve via marker URL match). Output: `{ ok, phase, dataPath, htmlPath, rendered }`. Returns `ok:false` (soft no-op) when `docs/.alm-plan-data.json` is missing — caller should preserve that file across phases for the helper to work. Plan-alm Phase 3 must NOT delete the file after the initial render — it's read by `check-alm-plan.js` for downstream Phase 0 ALM-plan gates and by this helper for post-run refreshes. **Cross-cutting behaviors**: (a) `setStepStatus` flips the matching entry in `planData.steps[]` to `completed` (or `failed` when the phase's marker indicates failure) — case-insensitive keyword match + stage filter, respects `skip: true`, never regresses completed→pending; (b) `deploy-pipeline` AND `configure-env-variables` both backfill `planData.envVars[i].values{}` from the project root's `deployment-settings.json` so the rendered plan's "Values by Environment" matrix auto-populates (accepts both top-level-stage and nested-`stages` shapes; `SchemaName`/`Value` and camelCase variants; never overwrites a populated untagged cell — manual override wins; cells it writes are tagged `envVars[i].valueSources[stage] = "settings"` so a later change to the file replaces them; schema names match case-insensitively); with **`--live`** (`--phase` mode only) the CLI first queries every `planData.stages[]` entry's `envUrl` (Azure CLI token per env; `environmentvariabledefinitions` + `environmentvariablevalues`, Secret-type definitions skipped) and merges those values for cells with no local value, tagging them `envVars[i].valueSources[stage] = "live"` so later live refreshes update/clear them and a `deployment-settings.json` value replaces them — values set in PPAC reach the plan. Per-stage query failures are fail-soft and reported in the output's `liveEnvVars: { filled, updated, cleared, queriedStages, errors }`, and both calling skills tell the user about each `errors[]` entry (a 401/403 or token failure usually means `az` is signed in to a different tenant than the environment); (c) `configure-env-variables` and `setup-solution` both re-ingest `docs/alm/last-env-vars.json` (when present) so freshly-created definitions appear in `planData.envVars[]` and `plannedEnvVarCount` zeros out; (d) `export-solution` ingests `docs/alm/last-export.json` into `planData.manualMeta.lastExport` (all 10 marker fields: solutionUniqueName/solutionId/previousVersion/version/managed/sourceEnvironmentUrl/zipPath/fileSizeBytes/asyncOperationId/exportedAt) so the Manual-path tab can show the most recent export. Marker absence is a silent step-sync-only no-op (no `manualMeta.lastExport: null` row in the rendered plan); (e) `deploy-pipeline` ingests the `batchValidation` block from `last-deploy.json` into `planData.pipelineMeta.lastDeploy.batchValidation` (totalSolutions/succeeded/failed/pendingApproval/timedOut/elapsedSeconds/perSolutionStageRunIds) so the rendered plan can show the Phase 3.6 parallel-validation outcome distinct from the serial deploy outcome. Explicitly set to `null` for single-solution / legacy v2 deploys so renderers can branch on it; legacy `elapsedSecondsApprox` field name is accepted and normalized to `elapsedSeconds` on ingest. **`ensure-pipelines-host` phase**: host-only update of `planData.hostResolution` from `last-host-check.json` (drops NoHost risks) WITHOUT touching `pipelineMeta` or the `Setup pipeline` step — for when the host was resolved but the pipeline doesn't exist yet. **`--reconcile` mode**: the enforcement backstop — scans the `last-*.json` markers and, for each one newer than `docs/.alm-plan-data.json` (a skipped refresh), applies the mapped phase (`MARKER_TO_PHASE`; `lastPipeline`→setup-pipeline supersedes the host-only phase; `lastEnvVars`→configure-env-variables if `deployment-settings.json` exists else setup-solution) against a single loaded planData, writes once, renders once. Honors `.alm-deferred`, soft no-op when no plan, idempotent. Output `{ ok, reconciled:[phases healed], failed:[{phase,error}], rendered }` — a phase whose refresh throws (e.g. a marker schema it can't parse) is captured in `failed` (and written to stderr) instead of being silently swallowed, while the remaining phases still heal. Invoked by the PostToolUse hook after every ALM skill (see Hooks). **Completion evaluator (`In Execution` → `Completed`):** after every phase's step-sync (both `refresh()` and `reconcile()`), `evaluatePlanCompletion` flips `PLAN_STATUS` to `Completed` + stamps `COMPLETED_AT` once every non-`skip` step is `completed` and none is `failed`. This is what makes the LAST execution skill terminate the plan automatically — no skill calls `--phase finalize` (the explicit `finalize` phase / `refreshFinalize` exists but nothing invoked it, so the lifecycle previously never reached `Completed`). Only advances from `In Execution` (the normal post-promotion state — see `check-alm-plan.js`) or `Approved` (defensive fallback); never regresses a `Draft` or already-`Completed` plan, and a `failed` step blocks completion so a failed deploy can't look "done".
#### PP Pipelines
- `scripts/lib/list-environments.js`: Enumerates the Dataverse environments the signed-in PAC user can access, as JSON, for `ENV_LIST` pre-fill (plan-alm Phase 1 Step 5, setup-pipeline, ensure-pipelines-host "Other (paste URL)" prompts). **Why it exists:** the skills used to run `pac env list --output json`, which is INVALID on current PAC CLI (verified 2.8.1 — `pac env list` accepts only `--filter` and errors on `--output`), so the JSON pre-fill silently never worked. This helper runs the plain `pac env list` and parses its table (anchored on the env GUID + https URL + unique-name tokens, so display names with spaces survive). `pac admin list --json` was rejected as the source — it's admin-only and tenant-wide, the wrong scope for a per-user pre-fill. Exports `parseEnvList(stdout)` (pure, tested) + `listEnvironments()`. CLI prints a JSON array of `{ displayName, environmentId, environmentUrl, uniqueName, active }`; prints `[]` and exits 0 on any failure (unauthenticated PAC, parse miss) so callers degrade to manual entry. Match envs by `environmentUrl` origin.
- `scripts/lib/discover-pipelines-host.js`: Discovers the tenant-level default Power Platform Pipelines host environment URL by calling `RetrieveSetting('DefaultCustomPipelinesHostEnvForTenant')` on the dev/source environment. Args: `--envUrl`, `--token`, `--userId`. Output: `{ found, hostEnvUrl }`. Exit 0 (including when not found); exit 1 on error.
- `scripts/lib/create-deployment-environment.js`: Creates a `deploymentenvironments` record in the Pipelines host environment using the **unprefixed** field schema (`name`, `environmentid`, `environmenttype`), then polls `validationstatus` until Succeeded (`200000001`) or Failed (`200000002`). Args: `--hostEnvUrl`, `--token`, `--name`, `--bapEnvId`, `--environmentType` (`200000000` Dev / `200000001` Target), `--environmentUrl` (opt, only echoed in output marker). Idempotent: if a record already exists for the same `environmentid`, returns it with `reused: true`. Output: `{ deploymentEnvironmentId, name, bapEnvId, environmentUrl, environmentType, validationStatus, reused }`.
- `scripts/lib/create-deployment-pipeline.js`: Creates a `deploymentpipelines` record, associates the source environment via `$ref` (relative path + `@odata.context`), and creates `deploymentstages` records for each target environment. Args: `--hostEnvUrl`, `--token`, `--pipelineName`, `--description`, `--sourceDeploymentEnvironmentId`, `--stagesJson` (JSON array of `{ name, targetDeploymentEnvironmentId, order }`). Output: `{ pipelineId, pipelineName, stages[] }`.
- `scripts/lib/create-stage-run.js`: Creates a `deploymentstageruns` record to initiate a pipeline deployment stage. Args: `--hostEnvUrl`, `--token`, `--pipelineId` (opt), `--stageId`, `--sourceDeploymentEnvironmentId`, `--solutionId` (GUID), `--artifactName` (unique name). Output: `{ stageRunId }`.
- `scripts/lib/poll-validation-status.js`: Polls `stagerunstatus` on a `deploymentstageruns` record until Validation Succeeded (200000007) or Failed (200000003). Args: `--hostEnvUrl`, `--token`, `--stageRunId`, `--intervalMs` (opt, default 5000), `--maxAttempts` (opt, default 36). Output: `{ stageRunId, validationResults, stageRunStatus }`.
- `scripts/lib/validate-stage-runs-batch.js`: Parallel batch validation of N stage runs against the same stage. Used by `deploy-pipeline` Phase 3.6 in `MULTI_RUN_MODE` (multi-solution v3 manifest) to compress validation from `N × ~120s` to roughly the slowest single validation. For each solution, runs `create-stage-run` + `POST ValidatePackageAsync` + `poll-validation-status` concurrently via `Promise.all` — wrap-errors-into-result-object pattern (helper never rejects per-solution; errors land on the result object's `status` + `error` fields). Deploy (`DeployPackageAsync`) is NOT parallelized — Dataverse takes an env-level import lock so parallel deploys queue on the host anyway. Args: `--hostEnvUrl`, `--stageId`, `--sourceDeploymentEnvironmentId`, `--solutionsFile <path>` (JSON array of `{ solutionUniqueName, solutionId }`), `--token` (opt), `--pipelineId` (opt), `--intervalMs` / `--maxAttempts` (opt, forwarded to poll). Output: `{ total, succeeded, failed, pendingApproval, timedOut, allPassed, elapsedSeconds, results: [{ solutionUniqueName, solutionId, stageRunId, status: "Succeeded"|"Failed"|"PendingApproval"|"Timeout"|"Error", validationResults?, error? }] }`. PendingApproval is detected via post-timeout probe (the underlying poll helper doesn't know about `200000005`, so a poll timeout triggers a single `?$select=stagerunstatus` re-query to distinguish "still validating" from "awaiting approval"). `elapsedSeconds` is wall-clock measured around the fan-out (excludes token-acquire prelude) so deploy-pipeline Phase 3.6.6 can persist it into `last-deploy.json`'s `batchValidation` block without out-of-band timing. **Also supports `--rePoll` mode**: solutionsFile entries must include `stageRunId` (carried from the original batch's results); the helper skips create-stage-run + ValidatePackageAsync and only runs the poll-and-probe pattern. Used by `deploy-pipeline` Phase 3.6.4 after the user approves PendingApproval validations in PPAC — re-poll the existing stage runs without re-creating them. In rePoll mode `--stageId` / `--sourceDeploymentEnvironmentId` are not required. Exit 0 always (caller inspects `allPassed`); exit 1 only on fatal setup errors.
- `scripts/lib/poll-deployment-status.js`: Polls `stagerunstatus` on a `deploymentstageruns` record until a terminal state. Returns `{ status: 'Awaiting' }` (exit 0, non-throwing) for approval gates (200000005 PendingApproval, 200000008 AwaitingPreDeployApproval) — caller must pause for user. Args: `--hostEnvUrl`, `--token`, `--stageRunId`, `--intervalMs` (opt, default 8000), `--maxAttempts` (opt, default 75). Output: `{ stageRunId, status, errorDetails }`.
- `scripts/lib/ensure-pipelines-host-detect.js`: Detection-only wrapper around the ensure-pipelines-host workflow. Runs Phases 1.0 (cache fast-path) + 2 (resolution order: org-setting → BAP env GET → tenant default custom → tenant-wide enumeration) + 5 (verify if host found). **Never enters Phase 3 (decision tree) or Phase 4 (provisioning)** — always exits with `actionTaken: "none"`. Used by `plan-alm` Phase 1 step 12 and other orchestrators that want to inspect host state without inviting user prompts. Resolution order mirrors `ProjectHostProvider.tsx` from the AppDeploymentConfiguration UI. Args: `--devEnvUrl`, `--token` (opt), `--projectRoot` (opt, for cache file). Output: `{ resolutionStatus, finalHostEnvUrl, finalHostEnvId, finalHostEnvName, hostType, pipelinesSolutionVersion, candidates: { existingCustomHosts[], ... }, actionTaken: "none" }`.
- `scripts/lib/provision-platform-host.js`: Provisions a Power Platform Pipelines Platform Host (PE) via the BAP `getOrCreate` endpoint. **Idempotent**: a tenant that already has a PE gets the existing one back (200 + `provisioningState=Succeeded`); a tenant without one gets it provisioned (202 + lifecycle op poll). Same call `make.powerapps.com → Pipelines → Get started` makes. Args: `--bapToken`, `--tenantId`, `--correlationId` (opt — defaults to a fresh UUID), `--bapBase` (opt — defaults to `https://api.bap.microsoft.com`), `--timeoutSec` (opt). Output: `{ status, alreadyExisted, envId, envUrl, envName, region, provisioningState, lifecycleOpId, durationSec, correlationId }`. Used by `ensure-pipelines-host` Phase 4.0.
- `scripts/lib/provision-custom-host.js`: Provisions a new Power Platform Pipelines Custom Host via the BAP env-create API with the `D365_ProjectHost` organization template (template pre-installs the Pipelines app so the env is immediately host-capable). Same template PPAC's `New custom host` button uses. Args: `--bapToken`, `--tenantId`, `--displayName`, `--region` (opt), `--sku` (opt — `Sandbox` / `Trial` / `Production`), `--correlationId`, `--bapBase`, `--timeoutSec`. On 409 capacity errors the helper surfaces `errorCode` so the caller can offer a SKU fallback (e.g. Sandbox → Trial → Production). Output: `{ status, envId, envUrl, envName, sku, lifecycleOpId, durationSec, correlationId }`. Used by `ensure-pipelines-host` Phase 4.A.
- `scripts/lib/force-link-environment.js`: Force-links an existing `deploymentenvironments` record (in a Pipelines host env) to take over the source environment's host association. API behind PPAC's "Force Link" button — the documented remediation when creating an environment record fails with *"this environment is already associated with another pipelines host"*. Args: `--hostEnvUrl`, `--token` (host-scoped), `--deploymentEnvironmentId` (the record on the new host). POSTs to `/api/data/v9.0/ManageEnvironmentStamp` with the GUID in upper-case-in-braces format (HAR-confirmed against `supplierportalpipelineshostch.crm17`, 2026-05-11). Idempotent: re-running on an already-stamped env is a 204 no-op. Output: `{ ok, deploymentEnvironmentId, hostEnvUrl, validationStatus, errorCode? }`. Used by `force-link-environment` skill (Pattern 15 auto-fix in `deployment-error-catalog.md`).
- `scripts/lib/pac-bap-shim.js`: PAC-CLI shim for BAP env-list / env-GET. Provides the same data shape that `resolve-env-by-id.js` and `list-tenant-envs.js` consume from BAP, but sourced from `pac admin list --json` instead. **Why this exists**: the BAP API at `api.bap.microsoft.com` rejects Az-CLI-acquired tokens in some tenants (verified 2026-04-28: D365DemoTSCE53051106 returns 401 InvalidAuthenticationToken even though the token claims show the right user/tenant/audience). PAC CLI succeeds because it uses a different first-party client ID with implicit BAP grants. The shim is the read-side fallback — enables detection scripts to work in tenants where Az→BAP fails. Exports `listTenantEnvs()`, `resolveEnvById(envId)` with the same return shape as the BAP-backed callers.
- `scripts/lib/verify-env-var-values.js`: Verifies that `environmentvariablevalues` records actually landed on a target environment after deploy / import / configure. Read-only — no Dataverse writes. **Why this exists**: `deploy-pipeline` Phase 5.2 PATCHes `deploymentsettingsjson` onto the stage run; the Pipelines handler writes value records as part of the import, BUT it does NOT always write values for every definition — definitions not bound to an `mspp_sitesetting` (or another consumer the platform recognizes) can land as zero-value on the target even when the stage run reports success. This helper closes the gap. Args: `--envUrl`, one of (`--schemaNames` comma-separated OR `--settingsFile <path>` to derive from `deployment-settings.json`), `--stageLabel` (required when reading the settings file), `--token` (opt). Output: `{ summary: { landed, missing, mismatched, error }, results: [{ schemaName, status: "landed"|"missing-value-record"|"missing-definition"|"value-mismatch"|"query-error", expected?, value? }] }`. Used by `deploy-pipeline` Phase 7.6.5, `import-solution` Phase 6b.verify, `configure-env-variables` Phase 7.
- `scripts/lib/validate-deployment-settings.js`: Pre-deploy validator for `deployment-settings.json`. Classifies each `EnvironmentVariables[]` entry by `valueFormat` (`kv-uri` / `kv-resource-id` / `kv-placeholder` / `empty` / `plain-text` / `invalid-uri`) and `status` (`valid` / `invalid` / `unknown-type` / `skipped`). When `--envUrl` is provided, Secret-type entries are validated against canonical Azure Key Vault reference formats. **Why this exists**: the Power Platform Pipelines handler validates the PATCH at import time, AFTER the stage run has been queued — a bad Secret reference fails the import with *"ImportAsHolding failed: The value provided as a secret reference does not match a valid secret reference format"* after a potentially-hours-long queue wait. This helper catches the bad reference in sub-second time. Args: `--settingsFile`, `--envUrl` (opt — enables Dataverse type lookups), `--stageLabel` (opt — narrows to a single stage), `--token` (opt). Output: `{ summary: { valid, invalid, "unknown-type", skipped }, findings: [{ schemaName, valueFormat, status, value, message, type? }] }`. Used by `deploy-pipeline` Phase 5.1b (pre-PATCH gate). The catalog of canonical Secret formats lives in this helper — do NOT duplicate the regex elsewhere.
#### Solution Export
- `scripts/lib/export-solution-async.js`: Triggers async Dataverse solution export via `ExportSolutionAsync` and polls `asyncoperations` until complete (statecode 3 = Succeeded). Args: `--envUrl`, `--solutionName`, `--managed` (`true`/`false`), `--token` (opt). Output: `{ asyncOperationId, solutionName, managed }`.
- `scripts/lib/download-export-data.js`: Downloads the solution zip after a successful async export via `DownloadSolutionExportData`. Decodes the base64 response and writes the zip file to disk. Args: `--envUrl`, `--asyncOperationId`, `--outputPath`, `--token` (opt). Output: `{ zipPath, fileSizeBytes }`.
### Shared References
Shared reference documents live at `references/` and are referenced by multiple skills via relative paths (e.g., `../../references/odata-common.md`). This avoids duplicating common patterns across skill-specific reference docs and SKILL.md files.
- `odata-common.md`: Auth headers, PowerShell token helper, token refresh cadence, HTTP status codes, Dataverse error codes, retry pattern. Used by `setup-datamodel` and `add-sample-data`.
- `dataverse-prerequisites.md`: PAC CLI auth check (`pac env who`), Azure CLI token acquisition, API access verification (`WhoAmI`). Used by `setup-datamodel`, `add-sample-data`, `setup-solution`, `export-solution`, and `import-solution`.
- `framework-conventions.md`: Supported frameworks, framework → build tool / router / build output / public dir / index HTML mapping, framework detection via `package.json`, route discovery patterns. Used by `create-site` and `add-seo`.
- `datamodel-manifest-schema.md`: Schema spec for `.datamodel-manifest.json` (fields, types, usage). Written by `setup-datamodel`, read by `add-sample-data`, validated by `validate-datamodel.js`.
- `skill-tracking-reference.md`: Skill usage tracking instructions — script invocation syntax, skill name mapping table, and YAML format. Referenced by all skills to record usage via `update-skill-tracking.js`.
- `solution-api-patterns.md`: OData body templates for publisher POST, solution POST, `AddSolutionComponent`, `ExportSolutionAsync`, `DownloadSolutionExportData`, `ImportSolutionAsync`, `StageSolution`. Also documents `.solution-manifest.json` format. Used by `setup-solution`, `export-solution`, and `import-solution`.
- `deployment-error-catalog.md`: Catalog of 10 known deployment failure patterns (stale manifest, blocked JS, missing websiteRecordId, auth expiry, empty build output, solution missing dependencies, solution timeout, PAC CLI not installed, environment mismatch, duplicate component). Each entry includes root cause, severity, auto-fix availability, and fix procedure. Used by `diagnose-deployment`.
- `cicd-pipeline-patterns.md`: PAC CLI service principal auth syntax; complete ADO `azure-pipelines.yml` template; complete GitHub Actions `deploy.yml` template; commented solution export/import blocks; secrets/variables setup tables; manual steps that cannot be automated; **Power Platform Pipelines API patterns** (HAR-confirmed): host env discovery via `RetrieveSetting`, `deploymentenvironments` create + `validationstatus` poll, `deploymentpipelines` create, `$ref` associate source (relative path format), `deploymentstages` create, `RetrieveDeploymentPipelineInfo`, stage run create + `ValidatePackageAsync` (204) + `operation` poll, `deploymentsettingsjson` PATCH, `DeployPackageAsync`, `stagerunstatus` terminal values, `docs/alm/last-pipeline.json` and `docs/alm/last-deploy.json` formats. Used by `setup-pipeline` and `deploy-pipeline`.
- `approval-gates.md`: Canonical terminology, marker syntax, and catalog of every user-confirmation point ("Approval Gate") across the **entire power-pages skill set**. Defines six categories (`intent` / `plan` / `progress` / `consent` / `final` / `pause`), an explicit-pairing marker (`<!-- gate: skill:phase | category=X | cancel-leaves=Y -->` + human `> 🚦 Gate (...)` block), the `cancel-leaves` vocabulary, and the seven gate-related lint rules enforced by `scripts/lint-skills-alm.js` at hard-fail severity: `GATE-must-have-marker`, `GATE-id-must-be-unique`, `GATE-must-be-in-catalog`, `GATE-intent-must-call-helper`, `GATE-cancel-leaves-known-vocab`, `GATE-prose-block-required` (marker must be followed by a 🚦 prose block within 10 lines, outside any code fence), and `CATALOG-row-must-have-marker` (reverse of `GATE-must-be-in-catalog` — every `kind: gate` catalog row must have a SKILL.md marker). §6.1–§6.12 catalogue the ALM skills; §6.13–§6.35 catalogue the non-ALM skills, including `analyze-performance`. `report-issue` is excluded because its workflow lives in the cross-plugin shared file. **New skills must extend §6 in the same PR they introduce an `AskUserQuestion`** — lint will block the PR otherwise.
Skill-specific reference docs (e.g., `skills/setup-datamodel/references/odata-api-patterns.md`) contain only patterns unique to that skill and point to the shared docs via `${PLUGIN_ROOT}/references/` paths for common content.
### MCP Integration
Playwright MCP server for browser automation and live site previews during development.
## Template System
Framework templates use `__PLACEHOLDER__` tokens (e.g., `__SITE_NAME__`, `__PRIMARY_COLOR__`, `__BG_COLOR__`) that get replaced during site scaffolding. The `gitignore` file is stored without the dot prefix to avoid git interference in the plugin repo — it gets renamed to `.gitignore` during scaffolding.
## Validation Scripts
### `create-site/scripts/validate-site.js`
Branches by project type. SPA sites retain checks for `package.json`, `.gitignore`,
`powerpages.config.json`, config fields, build/dev scripts, placeholders, Git, and `src/`.
Non-SPA sites require `.powerpages-site/.portalconfig/`, a non-empty `website.yml` with a valid
and optionally expected ID, downloaded non-SPA assets, and Git initialization.
### `setup-datamodel/scripts/validate-datamodel.js`
Checks created Dataverse data models by reading `.datamodel-manifest.json` (written by the `setup-datamodel` skill during table creation). Queries the Dataverse OData API to verify each table and column in the manifest actually exists in the environment. Gracefully exits 0 on auth errors (doesn't block if token expired) or when no manifest is found (not a data model session).
### `add-seo/scripts/validate-seo.js`
Checks SEO assets added to Power Pages sites: verifies `robots.txt` exists in `public/` with proper `User-agent` and `Sitemap` directives, `sitemap.xml` exists with `<urlset>` and `<loc>` entries (no unreplaced placeholders), and `index.html` has `meta description` and `viewport` tags. Only runs validation when at least one SEO file (robots.txt or sitemap.xml) is detected — gracefully exits 0 otherwise to avoid blocking non-SEO sessions.
### `analyze-performance/scripts/validate-analyze-performance.js`
Validates the exact HTML report path passed with `--reportPath` after Phase 6.3 renders it. Blocks when that current report is missing, unreadable, suspiciously small (< 500 bytes), or still contains unreplaced `__PLACEHOLDER__` tokens from the shared report template. The auto-discovered Skill PostToolUse invocation has no report path and intentionally approves because it fires when the skill instructions are loaded, before rendering; it never inspects historical reports.
### `add-localization/scripts/validate-localization.js`
Checks `.powerpages-localization.json`, framework/package/mode consistency,
canonical unique locales, default locale, resource files, key parity,
protected interpolation/markup tokens, managed files, language selector or
locale navigation, and document `lang`/`dir` handling. Gracefully exits 0 when
no localization manifest exists.
### `create-webroles/scripts/validate-webroles.js`
Validates aggregate `webrole.yml` or per-record `web-roles/*.webrole.yml` metadata for required
fields, UUIDs, booleans, and naming conventions. The skill workflow separately checks duplicate
IDs/names and anonymous/authenticated uniqueness constraints. Gracefully exits 0 when no Power
Pages metadata root or role records are found.
### `audit-permissions/scripts/collect-non-spa-permission-evidence.js`
Resolves a nested or directly opened non-SPA PAC root and emits normalized read-only evidence
for aggregate and per-record serialization: roles, table permissions, site settings, webpage rules,
pages, Basic Forms, Advanced Forms/steps, Lists, and reachable Liquid/Web API component references.
It preserves source paths and format labels while leaving Standard/Enhanced unknown until
authoritative PAC discovery, so the audit never infers the data model from local filenames or
`adx_` prefixes.
### `audit-permissions/scripts/validate-audit.js`
Checks that a permissions audit report was rendered under the resolved project `docs/` directory
or the system temporary directory and blocks reports that retain template placeholder tokens.
Direct non-SPA PAC roots are recognized through shared project-root discovery.
### `integrate-webapi/scripts/validate-webapi-integration.js`
For Power Pages SPA sites, verifies the shared API client
(`src/shared/powerPagesApi.ts` or equivalent) exists, at least one service file exists in
`src/shared/services/` or `src/services/` with `/_api/` endpoint references, and corresponding
type definition files exist in `src/types/`. Gracefully exits 0 when no integration files are
detected (not an integration session). For non-SPA sites, scans Web Files, webpages, and Web
Templates for `/_api/` calls and validates permission/site-setting schema from nested or direct
PAC roots.
### `setup-auth/scripts/validate-auth.js`
Checks that authentication and authorization code was created: verifies auth service (`src/services/authService.ts` or equivalent) exists with login/logout/getCurrentUser functions and anti-forgery token handling, Power Pages type declarations (`src/types/powerPages.d.ts`) exist, authorization utilities (`src/utils/authorization.ts`) exist, and an auth UI component (AuthButton or equivalent) exists. Gracefully exits 0 when no auth files are detected (not an auth session).
### `setup-solution/scripts/validate-solution.js`
Checks that `.solution-manifest.json` was written with required fields (`solution.uniqueName`, `solution.solutionId`, `publisher.publisherId`, at least one component of type 61). Queries Dataverse OData to confirm the solution actually exists in the environment. Gracefully exits 0 on auth errors or when no manifest is found.
### `export-solution/scripts/validate-export.js`
Checks that a solution zip file was written (`*_managed.zip` or `*_unmanaged.zip` pattern). Verifies file size > 1000 bytes and that `Solution.xml` is present inside the zip (via `unzip -l`). Gracefully exits 0 when no solution zip is found.
### `import-solution/scripts/validate-import.js`
Checks `docs/alm/last-import.json` marker for required fields (`solutionName`, `targetEnvironment`, `importedAt`). Blocks if all components failed to import (0 success + N failures). Gracefully exits 0 when no import marker is found.
### `setup-pipeline/scripts/validate-pipeline.js`
Checks for `docs/alm/last-pipeline.json` (Power Platform Pipelines path) — validates required fields: `pipelineId`, `hostEnvUrl`, `sourceDeploymentEnvironmentId`, non-empty `stages[]`, and each stage has `stageId` + `targetDeploymentEnvironmentId`. Also confirms `docs/pipeline-setup.md` was created. Falls back to checking `azure-pipelines.yml` or `.github/workflows/deploy.yml` for YAML keys and `docs/ci-cd-setup.md` (GitHub/ADO future path). Gracefully exits 0 when no pipeline artifacts are found.
### `deploy-pipeline/scripts/validate-deploy-pipeline.js`
Checks `docs/alm/last-deploy.json` marker for required fields (`pipelineId`, `stageRunId`, `solutionName`, `status`, `deployedAt`). Blocks if `status === "Failed"` — a failed deployment requires investigation before retrying. Gracefully exits 0 when no deploy marker is found (not a deploy-pipeline session).
## Skill Development Guide
All skills in this plugin follow a consistent set of patterns. When creating a new skill, follow every convention below to maintain consistency across the plugin.
### Phase-Wise Workflow
Every skill is a sequence of phases (typically 5-8): Prerequisites, Discover/Gather, Plan/Review, Implement, **Verify** (mandatory standalone phase), Deploy/Summarize. Never skip or reorder phases. `style-site` and the individual page or component workflow in `edit-site` use their compact local-only sequences above, with dedicated independent verification. Coordinated customization uses seven phases.
### Task Tracking
Create all tasks upfront at Phase 1 start using `TaskCreate` (one per phase). Each task needs `subject` (imperative), `activeForm` (present continuous for spinner), and `description`. Mark `in_progress` when starting, `completed` when done. Include a progress tracking table at the end of the SKILL.md. **Exceptions:** Styling and individual page/component workflows in `edit-site` use visible workflow status without mandatory task-tool calls or a phase-task table; expanded work may use tasks when useful.
### SKILL.md Frontmatter
```yaml
---
name: <skill-name>
description: >-
<when to use this skill>
user-invocable: true
argument-hint: <optional>
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, Task, TaskCreate, TaskUpdate, TaskList, AskUserQuestion
model: opus
---
```
Note: `allowed-tools` must be a comma-separated list, not JSON array or YAML list syntax. Do not add `hooks` to skill frontmatter; Power Pages skills register lifecycle hooks centrally.
### Plugin Version Check
Every SKILL.md must include the following line immediately after the closing `---` of the frontmatter (before the `#` title):
```markdown
> **Plugin check**: Run `node "${PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding.
```
This runs a lightweight check comparing the local plugin version against `origin/main` and shows an update notice if a newer version is available.
For styling-only requests through `edit-site`, reuse the same script's result once per installed plugin version in the current conversation. Reference reads perform no separate version check or preview disclosure.
### Key Patterns
- **Approval Gates** — Every load-bearing `AskUserQuestion` is an **Approval Gate**. Pause at minimum after gathering requirements, after presenting a plan, after implementation, and before deployment (Three-Point Approval Pattern), except for the styling workflow's documented local-only gates. **Every skill and reference workflow in this plugin** (ALM and non-ALM alike) must (a) catalogue each gate in `references/approval-gates.md` §6 with a stable `gate-id`, and (b) mark it in SKILL.md or the owning `references/*-workflow.md` with the explicit-pairing comment `<!-- gate: skill:phase | category=<intent|plan|progress|consent|final|pause> | cancel-leaves=<vocab> -->` followed by a human-readable `> 🚦 **Gate (...)**` block. Pure data-gathering prompts (free-text fallbacks, configuration sub-prompts) take a `<!-- not-a-gate: <reason> -->` comment instead. `scripts/lint-skills-alm.js` enforces this at **hard-fail** severity across the whole plugin — there is no warn-only carve-out for any skill class. **When you add a skill or reference workflow that introduces an `AskUserQuestion`, you must extend `references/approval-gates.md` §6 with the new gate-id(s) in the same PR; CI will block the PR otherwise.** Do not coin alternative terms ("review gate", "approval checkpoint", "manual step" etc.) — the canonical term is **Approval Gate**.
- **Deployment prompt** — Skills that modify site artifacts should end by asking "Ready to deploy?" and invoke `/deploy-site` if yes, **except the styling-only route**, which reports verified local work or explicitly requested Studio instructions without a final-completion/deployment question or automatic dispatch; blocked local work remains blocked.
- **Lifecycle hooks** — Hook registration is centralized in `hooks/hooks.json`; `scripts/lib/powerpages-hook-utils.js` derives tracked skills from `skills/*/SKILL.md` and discovers optional `scripts/validate*.js` validators. Do not define hook registration in individual `SKILL.md` files.
- **Graceful failure** — Track API call results, never auto-rollback, report failures clearly, continue with remaining items.
- **Token refresh** — Refresh Azure CLI token every ~20 records / 3-4 tables / ~60 seconds.
- **Git commits** — Commit after every significant milestone (each page/component, design foundations, phase completion), respecting user restrictions.
- **Agent spawning** — Process sequentially (not parallel), wait for completion, present output for approval.
- **Skill tracking** — Every skill must record usage in its final phase via `> Reference: ${PLUGIN_ROOT}/references/skill-tracking-reference.md` (pointer pattern, not hardcoded command). When adding a new skill, also add its entry to the skill name mapping table in `references/skill-tracking-reference.md`.
- **Shell-agnostic docs** — SKILL.md, agent, and reference files must not embed shell-specific syntax inside shell commands or code blocks. Use ` ```bash ` fences (or plain ` ``` `) only for cross-platform commands like `pac`, `az`, `dotnet`, and `node`. Do not use PowerShell cmdlets (`Get-ChildItem`, `Test-Path`, `New-Item`, `Get-Content`, `Remove-Item`, `ConvertFrom-Json`, `Invoke-RestMethod`, etc.) or PowerShell-only variable syntax inside shell commands/code blocks (e.g., `$var = command`, `$env:...`) — prefer `<placeholder>` angle-bracket style there (e.g., `<envUrl>`). Repo runtime placeholders used in prose/templates (such as `**Initial request:** $ARGUMENTS`) are allowed. For filesystem and JSON operations the agent already has first-class tools (`Glob`, `Read`, `Write`, `Edit`) — describe the intent in prose rather than prescribing a shell command.
- **Dataverse API calls** — Use deterministic Node.js scripts (in the skill's `scripts/` directory) for Dataverse API queries. Scripts should import `getAuthToken` and `makeRequest` from `scripts/lib/validation-helpers.js`. Never use inline PowerShell `Invoke-RestMethod` for API calls — scripts are more reliable, testable, and cross-platform.
- **ALM-aware by default** — Any skill that creates, modifies, or depends on Dataverse records that belong in a Power Pages site's solution (site components, env var definitions, web roles, site settings, server logic, cloud flow bindings, bot consumers, custom tables/columns, etc.) MUST ensure those records land in the user's solution when `.solution-manifest.json` exists. Concrete rules:
- **Solution selection — strict resolution order.** When a skill or script needs "which solution?" for an `AddSolutionComponent` call, resolve in this order and stop at the first match:
1. **Explicit `--solutionUniqueName` CLI arg** (or `solutionName=…` skill argument). Always wins. Used by advanced flows and CI.
2. **`.solution-manifest.json` in the project root** — read `solution.uniqueName`. This is the default path for nearly every invocation.
3. **No manifest AND no explicit arg**:
- **Interactive skill**: query Dataverse for unmanaged solutions whose publisher prefix matches the site publisher, present them via `AskUserQuestion` alongside the option **"Run `/power-pages:setup-solution` first (recommended)"** and **"Leave in Default (not recommended)"**. Proceed only after explicit selection.
- **Non-interactive script**: exit with a clear error — `--solutionUniqueName not provided and no .solution-manifest.json found. Run /power-pages:setup-solution first, or pass --solutionUniqueName.` Never silently fall back to `Default`.
Skills must never auto-pick "the first solution that looks relevant" — auto-selection masks misconfigurations (wrong env, wrong branch, wrong project).
- **Component-creation scripts** must accept a `--solutionUniqueName` argument and, when provided, add the created record to that solution via `AddSolutionComponent`. Test that `solutionUniqueName` flows through end to end.
- **Skill workflows** must read `.solution-manifest.json` during prerequisite checks and pass the solution's `uniqueName` to any component-creation script they call. When no manifest is present, the skill should surface that gap to the user (per the resolution order above) rather than silently creating records in `Default`.
- **Skills that can leave Dataverse artifacts uncovered** (e.g. `setup-auth` writing OAuth secrets as env vars) must end by prompting the user to run `/power-pages:setup-solution` in sync mode so the discovery pass picks up any newly-created records.
- **New component types** added to Power Pages must be reflected in `scripts/lib/discover-site-components.js` (the single source of truth for site inventory) and, if applicable, in the `PPC_TYPE_LABELS` enum. Discovery should never silently skip a type.
## Planned Skills (Not Yet Implemented)
The following skills are planned but require POC validation before implementation:
### Sprint 2 — Needs POC First
- `setup-environments`: Blocked by BAP API auth scope (`https://service.powerapps.com/`) differing from Dataverse token scope — needs POC in personal tenant. Managed env flag + admin assignment also need validation.
- `setup-git-versioning`: Blocked pending determination of whether `pac pages` has a git-config subcommand, or if git integration is portal-only. If no CLI surface exists, this reduces to a guidance doc.
- `configure-secrets`: Blocked pending mapping of full API path for Key Vault-backed environment variables (`environmentvariablevalues` with `keyVaultReference` JSON) and validation of `az keyvault set-policy` assignment in same session.
### Sprint 3 — Future / Complex
- `setup-approvals`: Blocked by the fact that ADO environment approval gates have no create/trigger API — the approval workflow setup requires human interaction in the ADO UI. Power Platform Pipelines approval status (`UpdateApprovalStatus`) schema is undocumented.
- `setup-pipeline` GitHub/ADO paths: Currently "coming soon" stubs. Full implementation spec is at `C:\Users\nityagi\OneDrive - Microsoft\Design Documents\Plans\ALM skills for plugin\ado-cicd-skills-guide.md`.
## Common Review Pitfalls
These patterns have caused repeated PR review feedback. Check for them before submitting changes to skills, validators, or hooks.
- **Phase cross-references break silently** — When renumbering or reordering phases in a SKILL.md, also update: `references/` docs that mention phase numbers, the Key Decision Points section, and any other files that cross-reference this skill's phases. After any phase reorder, grep for the old phase number across the skill directory and its references.
- **Validators must match the exact constraint** — If the rule is "no exports at all", block all `module.exports`/`exports` — don't just check if exported names are in an allowlist. If the rule is "try/catch required", verify both `try` AND `catch` exist. Re-read the exact constraint from SKILL.md and test the boundary cases.
- **Hook scripts run on every Skill tool use** — The PostToolUse hook fires for all tracked skills, so unconditional `process.stderr.write` creates noise. Gate debug logging behind `process.env.DEBUG`. Only errors should go to stderr unconditionally.
- **Template placeholders are context-encoded** — bare string placeholders render as HTML text, while structured values render as script-safe JSON. Use `__JSON_KEY__` for every JavaScript or `application/json` value, `__ATTR_KEY__` for attributes, and reserve `__RAW_KEY__` for code-owned trusted markup.
- **Guidance must be consistent within a skill** — If one section says "always use raw fetch", a framework-specific table in the same file must not recommend a different HTTP client without qualification. Reviewers will flag contradictions.
## Secure Coding Requirements
These requirements apply to scripts, hooks, skills, templates, reports, and documentation.
- **Process execution:** Non-constant data from users, files, environment variables, APIs, or CLI output MUST NEVER reach `exec`/`execSync` command strings or a child process launched with `shell: true`. New and changed calls MUST use `execFile`, `execFileSync`, `spawn`, or `spawnSync` with a fixed executable, an argv array, and `shell: false`; follow the argv patterns in `scripts/lib/pac-bap-shim.js` and `scripts/lib/telemetry/lib/pac-auth.js` instead of adding quoting helpers.
- **Authenticated URL boundary:** Introduce one authenticated-URL validator in `scripts/lib/validation-helpers.js`, then require every caller to reuse it before `getAuthToken`, an `Authorization` header, or any authenticated request is created. The validator MUST require HTTPS, reject credentials and unexpected ports, and match the parsed hostname against the Microsoft cloud mappings in `CLOUD_TO_API` and the approved Dataverse host suffixes; validate redirects and `@odata.nextLink` values again, and NEVER use substring host checks or local one-off regexes.
- **Untrusted report data:** Keep report data as data. Prefer DOM `textContent`; otherwise use the encoder for the exact HTML text, HTML attribute, JavaScript string, or URL component context, and NEVER put raw values into `innerHTML`, event-handler attributes, script source, or navigation URLs. Reuse or extend the encoding boundary in `scripts/lib/render-template.js` and `scripts/lib/templates/security-review-report.html` rather than adding per-report escaping.
- **Script embedding:** Every value embedded in `<script>`, including strings, MUST pass through `JSON.stringify`; escape characters that can terminate or alter the script context after serialization. The non-string branch in `scripts/lib/render-template.js` shows part of this pattern but is not a complete encoder for string placeholders. Quoted `__PLACEHOLDER__` substitution is not JSON-safe. Treat filenames, labels, findings, and scanner output as untrusted even when the plugin produced the file.
- **Plugin code resolution:** Resolve plugin-owned code from `__dirname` or the host-provided `${PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_ROOT}` and fail closed when neither is available. `process.cwd()` is a project-input location only and MUST NEVER be a fallback for locating plugin scripts, hooks, templates, configuration, or dependencies.
- **Runtime dependencies:** Runtime packages MUST use an exact version and a committed lock or equivalent integrity record at the owning package boundary. NEVER fetch and execute `@latest`, an unpinned package, or installer output during a plugin run; this includes MCP launchers such as `scripts/launch-playwright-mcp.js`.
- **Archives:** Treat archive entry names and metadata as hostile before listing or extraction. Upgrade existing solution-archive entry points, including `skills/export-solution/scripts/validate-export.js`, to use a shell-free library or fixed executable plus argv, inspect entries first, enforce file/count/size limits, reject absolute paths, traversal, links, and special files, and extract only into a new private temporary directory.
- **Approval guardrails:** `references/approval-gates.md` and `scripts/lint-skills-alm.js` are the source of truth for plugin Approval Gates, and host tool approvals MUST remain enabled. Documentation and skills MAY recommend narrow command allowlists, but MUST NEVER recommend global permission bypasses or options such as `--dangerously-skip-permissions`.
- **Telemetry and privacy:** Follow the `## Telemetry` contract below and minimize every event to its allowlisted operational fields; NEVER collect prompts, report contents, secrets, tokens, full paths, URLs, hostnames, tenant data, or user content. Disclosures MUST accurately state the shipped default, local mirror behavior, and opt-out precedence; telemetry-capable CI jobs MUST set `POWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT=1`. Edit `shared/telemetry/` first, refresh `scripts/lib/telemetry/lib` in the same change, and NEVER copy another plugin's `ikey.json`, resolver, instrumentation key, or event stream.
- **Secret temporary files:** Prefer stdin and keep secrets out of argv, environment variables, logs, and errors. `scripts/store-keyvault-secret.js` demonstrates stdin handling, mode `0600`, and `finally` cleanup; it does not yet provide private-directory or exclusive-creation guarantees. When a tool requires a file, it MUST also create a unique private temporary directory and open the secret file exclusively (`wx` / `O_EXCL`), then remove both in `finally`.
- **Security regression tests:** Security fixes and security-sensitive code MUST add `node:test` coverage under `scripts/tests/` for inert shell metacharacters, hostile HTML and script-closing text, traversal-style filenames and archive entries, and Windows and POSIX path/process behavior. Tests MUST prove that data remains data and that validation fails closed without placing a working exploit in documentation or fixtures.
- **Private vulnerability handling:** Suspected exploitable reports MUST stay in a GitHub private security advisory or another approved private channel until remediation and disclosure are coordinated. Do not paste exploit details, secrets, customer data, or working payloads into public issues, PRs, logs, reports, or test output; public changes should describe the affected class and the guardrail.
## Telemetry
This plugin ships 1DS telemetry for skill-run and script-run signals. The canonical shared library lives at the repo-root `shared/telemetry/`; `scripts/lib/telemetry/lib` is a physical copy bundled with this plugin so local checkouts and installed plugins do not depend on symlink handling. Zero npm dependencies — nothing to install.
- **`scripts/lib/telemetry/lib` is copied** from the repo-root `shared/telemetry/lib` — edit `shared/telemetry/lib/` first, then refresh this plugin's copy in the same change. The real files next to the copy are `ikey.json` (this plugin's config) and `resolver.js` (the resolver contract implementation). **Posture:** the committed `ikey.json` ships `disabled: false` — transmission is enabled for power-pages (the tenant-side Kusto stream + annotation for `PagesAIPluginEvent` are provisioned). Set `disabled: true` to hard-off (no POST, no local log) if you need to suppress all telemetry at the source.
- **Region routing (Artemis geo + cloud stamp)** lives in `scripts/lib/telemetry/region/` and is wired through `scripts/lib/telemetry/resolver.js`, which implements the resolver contract (`resolve({ event, cfg, cloud, configDir })` / `isProvisioned(cfg)`). The shared dispatcher is routing-agnostic — it auto-discovers `resolver.js` by convention (sibling of `ikey.json`) and calls it; the `region/` implementation is entirely plugin-owned and never referenced by the shared library.
- **Privacy posture:** usage telemetry is **default-on**. When PAC is signed in, events include the Dataverse organization GUID (`orgId`) and Entra tenant GUID (`tenantId`). Power Pages also includes the signed-in user's Entra object ID (`eventInfo.aadObjectId`) when PAC exposes it; otherwise that field is omitted. The local diagnostic mirror retains the same fields. Events do not include file paths, prompts, tool inputs, site names, Dataverse URLs, credentials, usernames, or hostnames. There is no consent prompt in skills. Users opt out via `/power-pages:telemetry off`, which stores a per-plugin choice in `~/.power-platform-skills/config.json` (`telemetry["power-pages"] = "off"`). Opting out stops transmission only; the local diagnostic mirror is still written. Re-enable with `/power-pages:telemetry on`. Automation/CI can disable transmission by setting `POWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT=1` (or `true`); this opt-out has the **highest precedence** and overrides both a persisted `/power-pages:telemetry` choice and `/power-pages:telemetry on`.
- **Strict allowlist:** `shared/telemetry/lib/events.js` enforces exactly the fields listed in the spec. Never add a field to a builder without first adding it to the allowlist and documenting it in the design doc.
- **Fail closed:** telemetry code must never change a script's exit code or break a skill run. Emission is fire-and-forget via a detached dispatcher child, so the hook or script returns before the HTTPS POST completes.
- **Non-SPA creation:** `scripts/emit-non-spa-creation-telemetry.js` records `template_used` and one terminal `non_spa_creation_success`/`non_spa_creation_failure` through the existing transport. Keep its logic in `scripts/lib/non-spa-creation-telemetry.js` and its per-event schema in `shared/telemetry/README.md`. Use only the validated public catalog ID, `templateKind: "non-spa"`, optional already-known `internal`/`external` audience, and fixed failure classes. Save the returned creation `correlationId` for unfinished retries/resume; private receipts are outside the site/Git tree and suppress duplicate calls. Baseline success requires model/identity, download, Bootstrap/assets, Git commit and final validation, not just API acceptance. Do not count cancellations, unresolved operations, presentation-only errors or later customization failures. Do not add telemetry-only questions, catalog fetches, or repeated PAC context lookups. SPA/default hook behavior stays unchanged.
- **Site framework in `eventInfo`:** both emission points attach the SPA framework of the site under work (`react` / `vue` / `angular` / `astro`) to the existing `eventInfo` dynamic column. Detection lives in `scripts/lib/detect-site-framework.js`: it walks up to the nearest Power Pages project boundary, then scans **one level of children** when cwd itself is not a site, and reads **declared** deps from `package.json`. A SPA marker is trusted only when `powerpages.config.json` is a regular file containing a JSON object; malformed or directory-shaped markers stop attribution. The child scan is load-bearing: `create-site`'s recommended layout puts the site in a *child* of the host cwd, and the skill's `cd` runs in a Bash subshell that never moves it. Unlike `findPath`, the scan counts both code (`powerpages.config.json`) and non-SPA (`.powerpages-site/`) children and returns a SPA site only when exactly one total site matches; hooks do not identify which child a skill will target, so mixed or multi-site workspaces must emit no framework rather than whichever directory the filesystem enumerates first. A nearer non-SPA root likewise stops the upward walk so an enclosing SPA site's framework is not misattributed. Framework fingerprints require non-empty string versions for scaffold tooling (`@vitejs/plugin-react`, `@vitejs/plugin-vue`, `@angular/core`, `astro`) rather than merely checking that a key exists; Astro wins because it legitimately hosts other framework integrations, while conflicting non-Astro markers report `null`. It never throws and returns `null` for malformed markers, invalid dependency values, non-Pages directories, ambiguous workspaces, non-SPA-only roots, and unrecognized stacks — the key is then omitted. `create-site`'s own run reports no framework (the project doesn't exist yet); it appears on subsequent skill runs.
- **Optional telemetry enrichment must load non-fatally.** `detect-site-framework` is required in its own `try/catch` that degrades to `null`, *not* in the hooks' mandatory require block. Bundling optional enrichment there would widen the blast radius of any packaging slip or syntax error from "no framework" to **no telemetry at all**. Apply the same separation to any future optional field.
- **Detection cost scales with the entry count of cwd.** A child miss performs one directory enumeration and at most two marker checks per immediate subdirectory; direct or upward site hits do not scan children. Typical directories cost single-digit ms against the hook's much larger PAC/agent-info work. It runs only *after* the hard-disabled / `isProvisioned` gates; user opt-out still builds the event because the local diagnostic mirror is retained.
- **`eventInfo` keys are independent.** Build the object unconditionally and assign it only when non-empty. Never nest a new key inside the `pacAuth.objectId` guard: that both drops the key on unauthenticated runs and throws on `undefined`, which the fail-closed wrapper swallows into *no event at all*.
- **The bundled lib copy is CI-guarded.** `scripts/tests/telemetry-lib-copy.test.js` asserts byte-equality with `shared/telemetry/lib` (skipping when `shared/` is absent, i.e. an installed plugin) and exercises the copy's defensive branches — `shared/telemetry/tests` has no workflow, so behavior asserted only there is not CI-covered.
See `shared/telemetry/README.md` for the integration guide.
## Maintaining This File
Update when plugin structure or conventions change or you learn something which can be useful for new skills or agents.
Keep this file concise — detailed docs belong in `PLUGIN_DEVELOPMENT_GUIDE.md` or individual SKILL.md / agent files.