# Plan Alm

> Creates an ALM (Application Lifecycle Management) plan for deploying a Power Pages site across environments. Gathers your promotion strategy, target environments, and approval requirements upfront, then generates a visual HTML plan document for your review and approval. **plan-alm does not deploy anything itself** — it is a planner. After you approve the plan, run the individual ALM skills (setup-solution, setup-pipeline, deploy-pipeline, or export-solution/import-solution); each detects the approved plan and executes the right step in order, keeping the plan updated as it runs. Use when asked to: "plan my alm", "set up alm", "create deployment plan", "plan my deployments", "help me deploy to multiple environments", "set up promotion strategy", "create cicd plan", "plan site promotion", "help me go to production", "set up pipeline for my site".

- Skill: `microsoft/plan-alm` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add microsoft/plan-alm`
- Raw SKILL.md: https://api.skillmd.com/api/skills/microsoft/plan-alm/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Microsoft (https://skillmd.com/u/microsoft)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/microsoft/plan-alm

---


> **Plugin check**: Run `node "${PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding.

# plan-alm

A 4-phase **planner** that gathers ALM strategy from the user, generates an HTML deployment plan, and gets approval. **It does not execute anything** — execution is delegated to the individual ALM skills, which the user runs afterward.

## Overview

This skill detects the current project state (existing solution, pipeline), asks targeted questions about the desired promotion strategy (Power Platform Pipelines or Manual export/import), generates a visual `docs/alm-plan.html`, and gets user approval. The four phases are: **Phase 1 — Detect**, **Phase 2 — Gather strategy**, **Phase 3 — Generate plan**, **Phase 4 — Approve & save**.

**plan-alm never deploys.** The plan's `steps[]` array records the **recommended execution sequence**. After approval, the user invokes the individual skills — `setup-solution`, `setup-pipeline` (or `export-solution`), and `deploy-pipeline` (or `import-solution`) — in that order. Each of those skills detects the approved plan via its Phase 0 gate, proceeds without re-nagging, and refreshes the plan on completion. This separation is deliberate: it keeps `plan-alm` safe to run unattended (e.g. under autopilot) because no single answer can trigger an irreversible deployment.

**Do NOT create tasks at the start** — strategy is unknown until Phase 2 completes. Create both tasks in Phase 3 once the strategy is determined.

---

## Phase 1 — Detect Project State

**Do NOT create tasks yet.** Use natural language progress reporting only during this phase.

Steps:

0. **Detect prior ALM deferral for this project.** Before any discovery work, check whether the project root contains a `.alm-deferred` marker file. The marker is written by users who explicitly opted ALM-skill validators out of "missing artifacts" warnings (e.g. *"this site is handled separately"* or *"ni-dev — no ALM"*). If a user is now invoking `plan-alm`, we should surface that the marker is present and ask what to do, rather than silently proceeding (which would build a plan the user previously decided not to maintain) or silently removing the marker (which would re-enable nags on every other ALM skill).

   ```bash
   node "${PLUGIN_ROOT}/scripts/lib/check-alm-plan.js" --projectRoot "." --no-heartbeat
   ```

   > Use `--no-heartbeat` here: this is a **read-only** deferral check by the *planner*, not an execution-skill Phase 0 gate. Without it, `check-alm-plan.js` would promote an already-`Approved` plan to `In Execution` (and refresh the heartbeat) just because you re-opened `plan-alm` — but re-planning isn't execution. Execution skills call it *without* `--no-heartbeat` so the first one to run does the `Approved → In Execution` promotion.

   <!-- gate: plan-alm:1.deferral | category=progress | cancel-leaves=deferral-marker -->
   > 🚦 **Gate (progress · plan-alm:1.deferral):** `.alm-deferred` marker present — continue and remove, continue and keep marker, or cancel. Determines whether downstream ALM skills resume gate enforcement.

   The helper returns `{ deferred, deferral, ... }`. If `deferred === true`, read the deferral reason (`deferral.reason` or the raw marker text) and ask via `AskUserQuestion`:

   > "This project has an `.alm-deferred` marker — `{reason}`. ALM was previously deferred here, so the other ALM skills (`setup-solution`, `setup-pipeline`, `deploy-pipeline`, …) skip their plan-completeness checks for this project. How would you like to proceed?"

   | Question | Header | Options |
   |---|---|---|
   | How would you like to proceed? | ALM deferral marker | Continue planning and remove the marker (Recommended), Continue planning but keep the marker (record deferral context in plan), Cancel |

   - **Continue and remove marker (Recommended)** → delete `.alm-deferred` (the user is re-engaging with ALM). Set `DEFERRAL_CLEARED = true` and proceed to step 1.
   - **Continue and keep marker** → set `DEFERRAL_PRESERVED = true` and `DEFERRAL_REASON = {reason}`. Proceed to step 1. Surface a one-line note in the Phase 1 step 9 user report (e.g. *"Note: `.alm-deferred` is preserved — other ALM skills will continue to skip plan-completeness checks for this project."*) so the user remembers the marker remains in effect after planning.
   - **Cancel** → exit cleanly (don't touch the marker).

   If `deferred === false`, skip this step silently and proceed to step 0b.

0b. **Offer to approve an existing Draft in place (skip re-planning).** The same `check-alm-plan.js` output from step 0 also carries `exists` and `planStatus`. When `exists === true` **and** `planStatus === "Draft"`, the user already has a saved Draft plan — offer to approve it directly instead of regenerating the whole plan. (This is the only Draft→Approved path; without it, approving a draft means a full re-plan.)

   <!-- gate: plan-alm:1.approve-draft | category=plan | cancel-leaves=nothing -->
   > 🚦 **Gate (plan · plan-alm:1.approve-draft):** An existing **Draft** plan was found — approve it in place (no re-plan), re-plan from scratch, or cancel. Approving here writes the status via `set-plan-status.js` and exits without re-running discovery; no deployment is triggered.

   Ask via `AskUserQuestion`:
   > "This site already has an ALM plan saved as **Draft** (`docs/alm-plan.html`). What would you like to do?"

   | Question | Header | Options |
   |---|---|---|
   | What would you like to do? | Existing draft plan | Approve this draft now — no re-plan (Recommended), Re-plan from scratch, Cancel |

   - **Approve this draft now (Recommended)** → capture the approver using the **Phase 4 approver-capture procedure** (the always-interactive prompt with git/OS-name prefill), then write the status atomically with the helper:

     ```bash
     node "${PLUGIN_ROOT}/scripts/lib/set-plan-status.js" --projectRoot "." --status Approved --approver "{APPROVER}" --render
     ```

     Commit (`git add docs/alm-plan.html docs/.alm-plan-data.json && git commit -m "Approve ALM plan for {siteName}"`), run skill tracking (Phase 4 finalize), print the Phase 4 next-steps guidance, and **exit**. Do **not** continue to step 1 — there is nothing to re-plan.
   - **Re-plan from scratch** → proceed to step 1 (the rest of Phase 1 regenerates the plan; Phase 4 saves the new version).
   - **Cancel** → exit cleanly (leave the Draft as-is).

   If `exists === false`, or `planStatus` is anything other than `"Draft"` (`Approved` / `In Execution` / `Completed` / null), skip this step silently and proceed to step 1.

1. **Resolve the site identity from the local project.** `.powerpages-site/website.yml` is the source of truth for `websiteRecordId` and `siteName`, and it is present for **both** Power Pages site types:
   - **Code / SPA sites** — scaffolded by `/power-pages:create-site` and downloaded with `pac pages download-code-site`. These also have a `powerpages.config.json` and SPA source (`src/`, build output in `dist/`/`build/`).
   - **Data-model sites (standard and enhanced data model / "EDM")** — downloaded with `pac pages download --modelVersion 1|2`. These have **no** `powerpages.config.json`; instead `.powerpages-site/` holds the config tree (`web-pages/`, `web-templates/`, `content-snippets/`, …) plus a `.powerpages-site/.portalconfig/` manifest pair. There is no local build output.

   **Resolution order** (first match wins):
   1. **`.powerpages-site/website.yml`** (preferred, present for every downloaded/deployed site) — read with the `Read` tool and extract:
      - `id` field → `websiteRecordId`
      - `name` field → `siteName` (the file uses short keys; it is `name:`, not `adx_name:`)
   2. **`powerpages.config.json`** (fallback — code/SPA sites only; used during plugin development from this repo root or for sites scaffolded but not yet deployed) — read `siteName` and `websiteRecordId`.

   **Determine `SITE_TYPE`** (recorded in planData as `siteType` and used to skip SPA-only assumptions below; it is a data field in `docs/.alm-plan-data.json`, not rendered in the HTML):
   - `declarative` when `.powerpages-site/.portalconfig/` exists, **or** `.powerpages-site/website.yml` resolved while no `powerpages.config.json` is present. (This value was formerly `data-model`; plans written before the rename may still carry `data-model`, which is equivalent.)
   - `code` when `powerpages.config.json` is present.

   If neither marker is found, stop with:
   > "No Power Pages site found in the current directory. Run this skill from your site project root — that's where `.powerpages-site/` lives after `pac pages download-code-site` (code/SPA site) or `pac pages download --modelVersion 2` (enhanced data-model site). If you haven't created the site yet, run `/power-pages:create-site` first."

   `environmentUrl` is always re-confirmed from `pac env who` in step 4 — it does not need to come from either source.

2. Check for `.solution-manifest.json` in the project root:
   - Store `SOLUTION_DONE = true` if found, `false` otherwise
   - If found, read `solution.uniqueName` and store as `SOLUTION_UNIQUE_NAME`

3. Check for `docs/alm/last-pipeline.json` in the project root:
   - Store `PIPELINE_DONE = true` if found, `false` otherwise
   - If found, read `pipelineName` and `stages[]` for later use

4. Run silently:
   ```bash
   pac env who
   ```
   Capture the environment URL and display name. Store as `DEV_ENV_URL` and `DEV_ENV_NAME`. **The URL label varies by PAC version**: current PAC (2.8.x) prints it under `Org URL:`; older builds used `Environment URL:` — read whichever is present (there is no `Environment URL:` line on 2.8.x, so do not look only for that label). The display name is the `Friendly Name:` / `Connected to...` value. If you can't parse it reliably, leave `DEV_ENV_URL` empty — Step 6's `verify-alm-prerequisites.js` resolves the authoritative URL from `pac env who` via the shared `getEnvironmentUrl()` helper (which matches both labels) and returns it as `.envUrl`.

5. Run silently:
   ```bash
   node "${PLUGIN_ROOT}/scripts/lib/list-environments.js"
   ```
   Store the JSON array as `ENV_LIST` for pre-filling environment URLs in Phase 2. (This helper parses `pac env list`; the old `pac env list --output json` is invalid on current PAC CLI — `pac env list` only accepts `--filter` — so the helper exists to produce the JSON the table form doesn't. It prints `[]` and exits 0 if PAC is unauthenticated, so pre-fill simply degrades to manual entry.) Each entry is `{ displayName, environmentId, environmentUrl, uniqueName, active }`.

6. Acquire dev environment token (silently):
   ```bash
   node "${PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --envUrl "{DEV_ENV_URL}"
   ```
   Store `.token` as `DEV_TOKEN` and `.userId` as `userId`.

   **Track plan quality.** Initialize a `PLAN_QUALITY` accumulator to `"complete"` at the start of Phase 1. If this token acquisition fails (auth error), set `DEV_TOKEN = null`, set `PLAN_QUALITY = "degraded"`, and record the cause (e.g. *"dev-environment auth failed — contents/size/host discovery skipped"*) — then continue. Contents discovery is skipped gracefully, but the resulting plan is built on partial inputs; Phase 3 surfaces this as a prominent risk so the user reviews before executing. (There is no execute path to block here — `plan-alm` only plans — but a degraded plan must be visibly flagged.)

6b. **Environment-match guard** — confirm `pac env who` points at the project's environment *before* running discovery. `DEV_ENV_URL` comes from whatever environment PAC happens to be connected to, which is **not** guaranteed to be the project's. If it isn't, every query in Steps 7–12 runs against the wrong environment and silently produces a degraded plan (zero or wrong site settings, wrong size, wrong host) that *looks* valid. Cross-check both signals available:

    1. **Recorded-URL comparison** (no token needed): collect any environment URL the project already records — `powerpages.config.json` → top-level `environmentUrl` (code/SPA sites; absent for declarative/EDM sites) and `.solution-manifest.json` → top-level `environmentUrl` if present. Normalize by **origin** (lowercase host, drop trailing slash + path/query). If any recorded URL exists and its origin **differs** from `DEV_ENV_URL`'s origin → **mismatch**.
    2. **Site-existence probe** (covers declarative/EDM sites that record no URL; only when `DEV_TOKEN` is available): verify the site's `websiteRecordId` actually exists in the connected env:
       ```
       GET {DEV_ENV_URL}/api/data/v9.2/powerpagesites({websiteRecordId})?$select=powerpagesiteid
       Authorization: Bearer {DEV_TOKEN}
       ```
       A `404` (or empty result) means the connected environment does not contain this site → **mismatch**. (Skip this probe when `DEV_TOKEN = null` — Step 6 already degraded the plan; don't double-prompt.)

    If **neither** signal indicates a mismatch, continue silently to Step 7 — do not prompt. Only prompt on a detected mismatch:

    <!-- gate: plan-alm:1.env-match | category=progress | cancel-leaves=nothing -->
    > 🚦 **Gate (progress · plan-alm:1.env-match):** PAC CLI is connected to an environment that does not match the project's. Switch and re-run, or continue against the connected env (degraded plan).

    Ask via `AskUserQuestion`:

    | Question | Header | Options |
    |---|---|---|
    | PAC CLI is connected to **{DEV_ENV_NAME}** (`{DEV_ENV_URL}`), which does not match this project's configured environment ({recorded URL, or "this site was not found there"}). Discovery will run against the connected environment. How do you want to proceed? | Env Mismatch | Switch PAC env & re-run (Recommended), Continue against {DEV_ENV_NAME} anyway |

    Exactly two outcomes (both halt-or-proceed; no separate "cancel" — "Switch & re-run" already stops the skill):
    - **Switch PAC env & re-run (Recommended)**: stop the skill. Tell the user to point PAC at the right environment (`pac auth select --name <profile>` or `pac org select --environment <url>`) and re-run `/power-pages:plan-alm`. Nothing has been written.
    - **Continue against {DEV_ENV_NAME} anyway**: proceed to Step 7 against `DEV_ENV_URL`, but set `PLAN_QUALITY = "degraded"` and record the cause (*"discovery ran against {DEV_ENV_NAME}, which may not be the project's environment — verify the plan's site settings / size / host before executing"*) so Phase 3 surfaces it as a prominent risk.

    > **Why this exists**: a real EDM-site run produced a valid-looking plan after PAC had silently stayed connected to a different env than the project targeted. The site-existence probe + recorded-URL comparison catch that at the earliest gate, before any discovery runs.

7. Discover and classify site settings (if `DEV_TOKEN` is available and `websiteRecordId` is known):

   Use Node.js `https` module to query. **Paginate via `@odata.nextLink`** — sites with > 500 settings would otherwise silently truncate, dropping tier classifications and underreporting `plannedEnvVarCount`. Send `Prefer: odata.maxpagesize=5000` so Dataverse emits the continuation link, then loop until exhausted:
   ```
   GET {DEV_ENV_URL}/api/data/v9.2/mspp_sitesettings?$filter=_mspp_websiteid_value eq '{websiteRecordId}'&$select=mspp_name,mspp_value&$top=5000
   Authorization: Bearer {DEV_TOKEN}
   Prefer: odata.maxpagesize=5000
   OData-MaxVersion: 4.0
   OData-Version: 4.0
   Accept: application/json
   ```
   On each response, append `value[]` to the running array. If `@odata.nextLink` is present, GET that URL with the same headers (no need to re-add the filter — the nextLink already encodes the query). Stop when the response has no `@odata.nextLink`. Cap at 100 iterations for safety.

   Classify the returned settings using `${PLUGIN_ROOT}/scripts/lib/classify-site-settings.js` — the single source of truth for the credential regex and tier mapping shared with `setup-solution` Phase 5. Either pipe the JSON array of `{name, value}` rows into the script's stdin (CLI mode) or `require()` it inline:

   ```bash
   echo '<JSON array of {name,value}>' \
     | node "${PLUGIN_ROOT}/scripts/lib/classify-site-settings.js"
   ```

   Output (the four-bucket shape that downstream phases + `setup-solution` consume directly):

   ```js
   SITE_SETTINGS_DATA = {
     keepAsIs: [{name}],                          // regular settings (Tier 3 — Search/Bootstrap/WebApi/feature flags)
     authNoValue: [{name}],                       // Authentication/* or AzureAD/* with empty value (Tier 2b — added as-is, set in target env)
     promoteToEnvVar: [{name, value}],            // Authentication/* or AzureAD/* with value (Tier 2a — setup-solution offers env-var promotion)
     credentialNeedsDecision: [{name, value}]     // ConsumerKey/ConsumerSecret/ClientId/ClientSecret/AppSecret/AppKey/ApiKey/Password (Tier 1 — bulk-with-override prompt in setup-solution Phase 5.4.C)
   }
   ```

   Tier semantics in plain English (so reviewers reading the plan know what each bucket implies):
   - **Tier 1 (`credentialNeedsDecision`)** — credential-style names. Setup-solution Phase 5.4.C runs a single bulk prompt: auto-classify by name (Secret-typed env var for `*Secret`/`*Password`/`*ApiKey`/`*AppKey`; String-typed for `*Id`/`*ConsumerKey`), all-as-Secret, all-as-String, skip-all, or pick-per-credential.
   - **Tier 2a (`promoteToEnvVar`)** — auth config with a dev value. Setup-solution Phase 5.4.A asks which to back with env vars so each stage can use different values.
   - **Tier 2b (`authNoValue`)** — auth config with no dev value yet. Added to the solution as-is; user sets the value in each target env after deployment.
   - **Tier 3 (`keepAsIs`)** — everything else. Added unchanged.

   If the OData query fails or the helper errors out, set `SITE_SETTINGS_DATA = null` and continue — the plan still renders, it just can't break down site settings by tier.

8. Build `SOLUTION_CONTENTS_DATA`:
   ```js
   {
     tables: solutionManifest?.components?.tables || [],     // from .solution-manifest.json if SOLUTION_DONE
     botComponents: solutionManifest?.botComponents || [],   // from manifest if available
     siteSettings: SITE_SETTINGS_DATA                        // from step 7, or null
   }
   ```
   If `SOLUTION_DONE = false` and manifest is absent, `tables` and `botComponents` will be empty arrays — the plan will show a note that they will be discovered during setup-solution.

9. Report to user:
   ```
   Found: **{siteName}** on `{devEnvUrl}`.
   Solution: {✓ already set up ({solutionUniqueName}) / ✗ not yet}.
   Pipeline: {✓ already set up ({pipelineName}) / ✗ not yet}.
   Site settings: {N total — K regular (keep as-is), P auth settings to review for env var, A auth settings (no dev value), C credential-style settings (setup-solution will prompt per credential) / unable to query}.
   ```

10. **Estimate solution size and evaluate the split decision tree.** First ensure the ALM artifacts directory exists (all `.alm-*` and `last-*` artifacts live under `docs/alm/` to keep the project root uncluttered):
    ```bash
    node -e "require('fs').mkdirSync('docs/alm',{recursive:true})"
    ```
    Run the estimate helper to classify the site across size, component count, schema heaviness, web file aggregate, and env var count. Use the tmp-file write pattern — if the estimator fails, a prior good `docs/alm/alm-size-estimate.json` is preserved instead of being overwritten with an empty/partial file. When `SOLUTION_DONE = true` (a `.solution-manifest.json` exists), pass `--solutionId {solutionId}` so the env var count is scoped to the target solution — without it, the estimator falls back to a publisher-prefix tenant-wide query and overcounts whenever the prefix is shared across projects (the common `new_` / `cr5fe_` regression):
    ```bash
    node "${PLUGIN_ROOT}/scripts/lib/estimate-solution-size.js" \
      --envUrl "{DEV_ENV_URL}" --websiteRecordId "{websiteRecordId}" \
      --publisherPrefix "{publisherPrefix}" --siteName "{siteName}" \
      {if SOLUTION_DONE: --solutionId "{solutionManifest.solution.solutionId}"} \
      --projectRoot "." --siteType "{SITE_TYPE}" \
      --datamodelManifest "./.datamodel-manifest.json" > ./docs/alm/alm-size-estimate.json.tmp \
      && mv ./docs/alm/alm-size-estimate.json.tmp ./docs/alm/alm-size-estimate.json
    ```
    When `SOLUTION_DONE = false`, omit `--solutionId`; the estimator's output will include `envVarCountScope: "publisher-prefix"` to signal the wider scope, and the renderer surfaces this caveat in the Env Variables tab so reviewers know the number reflects the tenant view, not a specific solution. `--projectRoot "."` enables the disk cross-check — the estimator walks the local build output (`dist/`, `public-output/`, `build/`, `.output/`) and surfaces `webFilesDiskMeasuredMB`. When that number is much larger than the Dataverse-measured `webFilesAggregateMB`, the estimator flips `truncationSuspected: true` with a warning — file-typed columns whose bytes aren't returned by `$select=content` are the usual cause and the plan should trust the disk number.

> **`SITE_TYPE = "declarative"` (EDM/standard data-model) sites have no build output**, so the disk cross-check finds no `dist/`/`build/` directory and `webFilesDiskMeasuredMB` stays `null` — this is expected, not a problem. Web files for declarative sites live as records under `.powerpages-site/web-files/` and are measured via the Dataverse query, so the size estimate is still valid; there's simply no SPA bundle on disk to cross-check against. Pass `--projectRoot "."` regardless — it's a harmless no-op for these sites.
    Then run the decision tree (same tmp-file pattern):
    ```bash
    node "${PLUGIN_ROOT}/scripts/lib/compute-split-plan.js" \
      --estimate ./docs/alm/alm-size-estimate.json \
      --projectRoot "." \
      --siteName "{siteName}" \
      --publisherPrefix "{publisherPrefix}" > ./docs/alm/alm-split-plan.json.tmp \
      && mv ./docs/alm/alm-split-plan.json.tmp ./docs/alm/alm-split-plan.json
    ```
    If either command exits non-zero, stop and report the stderr message to the user. Do not proceed to Q1b in Phase 2 without a valid split plan.
    Store the output as `SPLIT_PLAN`. Fields to read: `splitStrategy`, `proposedSolutions[]`, `appliedStrategies[]`, `assetAdvisory`, `sizeAnalysis`, `recommendations[]`.

    If `SPLIT_PLAN.proposedSolutions.length > 1`, set `RECOMMEND_SPLIT = true`. Otherwise `false`.

    Report to the user:
    ```
    Estimated size: {totalSizeMB} MB — components: {count} — tier: {overall tier}.
    Tables: {tableCount} — scoped to the site's table permissions ({tableCountScope}).
    Decision tree result: {splitStrategy} → {N} solutions recommended.
    Asset advisory: {K} files flagged for Azure Blob externalization.
    ```

    > **Table count is site-referenced, not publisher-prefix.** The estimator scopes custom tables to the tables the site actually references (its table permissions + datamodel manifest), so a shared/default publisher (`new_`) no longer inflates the count. `tableCountScope` reports how it was scoped: `site-referenced` (table permissions), `manifest-only`, or `unavailable` (no local `.powerpages-site/` signal — table count is 0, never an env-wide dump). When `unavailable`, note that the table-based split signal was skipped. The estimate command already passes `--projectRoot "."`, which supplies the local table permissions.

10b. **Enumerate environment variable definitions** (runs whenever `DEV_TOKEN` is available — the size estimator gives a count but not per-variable metadata).

    The renderer's Env Variables tab needs schema name, type, default value, and bound site setting per definition. Without this step, the tab can only show a count-summary note while the size signal and the warning quote a number — three views that don't fully agree. Running this query produces the row-level data so the table renders properly.

    Pass `--solutionId` when `SOLUTION_DONE = true` so the returned envVars[] is scoped to the target solution. The helper paginates correctly (Prefer: odata.maxpagesize + @odata.nextLink) regardless of scope — the difference is just which env vars are returned.

    ```bash
    node "${PLUGIN_ROOT}/scripts/lib/discover-env-var-definitions.js" \
      --envUrl "{DEV_ENV_URL}" --token "{DEV_TOKEN}" \
      --publisherPrefix "{publisherPrefix}" \
      --websiteRecordId "{websiteRecordId}" \
      {if SOLUTION_DONE: --solutionId "{solutionManifest.solution.solutionId}"} > ./docs/alm/alm-env-vars.json.tmp \
      && mv ./docs/alm/alm-env-vars.json.tmp ./docs/alm/alm-env-vars.json
    ```

    Read the JSON: `{ envVars: [{ schemaName, type, defaultValue, siteSetting }], count, scope }`. The `scope` field is `'solution'` when `--solutionId` was passed and the solution had env var defs, `'publisher-prefix'` otherwise, `'none'` when the helper short-circuited (no prefix or auth lapse). Store `envVars` as `ENV_VARS_DETAILS` and `scope` as `ENV_VARS_SCOPE` for use when building `planData.envVars` in Phase 3 (pass through unchanged).

    The helper degrades gracefully (returns `{ envVars: [], count: 0, scope: 'none' }`) when the publisher prefix is unknown, the token has expired, or the query errors. In those cases the renderer falls back to the size estimator's count via `sizeAnalysis.envVarCount.value` (commit `8cbc39a`) — `ENV_VARS_DETAILS = []` is acceptable.

    > **Why scoping matters here**: without `--solutionId`, the helper filters env var defs by publisher prefix tenant-wide. For tenants with a generic prefix (`new_`, `cr5fe_`), this returns env vars from unrelated projects and inflates the count + the `envVars[]` table the renderer draws. The plan looks correct ("12 env vars detected") but is actually showing rows from someone else's project. With `--solutionId`, the helper intersects against `solutioncomponents.componenttype=380` for the target solution — only env vars actually owned by the plan's solution.

    **Skip rule**: if `DEV_TOKEN` is null (auth was unavailable in step 6), skip this step and set `ENV_VARS_DETAILS = []`. The renderer's count-summary fallback covers this case.

11. **Pre-plan completeness check** (only runs when `SOLUTION_DONE = true`).

    Before the user approves a plan, verify the existing solution already covers everything on the live site. Components created after the last `/power-pages:setup-solution` run (server logic from `add-server-logic`, flows from `add-cloud-flow`, env vars from `configure-env-variables` or `setup-auth`) are silently excluded from any plan built on top of a stale solution.

    Run the shared discovery helper against the source environment:

    ```bash
    node "${PLUGIN_ROOT}/scripts/lib/discover-site-components.js" \
      --envUrl "{envUrl}" --token "{token}" \
      --siteId "{websiteRecordId from powerpages.config.json}" \
      --publisherPrefix "{solutionManifest.publisher.prefix}" \
      --solutionId "{solutionManifest.solution.solutionId}" \
      --projectRoot "."
    ```

    Parse stdout and evaluate `missing.*`:

    - **All `missing.*` arrays empty** → report "Solution contents match the site — proceeding with fresh plan inputs." Continue to Phase 2.
    - **Any non-empty `missing.*` array** → report a compact summary:
      > "Your solution is **missing {N} component(s)** that exist on the site:
      >
      > - **{X}** site components (e.g. {first 3 names})
      > - **{L}** site languages (powerpagesitelanguage — required; without these the target site silently fails to render post-auth)
      > - **{Y}** cloud flows
      > - **{Z}** environment variable definitions
      > - **{W}** custom tables
      >
      > A plan built now will ignore these components. How would you like to proceed?"

      Always render the **site languages** line when `missing.siteLanguages.length > 0`, even when other categories are zero — this gap was a recurring silent-failure mode before discover-site-components started enumerating `powerpagesitelanguages`. See `references/solution-api-patterns.md` for the 3-entity model.

      <!-- gate: plan-alm:1.completeness | category=progress | cancel-leaves=nothing -->
      > 🚦 **Gate (progress · plan-alm:1.completeness):** Completeness check found gaps vs live site. Sync first, plan with gaps recorded, or cancel.

      Ask via `AskUserQuestion`:

      | Question | Header | Options |
      |---|---|---|
      | Run `/power-pages:setup-solution` in sync mode to adopt the missing components before planning? | Completeness Check | Yes — sync first (Recommended), No — plan with current solution contents, Cancel |

      - **Yes, sync first (Recommended)**: invoke `/power-pages:setup-solution` (auto-detects the existing manifest and enters sync mode). After it completes, re-run the discovery helper; if `missing.*` is now empty proceed to Phase 2, otherwise repeat the prompt.
      - **No, plan with current contents**: store the gap summary as `KNOWN_GAPS` so Phase 3 can surface it in the plan HTML's Risks section, then continue.
      - **Cancel**: stop the skill so the user can investigate.

    > **Why this exists**: the same check runs at export (`export-solution` Phase 2.5) and deploy (`deploy-pipeline` Phase 3.5). Adding it here catches gaps at the earliest possible gate — before the user invests time reviewing a plan built on stale inputs. See AGENTS.md → ALM-aware by default.

    > **Skip when `SOLUTION_DONE = false`**: if there is no manifest yet, there is nothing to be stale against — Phase 2 Q1 will handle first-time solution setup.

12. **Run host resolution** (PP Pipelines path only — runs after the completeness check).

    **Skip rule:** if `PIPELINE_DONE = true`, skip this step entirely — the host info comes from `docs/alm/last-pipeline.json`. Only fresh-pipeline projects need resolution.

    Acquire a BAP-audience access token (the BAP API uses a different audience than Dataverse):
    ```bash
    az account get-access-token --resource "https://service.powerapps.com/" --query accessToken -o tsv
    ```
    Capture the output as `BAP_TOKEN`. If acquisition fails, set `HOST_RESOLUTION = { status: 'DetectionFailed', error: '<stderr>' }` and skip the detect call.

    Run the detect-only wrapper. Use the same tmp-file-then-mv pattern as Phase 1 step 10 so a prior good `docs/alm/alm-host-resolution.json` is preserved if the script fails mid-write. Pass `--skus Production,Sandbox,Trial` so trial-license and developer tenants see their eligible envs in the env-first menu (the helper's default is `Production,Sandbox`; we widen to include Trial here because plan-alm's NoHost branch always offers an existing-env install path that Trial envs can take, even though Trial envs cannot use the create-new fast-path):
    ```bash
    node "${PLUGIN_ROOT}/scripts/lib/ensure-pipelines-host-detect.js" \
      --envUrl "{DEV_ENV_URL}" --token "{DEV_TOKEN}" --userId "{userId}" \
      --bapToken "{BAP_TOKEN}" \
      --projectRoot "." \
      --cacheMaxAgeHours 24 \
      --skus Production,Sandbox,Trial > ./docs/alm/alm-host-resolution.json.tmp \
      && mv ./docs/alm/alm-host-resolution.json.tmp ./docs/alm/alm-host-resolution.json
    ```

    > **Note**: `ensure-pipelines-host-detect.js` is a **detection-only wrapper** the `ensure-pipelines-host` skill exposes for orchestrators. It runs Phases 1.0 (cache fast-path) + 2 (resolution order including tenant-wide enumeration) + 5 (verify if a host is found) of that workflow, but never enters Phase 3 (decision tree) or Phase 4 (provisioning). Output matches the `docs/alm/last-host-check.json` schemaVersion 2 with `actionTaken: "none"` always.

    **Failure handling:** if the detection script exits non-zero, set `HOST_RESOLUTION = { status: 'DetectionFailed', error: '<stderr>' }` and continue. Phase 2 Q4 falls back to today's "enter URL manually" branch.

    On success, parse `docs/alm/alm-host-resolution.json` and store as `HOST_RESOLUTION` (mapping the wrapper's field names into the plan-alm shape):
    ```js
    HOST_RESOLUTION = {
      status: parsed.resolutionStatus,                  // one of: AvailableUsingCustomHost | AvailableUsingCustomHostByAdminDefault | AvailableUsingPlatformHost | AvailableUnboundCustomHost | MultipleUnboundCustomHosts | PlatformHostExistsUnbound | CannotRedirect | NoHost | OrgSettingStale | PermissionDenied
      finalHostEnvUrl: parsed.finalHostEnvUrl,          // string | null
      finalHostEnvId: parsed.finalHostEnvId,            // string | null
      hostType: parsed.isPlatformHost ? 'platform' : (parsed.finalHostEnvUrl ? 'custom' : null),
      pipelinesSolutionVersion: parsed.pipelinesSolutionVersion,  // string | null
      candidates: parsed.candidates                     // { existingCustomHosts[], existingPlatformHost, eligibleForAppInstall[], inaccessibleEnvs[] }
    }
    ```

    Report a single line:
    ```
    Pipeline host: {finalHostEnvUrl} ({status})
    ```
    or, when no URL is set yet:
    ```
    Pipeline host: will be ensured during setup-pipeline ({status})
    ```

---

## Phase 2 — Gather ALM Strategy

Ask questions in sequence. **Solution is always Q1** — it is the prerequisite for all other steps. Branch after Q2 based on promotion strategy selection.

**Log every major decision.** As each decision is made below (Q1 solution, Q1b split/override, Q2 strategy, Q3 stages/targets, Q4 host, Q5 approval mode, Q5 manual export type), append to a `DECISIONS_LOG` array: `{ field, value, source }` where `source = "default"` when the recommended/auto value was accepted without an active change, or `"explicit"` when the user picked a non-default option. Phase 4 renders a **"Decisions defaulted (please review)"** section from this log so a reviewer can see at a glance which choices were defaults vs. deliberate picks (closes the *"I never agreed to managed export"* gap). This adds no new prompts — it only records what the existing prompts produced.

### Q1 — Solution Setup (always asked first)

**If `SOLUTION_DONE = true`** (manifest found in Phase 1):

<!-- gate: plan-alm:2.q1-existing | category=plan | cancel-leaves=nothing -->
> 🚦 **Gate (plan · plan-alm:2.q1-existing):** Existing solution found — reuse it (skip setup-solution) or create new (run setup-solution).

Ask via `AskUserQuestion`:
> "A Dataverse solution is already configured for this site: **{SOLUTION_UNIQUE_NAME}**. Use this existing solution?"

Options:
1. **Yes, use the existing solution** — `setup-solution` will be skipped in the plan
2. **No, create a new solution** — set `SOLUTION_DONE = false`; `setup-solution` will run

**If `SOLUTION_DONE = false`** (no manifest found):

Tell the user (not via `AskUserQuestion` — informational only):
> "No Dataverse solution is set up for this site yet. **`setup-solution` will be the first step in your plan.** The publisher prefix you choose during setup is irreversible — choose carefully."

<!-- gate: plan-alm:2.q1-fresh | category=plan | cancel-leaves=nothing -->
> 🚦 **Gate (plan · plan-alm:2.q1-fresh):** No existing solution — include setup-solution in plan, or accept a user-supplied unique name.

Ask via `AskUserQuestion`:
> "Ready to include solution setup in the plan?"

Options:
1. **Yes, include solution setup** — continue
2. **I already have a solution (enter name)** — accept free-text solution unique name, set `SOLUTION_DONE = true`, `SOLUTION_UNIQUE_NAME = user input`

---

### Q1b — Split Recommendation (only if `RECOMMEND_SPLIT = true`)

<!-- gate: plan-alm:2.q1b-split | category=plan | cancel-leaves=nothing -->
> 🚦 **Gate (plan · plan-alm:2.q1b-split):** Follow recommended split strategy, override to single, accept Asset Advisory first, or show migration guidance.

The decision tree from Phase 1 Step 10 recommended splitting into multiple solutions. Ask via `AskUserQuestion`:

> "Based on the site size and component analysis, the recommended approach is **{splitStrategy}** — {N} solutions instead of one. Do you want to follow this recommendation?"

Options:
1. **Use the recommended split** — proceed with `proposedSolutions[]` from the decision tree. `setup-solution` will create all N solutions.
2. **Keep as a single solution anyway** — override to single. Record override reason; `setup-solution` creates one solution with all components.
3. **Accept Asset Advisory first** (only offered if `assetAdvisory.candidates.length > 0`) — user commits to externalizing the flagged assets. Recompute size excluding those files, re-run the decision tree, present the new recommendation.
4. **Show me migration guidance** (only offered if an existing `.solution-manifest.json` is found and does not match the recommendation) — produce `docs/alm-migration-plan.md` and exit. Do not execute.

**If option 1:** continue with `proposedSolutions`.

**If option 2 — Keep as a single solution anyway:** this overrides a data-driven recommendation that's frequently right. Before honoring the override, **re-surface the tier signals so the user is making an informed choice, not a one-click dismissal.** Read from `SPLIT_PLAN.sizeAnalysis`:

   ```
   You're about to override a {splitStrategy} recommendation. Before doing that, here's what the estimator measured:

   • Total size:           {totalSizeMB.value} MB   (tier: {totalSizeMB.tier} — threshold {thresholds.maxSolutionSizeMB} MB)
   • Component count:      {componentCount.value}   (tier: {componentCount.tier} — threshold {thresholds.maxComponentCount})
   • Schema attributes:    {schemaAttrCount.value}  (tier: {schemaAttrCount.tier} — threshold {thresholds.maxSchemaAttrs})
   • Web files aggregate:  {webFilesAggregateMB.value} MB  (tier: {webFilesAggregateMB.tier} — threshold {thresholds.maxAggregateWebFilesMB} MB)
   • Env var definitions:  {envVarCount.value}      (tier: {envVarCount.tier})

   {if SPLIT_PLAN.truncationSuspected === true:
     ⚠ The estimator flagged its inputs as possibly truncated:
       {SPLIT_PLAN.truncationWarnings.join('\n       ')}
     The numbers above could be UNDER-counted. Investigate before overriding.
   }

   Solutions exceeding the platform thresholds frequently fail to import (timeouts, OOM, partial state). A single-solution plan that lands in the red tier is the most common cause of "the deploy hung overnight" reports. Recovering means splitting after the fact, which is harder than splitting upfront.
   ```

   <!-- gate: plan-alm:2.q1b-override | category=consent | cancel-leaves=nothing -->
   > 🚦 **Gate (consent · plan-alm:2.q1b-override):** Override the data-driven split recommendation to keep as single solution. Free-text `overrideReason` follows on Yes.

   Then ask via `AskUserQuestion`:

   | Question | Header | Options |
   |---|---|---|
   | Still want to keep as a single solution? | Override confirmation | No — use the recommended {splitStrategy} split (Recommended), Yes — override anyway and note the reason, Cancel — re-think the strategy |

   - **No** → re-route to Option 1 (use the recommended split).
   - **Yes** → require a free-text `overrideReason` via a follow-up `AskUserQuestion` ("Briefly: why is single-solution the right call for this site?"). Record `overrideReason` and `overrideConfirmedSignals` (the tier-classified signals shown above) in the plan. Only then override `SPLIT_PLAN.proposedSolutions` to the single-solution structure for rendering.
   - **Cancel** → return to Q1b top.

   > **Why the friction:** in field-reported sessions, "keep as single anyway" was a one-click override and turned out to be the single most common path to a wrong recommendation. The re-confirmation isn't there to talk the user out of it — it's there to make sure the override is informed and the reason gets recorded for audit. Override-with-recorded-reason is fully respected; the gate only blocks the silent click-through.

**If option 3:** subtract advisory candidate siz

…(truncated)
