AGENTS.md@plugins/power-pages · git:20260811.5ebf14d · 2026-08-11 · sha256 4a82dbd2570aac24
AGENTS.md@plugins/power-pages git:20260811.5ebf14dB
Immutable. This exact content is served forever at /api/v1/blob/4a82dbd2570aac24.
# Power Pages Plugin
A plugin for creating, deploying, and managing Power Pages code sites. Supports static SPA frameworks (React, Vue, Angular, Astro) with Dataverse integration, Web API access, and browser-based previews via Playwright.
**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.
## 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 code-site 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.
- **Templates** use `__PLACEHOLDER__` tokens (e.g., `__SITE_NAME__`) replaced during scaffolding. The `gitignore` file is stored without the dot prefix and renamed to `.gitignore` during scaffolding.
- **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)
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
skills/
create-site/
SKILL.md ← Skill definition with frontmatter (model, allowed-tools)
assets/{react,vue,angular,astro}/ ← Framework templates with __PLACEHOLDER__ tokens
references/design-aesthetics.md ← Design principles, font/color/motion guidance for inline design step
scripts/validate-site.js ← Node script validating generated sites
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 site requirements, discovers existing Dataverse tables via OData API, and proposes a data model (new/modified/reused tables + Mermaid ER diagram). Uses `pac env who` + Azure CLI auth to query Dataverse. Renders the ER diagram visually in the browser via Playwright (writes a temp HTML file with Mermaid.js CDN, navigates to it, takes a screenshot) 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 code 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 site code, discovers existing web roles and table permissions, and proposes a table-permissions plan (web roles → table permissions with CRUD flags and scopes) rendered as a Mermaid flowchart. Checks for `.powerpages-site` folder to verify site deployment. 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 queries Dataverse for exact column LogicalNames (case-sensitive) and proposes `Webapi/<table>/enabled` and `Webapi/<table>/fields` site settings. Never uses `*` for field settings except for aggregate OData queries — always lists specific columns. Presents the plan via plan mode; after approval, creates site-setting YAML files using `create-site-setting.js`. Supports the **AI-only read posture** (minimal fields list: no primary key, only `_<col>_value` lookup read forms). 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`: 6-step workflow — gather requirements (including design direction), plan (with explicit scaffold prerequisites), scaffold from template, build pages/components/routing with design applied from the start using `skills/create-site/references/design-aesthetics.md` and live Playwright preview, review, deploy
- `deploy-site`: 6-step workflow — verify PAC CLI, authenticate, confirm environment, upload via `pac pages upload-code-site`, verify deployment (confirm `.powerpages-site` folder, commit, offer activation), handle blocked JS attachments
- `setup-datamodel`: 7-step workflow — verify prerequisites, invoke data-model-architect agent, review proposal, pre-creation checks, create tables & columns via OData API, create relationships, publish & verify. Writes `.datamodel-manifest.json` for hook validation.
- `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.
- `create-webroles`: 6-step workflow — verify `.powerpages-site/web-roles/` exists (redirect to deploy-site if missing), discover existing roles, determine new roles needed, create web role YAML files with UUIDs from shared `scripts/generate-uuid.js`, verify web roles (validate files, UUIDs, uniqueness constraints), review & prompt deployment via deploy-site skill.
- `integrate-webapi`: 7-step workflow — verify site exists, use Explore agent to analyze code and identify tables needing Web API integration, review plan with user, invoke `webapi-integration` agent per table to create API client/types/services/hooks, verify integrations (validate all files exist, project builds), invoke `table-permissions-architect` and `webapi-settings-architect` agents (in parallel) to configure table permissions and site settings, review & deploy via `deploy-site` skill. Supports an `[AI-READ-ONLY]` sentinel that hardens the flow to read-only when invoked by `/add-ai-webapi`.
- `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.
- `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-<SkillName>.sitesetting.yml`) and records the authoring tool (`Site-AI-AuthoringTool.sitesetting.yml`). Exits silently if `.powerpages-site/site-settings/` does not exist. 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` (declarative/data-model sites) → `name`→`siteName`, `id`→`websiteRecordId`; (3) `pac pages list` ONLY when the GUID is still unknown** — declarative sites (and code 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** — code/SPA vs declarative (design-studio) site — NOT the Dataverse data-model axis (a declarative site can be on the standard OR enhanced data model; both download to a `.powerpages-site/` tree). `siteType: "declarative"` is the declarative bucket (it was historically labeled `"data-model"`; that value is now the legacy alias — nothing branches on the literal, so older plan-data carrying `"data-model"` stays equivalent). Resolves identity in order: (1) `powerpages.config.json` → `siteType: "code"` (code/SPA sites); (2) `.powerpages-site/` → `siteType: "declarative"` (declarative design-studio sites — standard or enhanced data model — which have **no** `powerpages.config.json`). The **authoritative declarative marker is the `.powerpages-site/.portalconfig/` directory** (only declarative sites have it); `website.yml` is the identity source (`id`→`websiteRecordId`, `name`→`siteName`) but is NOT a reliable declarative signal alone because **both** site types carry it. `environmentUrl: null` for declarative sites (no env URL in the local files — callers re-confirm via `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 `powerpages.config.json` nor a `.powerpages-site/` (`.portalconfig/`/`website.yml`) marker is found. Note: `findProjectRoot` (in `validation-helpers.js`) likewise treats a `.powerpages-site/` directory as a project-root marker.
- `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".
#### 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** (12 ALM + 12 non-ALM). 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.24 catalogue the non-ALM skills (`create-site`, `deploy-site`, `add-server-logic`, `add-cloud-flow`, `setup-auth`, `integrate-webapi`, `setup-datamodel`, `add-sample-data`, `add-seo`, `create-webroles`, `audit-permissions`, `integrate-backend`). `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`
Checks generated sites for: required files (`package.json`, `.gitignore`, `powerpages.config.json`), config schema fields (`$schema`, `compiledPath`, `siteName`, `defaultLandingPage`), build/dev scripts in package.json, unreplaced `__PLACEHOLDER__` tokens, git initialization, and `src/` directory existence.
### `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.
### `create-webroles/scripts/validate-webroles.js`
Checks that web role YAML files were created in `.powerpages-site/web-roles/`. Validates each file has required `id` and `name` fields and that the `id` field contains a valid UUID v4 format. Gracefully exits 0 when no `.powerpages-site/web-roles/` directory is found (not a web roles session).
### `integrate-webapi/scripts/validate-webapi-integration.js`
Checks that Web API integration code was created for a Power Pages code site: 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).
### `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.
### 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.
### 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.
### 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). **Every skill 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 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 new skill 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.
- **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).
- **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.
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.