Plugin check: Run node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js" — if it outputs a message, show it to the user before proceeding.
plan-alm
An 8-phase orchestrator that gathers ALM strategy from the user, generates an HTML deployment plan, gets approval, then executes the plan by calling existing skills in sequence.
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, gets user approval, and then invokes setup-solution, setup-pipeline (or export-solution), and deploy-pipeline (or import-solution) in the correct order.
Do NOT create tasks at the start — strategy is unknown until Phase 2 completes. Create all 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:
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).
node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/check-alm-plan.js" --projectRoot "."
🚦 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 1.
Resolve the site identity from the local project. ALM skills are normally invoked from a site-root directory where pac pages download-code-site (or a create-site scaffold followed by a deploy) has written .powerpages-site/website.yml. That YAML file is the source of truth for websiteRecordId and siteName.
Resolution order (first match wins):
.powerpages-site/website.yml (preferred, present for every 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:)
powerpages.config.json (fallback, used during plugin development from this repo root or for sites scaffolded but not yet deployed) — read siteName and websiteRecordId.
If neither is found, stop with:
"No Power Pages site found in the current directory. Run this skill from your site project root (where .powerpages-site/ exists after pac pages download-code-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.
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
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
Run silently:
pac env who
Capture the Environment URL and display name. Store as DEV_ENV_URL and DEV_ENV_NAME.
Run silently:
pac env list --output json 2>/dev/null
Store output as ENV_LIST for pre-filling environment URLs in Phase 2.
Acquire dev environment token (silently):
node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --envUrl "{DEV_ENV_URL}"
Store .token as DEV_TOKEN and .userId as userId. If this fails (auth error), set DEV_TOKEN = null and continue — contents discovery will be skipped gracefully.
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 ${CLAUDE_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:
echo '<JSON array of {name,value}>' \
| node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/classify-site-settings.js"
Output (the four-bucket shape that downstream phases + setup-solution consume directly):
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.
Build SOLUTION_CONTENTS_DATA:
{
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.
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}.
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):
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):
node "${CLAUDE_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 "." \
--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.
Then run the decision tree (same tmp-file pattern):
node "${CLAUDE_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}.
Decision tree result: {splitStrategy} → {N} solutions recommended.
Asset advisory: {K} files flagged for Azure Blob externalization.
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 "${CLAUDE_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.
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:
node "${CLAUDE_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}"
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 (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.
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):
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):
node "${CLAUDE_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):
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.
Q1 — Solution Setup (always asked first)
If SOLUTION_DONE = true (manifest found in Phase 1):
🚦 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:
- Yes, use the existing solution —
setup-solution will be skipped in the plan
- 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 · 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:
- Yes, include solution setup — continue
- 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 · 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:
- Use the recommended split — proceed with
proposedSolutions[] from the decision tree. setup-solution will create all N solutions.
- Keep as a single solution anyway — override to single. Record override reason;
setup-solution creates one solution with all components.
- 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.
- 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 (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 sizes from the estimate, re-run compute-split-plan.js, re-present.
If option 4: write docs/alm-migration-plan.md (see the spec doc solution-splitting-logic.md §7), commit it, mark plan as Deferred, exit.
Q2 — Strategy Selection (always asked)
🚦 Gate (plan · plan-alm:2.q2-strategy): Pick promotion strategy — PP Pipelines, manual export/import, existing pipeline, or help-me-decide. Branches the rest of the plan.
Ask via AskUserQuestion:
"How do you want to promote your solution between environments?"
Options:
- Power Platform Pipelines — Microsoft's native CI/CD, managed deployments, approval gates
- Manual export/import — export a zip from dev and import directly to each target environment
- I already have a pipeline set up — run a deployment now
- Help me decide — show a quick comparison
If option 4 selected: Explain:
"Power Platform Pipelines is recommended for teams and multiple environments — it provides automated promotion, approval gates, and deployment history in one place. Manual export/import is simpler for one-off migrations or when you only need to deploy once. For ongoing CI/CD, choose Power Platform Pipelines."
Then re-ask Q2 with only options 1–3.
If option 3 selected: Read docs/alm/last-pipeline.json, confirm pipeline name and stages, then skip to Phase 3 (generate plan) with strategy = pp-pipelines, PIPELINE_DONE = true.
PP Pipelines Path — Q3 through Q6
🚦 Gate (plan · plan-alm:2.q3-stages): Pick how many deployment stages — Staging only / +Production / Production directly / Custom.
Q3: Ask via AskUserQuestion:
"How many deployment stages do you want in this pipeline?"
Options:
- Staging only — Dev → Staging (I'll add Production later)
- Staging + Production — Dev → Staging → Production (full promotion chain)
- Production directly — Dev → Production only (bypass staging)
- Custom — I'll describe my own stage layout
If option 4: accept free-text description (via "Other") and build a stage list from the response.
Store stages as PP_STAGES (array of { label, envUrl, envName, type }). Dev is always the source.
For each stage, populate envName from ENV_LIST (gathered in Phase 1 Step 5 via pac env list --output json). Match by URL origin (lowercase, trailing slash stripped, path/query ignored) and copy the entry's DisplayName (or displayName) into envName. When no match is found — usually because the user pasted a custom URL via "Other" — leave envName unset; the renderer falls back to showing the URL alone in the stage card. The renderer puts envName between the stage label and the URL (e.g. Staging / Supplier Portal Staging / https://orgd6a9894f.crm5.dynamics.com/) so reviewers recognize the env at a glance and the URL stays available as a one-click jump-to-env. Set type: "source" for the dev/source stage and type: "target" for every downstream stage so the renderer applies the active-stage styling correctly.
Q4 (host environment — branches on HOST_RESOLUTION.status from Phase 1 step 12):
This question consumes HOST_RESOLUTION populated by the new detect-only wrapper run in Phase 1 step 12. Each branch sets HOST_ENV_URL (which feeds the rest of plan-alm) and may also set the auxiliary flags CHOSEN_ENV_URL, WILL_PROVISION_PLATFORM, WILL_PROVISION_CUSTOM, WILL_USE_PPAC, WILL_ENSURE_HOST, and USER_CHOSE_DEFER_TO_SETUP_PIPELINE. Defaults: HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl, all flags false / null.
Why the NoHost branch presents the env-first menu here instead of deferring to ensure-pipelines-host Phase 3.C: the original design asked a yes/no "we'll provision new — continue?" question in plan-alm and let 3.C surface the env-first choice at execution time. In practice the agent treated the plan-alm yes-confirmation as authorization to skip 3.C entirely (or to skip 4.A's pre-call gate), and users hit 4.A → 409 trial-license errors when an existing env install (4.B) would have been a clean path. Surfacing the env-first menu here — once, at planning time, when the user has full context — eliminates the ambiguity. ensure-pipelines-host then trusts CHOSEN_ENV_URL and skips its own 3.C menu (see ensure-pipelines-host Phase 3 skip rule).
status |
Q4 prompt |
Result |
AvailableUsingCustomHost, AvailableUsingCustomHostByAdminDefault, AvailableUsingPlatformHost |
"Detected host {finalHostEnvUrl} (Pipelines v{pipelinesSolutionVersion}). Use this host?" Options: 1. Yes, use this / 2. Use a different host environment (Other) |
Y → HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl. N → fall back to today's "enter different URL" branch (free-text via Other). |
AvailableUnboundCustomHost |
"Existing Custom Host {displayName} ({finalHostEnvUrl}) found in tenant — not yet bound to dev env. setup-pipeline will reuse it (recommended; avoids duplicates). Use this host?" Options: 1. Yes, use this / 2. Use a different host environment (Other) |
Y → HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl, WILL_ENSURE_HOST = true. N → fall back to "enter different URL". |
MultipleUnboundCustomHosts |
"{N} Custom Hosts found in tenant. Which one should setup-pipeline use?" Options: enumerate HOST_RESOLUTION.candidates.existingCustomHosts[] (up to 3) by display name + URL, plus "Other" for a custom URL, plus "Decide later — setup-pipeline will ask". |
Picked candidate → HOST_ENV_URL = candidate.instanceApiUrl, WILL_ENSURE_HOST = true. Decide-later → HOST_ENV_URL = null, WILL_ENSURE_HOST = true, USER_CHOSE_DEFER_TO_SETUP_PIPELINE = true. |
PlatformHostExistsUnbound |
"Tenant Platform Host {finalHostEnvUrl} exists. Use it (no admin role required) or create a new Custom Host?" Options: 1. Use Platform Host / 2. Create new Custom Host / 3. Cancel |
1 → HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl, WILL_ENSURE_HOST = true. 2 → HOST_ENV_URL = null, WILL_PROVISION_CUSTOM = true, WILL_ENSURE_HOST = true. 3 → exit. |
NoHost |
Host-type prompt — same shape as ensure-pipelines-host Phase 3.C so the user makes the host choice once, here, instead of being asked again at execution time. Present: "No Pipelines host bound to {devEnvUrl}. Which environment should host Pipelines? Pipelines lives in one env per tenant; pipelines, stages, and run history are stored there. Source envs deploy through it." Top-level options: 1. "Provision a Platform Host (recommended) — Microsoft-managed Dataverse env auto-provisioned in your tenant home geo. Pipelines app pre-installed. Idempotent. ~3–5 min." 2. "Set up a Custom Host — Pipelines lives in a Dataverse env you control. We'll ask whether to use an existing env or create a brand-new dedicated one." 3. "Open PPAC and create one manually (admin fallback)." 4. "Switch to Manual export/import strategy." 5. "Cancel." When the user picks Option 2, surface the Custom-Host sub-prompt: build the eligible-env list from HOST_RESOLUTION.candidates.eligibleForAppInstall[] with role labels (dev env, source env, staging env, production env) per origin match; cap the visible list at 5 envs with role-aware ranking (see "Eligible-env presentation cap" below). Sub-options: a. Each visible env (display name + URL + role labels) labeled "Install Pipelines app on this env" — sandbox-sku envs add a "(Sandbox — confirmation gate)" suffix; append "Other (paste URL)" as the last per-env entry. b. "Create a brand-new dedicated env (D365_ProjectHost template, ~5–10 min, requires Power Platform admin)." c. "Back — return to host-type menu." When the eligible list is empty, drop sub-option a and present only b / c. |
Option 1 (Platform Host) → HOST_ENV_URL = null, WILL_PROVISION_PLATFORM = true, WILL_ENSURE_HOST = true. Option 2 → sub-prompt; sub-option a picked env → HOST_ENV_URL = picked.instanceApiUrl, CHOSEN_ENV_URL = picked.instanceApiUrl, WILL_ENSURE_HOST = true (Sandbox confirmation gate, if applicable, must be passed before this resolution stands; "Other (paste URL)" → ask for the env URL via free-text, then proceed as a picked eligible env); sub-option b → HOST_ENV_URL = null, WILL_PROVISION_CUSTOM = true, WILL_ENSURE_HOST = true; sub-option c → re-show top-level menu. Option 3 (PPAC) → HOST_ENV_URL = null, WILL_USE_PPAC = true, WILL_ENSURE_HOST = true. Option 4 (Manual strategy) → restart Phase 2 with STRATEGY = manual. Option 5 → exit. |
CannotRedirect |
Block. Show the org-setting vs tenant-default mismatch error from HOST_RESOLUTION.candidates/warnings and stop the skill — only a Power Platform admin can resolve. |
Exit with the specific error. |
OrgSettingStale, PermissionDenied, DetectionFailed |
Surface the error; ask the user to enter the host URL manually with pac env list pre-fill (today's fallback). Pre-fill options from ENV_LIST (up to 3 known environment URLs) plus "Other" for a custom URL; pre-fill first option from docs/alm/last-pipeline.json if present. |
HOST_ENV_URL = user-supplied. |
Store the resulting HOST_ENV_URL for use by the rest of plan-alm. The auxiliary flags CHOSEN_ENV_URL, WILL_PROVISION_PLATFORM, WILL_PROVISION_CUSTOM, WILL_USE_PPAC, WILL_ENSURE_HOST, and USER_CHOSE_DEFER_TO_SETUP_PIPELINE feed the planData hostResolution block in Phase 3 and the inline summary in Phase 4. ensure-pipelines-host reads chosenEnvUrl, willProvisionPlatform, willProvisionCustom, and willUsePpac from that block to bypass its own Phase 3.C menu when the user has already made the choice here.
Eligible-env presentation cap (used by the NoHost row above and by MultipleUnboundCustomHosts). When the eligible list runs long, build the visible options as follows so the prompt stays scannable:
- Always-visible role-labeled envs first. Include any eligible env carrying a
dev env, source env, staging env, or production env label (matched by URL origin against devEnvUrl and against PP_STAGES[].envUrl). These are the project's own envs and are nearly always the right pick. Dedupe by origin.
- Fill remaining slots up to 5 from the rest of the eligible list, in the order returned by
list-tenant-envs.js (name-hint pattern pipeline|deploy|host|alm|cicd|govern → admin-perms → recency).
- Append "Other (paste URL)" as the last per-env entry inside option 1's nested list — escape hatch for envs that didn't make the cap.
- When
eligible.length > 5, suffix option 1's headline with: Showing top 5 of {N}; the remaining {N-5} eligible env(s) can be reached via the "Other (paste URL)" entry. When eligible.length <= 5, no suffix (all envs visible inline).
- When the user picks "Other (paste URL)", pre-fill the URL input with
ENV_LIST (the pac env list --output json output gathered in Phase 1) s
…(truncated)
1---2name: plan-alm3description: 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, generates a visual HTML plan document for review, then — after your approval — executes the plan by calling setup-solution, setup-pipeline, export-solution, and deploy-pipeline (or import-solution) in sequence. 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".4---56> **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding.78# plan-alm910An 8-phase orchestrator that gathers ALM strategy from the user, generates an HTML deployment plan, gets approval, then executes the plan by calling existing skills in sequence.1112## Overview1314This 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`, gets user approval, and then invokes `setup-solution`, `setup-pipeline` (or `export-solution`), and `deploy-pipeline` (or `import-solution`) in the correct order.1516**Do NOT create tasks at the start** — strategy is unknown until Phase 2 completes. Create all tasks in Phase 3 once the strategy is determined.1718---1920## Phase 1 — Detect Project State2122**Do NOT create tasks yet.** Use natural language progress reporting only during this phase.2324Steps:25260. **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).2728 ```bash29 node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/check-alm-plan.js" --projectRoot "."30 ```3132 <!-- gate: plan-alm:1.deferral | category=progress | cancel-leaves=deferral-marker -->33 > 🚦 **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.3435 The helper returns `{ deferred, deferral, ... }`. If `deferred === true`, read the deferral reason (`deferral.reason` or the raw marker text) and ask via `AskUserQuestion`:3637 > "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?"3839 | Question | Header | Options |40 |---|---|---|41 | 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 |4243 - **Continue and remove marker (Recommended)** → delete `.alm-deferred` (the user is re-engaging with ALM). Set `DEFERRAL_CLEARED = true` and proceed to step 1.44 - **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.45 - **Cancel** → exit cleanly (don't touch the marker).4647 If `deferred === false`, skip this step silently and proceed to step 1.48491. **Resolve the site identity from the local project.** ALM skills are normally invoked from a site-root directory where `pac pages download-code-site` (or a create-site scaffold followed by a deploy) has written `.powerpages-site/website.yml`. That YAML file is the source of truth for `websiteRecordId` and `siteName`.5051 **Resolution order** (first match wins):52 1. **`.powerpages-site/website.yml`** (preferred, present for every deployed site) — read with the `Read` tool and extract:53 - `id` field → `websiteRecordId`54 - `name` field → `siteName` (the file uses short keys; it is `name:`, not `adx_name:`)55 2. **`powerpages.config.json`** (fallback, used during plugin development from this repo root or for sites scaffolded but not yet deployed) — read `siteName` and `websiteRecordId`.5657 If neither is found, stop with:58 > "No Power Pages site found in the current directory. Run this skill from your site project root (where `.powerpages-site/` exists after `pac pages download-code-site`). If you haven't created the site yet, run `/power-pages:create-site` first."5960 `environmentUrl` is always re-confirmed from `pac env who` in step 4 — it does not need to come from either source.61622. Check for `.solution-manifest.json` in the project root:63 - Store `SOLUTION_DONE = true` if found, `false` otherwise64 - If found, read `solution.uniqueName` and store as `SOLUTION_UNIQUE_NAME`65663. Check for `docs/alm/last-pipeline.json` in the project root:67 - Store `PIPELINE_DONE = true` if found, `false` otherwise68 - If found, read `pipelineName` and `stages[]` for later use69704. Run silently:71 ```bash72 pac env who73 ```74 Capture the `Environment URL` and display name. Store as `DEV_ENV_URL` and `DEV_ENV_NAME`.75765. Run silently:77 ```bash78 pac env list --output json 2>/dev/null79 ```80 Store output as `ENV_LIST` for pre-filling environment URLs in Phase 2.81826. Acquire dev environment token (silently):83 ```bash84 node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --envUrl "{DEV_ENV_URL}"85 ```86 Store `.token` as `DEV_TOKEN` and `.userId` as `userId`. If this fails (auth error), set `DEV_TOKEN = null` and continue — contents discovery will be skipped gracefully.87887. Discover and classify site settings (if `DEV_TOKEN` is available and `websiteRecordId` is known):8990 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:91 ```92 GET {DEV_ENV_URL}/api/data/v9.2/mspp_sitesettings?$filter=_mspp_websiteid_value eq '{websiteRecordId}'&$select=mspp_name,mspp_value&$top=500093 Authorization: Bearer {DEV_TOKEN}94 Prefer: odata.maxpagesize=500095 OData-MaxVersion: 4.096 OData-Version: 4.097 Accept: application/json98 ```99 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.100101 Classify the returned settings using `${CLAUDE_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:102103 ```bash104 echo '<JSON array of {name,value}>' \105 | node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/classify-site-settings.js"106 ```107108 Output (the four-bucket shape that downstream phases + `setup-solution` consume directly):109110 ```js111 SITE_SETTINGS_DATA = {112 keepAsIs: [{name}], // regular settings (Tier 3 — Search/Bootstrap/WebApi/feature flags)113 authNoValue: [{name}], // Authentication/* or AzureAD/* with empty value (Tier 2b — added as-is, set in target env)114 promoteToEnvVar: [{name, value}], // Authentication/* or AzureAD/* with value (Tier 2a — setup-solution offers env-var promotion)115 credentialNeedsDecision: [{name, value}] // ConsumerKey/ConsumerSecret/ClientId/ClientSecret/AppSecret/AppKey/ApiKey/Password (Tier 1 — bulk-with-override prompt in setup-solution Phase 5.4.C)116 }117 ```118119 Tier semantics in plain English (so reviewers reading the plan know what each bucket implies):120 - **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.121 - **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.122 - **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.123 - **Tier 3 (`keepAsIs`)** — everything else. Added unchanged.124125 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.1261278. Build `SOLUTION_CONTENTS_DATA`:128 ```js129 {130 tables: solutionManifest?.components?.tables || [], // from .solution-manifest.json if SOLUTION_DONE131 botComponents: solutionManifest?.botComponents || [], // from manifest if available132 siteSettings: SITE_SETTINGS_DATA // from step 7, or null133 }134 ```135 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.1361379. Report to user:138 ```139 Found: **{siteName}** on `{devEnvUrl}`.140 Solution: {✓ already set up ({solutionUniqueName}) / ✗ not yet}.141 Pipeline: {✓ already set up ({pipelineName}) / ✗ not yet}.142 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}.143 ```14414510. **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):146 ```bash147 node -e "require('fs').mkdirSync('docs/alm',{recursive:true})"148 ```149 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):150 ```bash151 node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/estimate-solution-size.js" \152 --envUrl "{DEV_ENV_URL}" --websiteRecordId "{websiteRecordId}" \153 --publisherPrefix "{publisherPrefix}" --siteName "{siteName}" \154 {if SOLUTION_DONE: --solutionId "{solutionManifest.solution.solutionId}"} \155 --projectRoot "." \156 --datamodelManifest "./.datamodel-manifest.json" > ./docs/alm/alm-size-estimate.json.tmp \157 && mv ./docs/alm/alm-size-estimate.json.tmp ./docs/alm/alm-size-estimate.json158 ```159 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.160 Then run the decision tree (same tmp-file pattern):161 ```bash162 node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/compute-split-plan.js" \163 --estimate ./docs/alm/alm-size-estimate.json \164 --projectRoot "." \165 --siteName "{siteName}" \166 --publisherPrefix "{publisherPrefix}" > ./docs/alm/alm-split-plan.json.tmp \167 && mv ./docs/alm/alm-split-plan.json.tmp ./docs/alm/alm-split-plan.json168 ```169 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.170 Store the output as `SPLIT_PLAN`. Fields to read: `splitStrategy`, `proposedSolutions[]`, `appliedStrategies[]`, `assetAdvisory`, `sizeAnalysis`, `recommendations[]`.171172 If `SPLIT_PLAN.proposedSolutions.length > 1`, set `RECOMMEND_SPLIT = true`. Otherwise `false`.173174 Report to the user:175 ```176 Estimated size: {totalSizeMB} MB — components: {count} — tier: {overall tier}.177 Decision tree result: {splitStrategy} → {N} solutions recommended.178 Asset advisory: {K} files flagged for Azure Blob externalization.179 ```18018110b. **Enumerate environment variable definitions** (runs whenever `DEV_TOKEN` is available — the size estimator gives a count but not per-variable metadata).182183 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.184185 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.186187 ```bash188 node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/discover-env-var-definitions.js" \189 --envUrl "{DEV_ENV_URL}" --token "{DEV_TOKEN}" \190 --publisherPrefix "{publisherPrefix}" \191 --websiteRecordId "{websiteRecordId}" \192 {if SOLUTION_DONE: --solutionId "{solutionManifest.solution.solutionId}"} > ./docs/alm/alm-env-vars.json.tmp \193 && mv ./docs/alm/alm-env-vars.json.tmp ./docs/alm/alm-env-vars.json194 ```195196 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).197198 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.199200 > **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.201202 **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.20320411. **Pre-plan completeness check** (only runs when `SOLUTION_DONE = true`).205206 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.207208 Run the shared discovery helper against the source environment:209210 ```bash211 node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/discover-site-components.js" \212 --envUrl "{envUrl}" --token "{token}" \213 --siteId "{websiteRecordId from powerpages.config.json}" \214 --publisherPrefix "{solutionManifest.publisher.prefix}" \215 --solutionId "{solutionManifest.solution.solutionId}"216 ```217218 Parse stdout and evaluate `missing.*`:219220 - **All `missing.*` arrays empty** → report "Solution contents match the site — proceeding with fresh plan inputs." Continue to Phase 2.221 - **Any non-empty `missing.*` array** → report a compact summary:222 > "Your solution is **missing {N} component(s)** that exist on the site:223 >224 > - **{X}** site components (e.g. {first 3 names})225 > - **{L}** site languages (powerpagesitelanguage — required; without these the target site silently fails to render post-auth)226 > - **{Y}** cloud flows227 > - **{Z}** environment variable definitions228 > - **{W}** custom tables229 >230 > A plan built now will ignore these components. How would you like to proceed?"231232 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.233234 <!-- gate: plan-alm:1.completeness | category=progress | cancel-leaves=nothing -->235 > 🚦 **Gate (progress · plan-alm:1.completeness):** Completeness check found gaps vs live site. Sync first, plan with gaps recorded, or cancel.236237 Ask via `AskUserQuestion`:238239 | Question | Header | Options |240 |---|---|---|241 | 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 |242243 - **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.244 - **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.245 - **Cancel**: stop the skill so the user can investigate.246247 > **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.248249 > **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.25025112. **Run host resolution** (PP Pipelines path only — runs after the completeness check).252253 **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.254255 Acquire a BAP-audience access token (the BAP API uses a different audience than Dataverse):256 ```bash257 az account get-access-token --resource "https://service.powerapps.com/" --query accessToken -o tsv258 ```259 Capture the output as `BAP_TOKEN`. If acquisition fails, set `HOST_RESOLUTION = { status: 'DetectionFailed', error: '<stderr>' }` and skip the detect call.260261 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):262 ```bash263 node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/ensure-pipelines-host-detect.js" \264 --envUrl "{DEV_ENV_URL}" --token "{DEV_TOKEN}" --userId "{userId}" \265 --bapToken "{BAP_TOKEN}" \266 --projectRoot "." \267 --cacheMaxAgeHours 24 \268 --skus Production,Sandbox,Trial > ./docs/alm/alm-host-resolution.json.tmp \269 && mv ./docs/alm/alm-host-resolution.json.tmp ./docs/alm/alm-host-resolution.json270 ```271272 > **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.273274 **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.275276 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):277 ```js278 HOST_RESOLUTION = {279 status: parsed.resolutionStatus, // one of: AvailableUsingCustomHost | AvailableUsingCustomHostByAdminDefault | AvailableUsingPlatformHost | AvailableUnboundCustomHost | MultipleUnboundCustomHosts | PlatformHostExistsUnbound | CannotRedirect | NoHost | OrgSettingStale | PermissionDenied280 finalHostEnvUrl: parsed.finalHostEnvUrl, // string | null281 finalHostEnvId: parsed.finalHostEnvId, // string | null282 hostType: parsed.isPlatformHost ? 'platform' : (parsed.finalHostEnvUrl ? 'custom' : null),283 pipelinesSolutionVersion: parsed.pipelinesSolutionVersion, // string | null284 candidates: parsed.candidates // { existingCustomHosts[], existingPlatformHost, eligibleForAppInstall[], inaccessibleEnvs[] }285 }286 ```287288 Report a single line:289 ```290 Pipeline host: {finalHostEnvUrl} ({status})291 ```292 or, when no URL is set yet:293 ```294 Pipeline host: will be ensured during setup-pipeline ({status})295 ```296297---298299## Phase 2 — Gather ALM Strategy300301Ask questions in sequence. **Solution is always Q1** — it is the prerequisite for all other steps. Branch after Q2 based on promotion strategy selection.302303### Q1 — Solution Setup (always asked first)304305**If `SOLUTION_DONE = true`** (manifest found in Phase 1):306307<!-- gate: plan-alm:2.q1-existing | category=plan | cancel-leaves=nothing -->308> 🚦 **Gate (plan · plan-alm:2.q1-existing):** Existing solution found — reuse it (skip setup-solution) or create new (run setup-solution).309310Ask via `AskUserQuestion`:311> "A Dataverse solution is already configured for this site: **{SOLUTION_UNIQUE_NAME}**. Use this existing solution?"312313Options:3141. **Yes, use the existing solution** — `setup-solution` will be skipped in the plan3152. **No, create a new solution** — set `SOLUTION_DONE = false`; `setup-solution` will run316317**If `SOLUTION_DONE = false`** (no manifest found):318319Tell the user (not via `AskUserQuestion` — informational only):320> "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."321322<!-- gate: plan-alm:2.q1-fresh | category=plan | cancel-leaves=nothing -->323> 🚦 **Gate (plan · plan-alm:2.q1-fresh):** No existing solution — include setup-solution in plan, or accept a user-supplied unique name.324325Ask via `AskUserQuestion`:326> "Ready to include solution setup in the plan?"327328Options:3291. **Yes, include solution setup** — continue3302. **I already have a solution (enter name)** — accept free-text solution unique name, set `SOLUTION_DONE = true`, `SOLUTION_UNIQUE_NAME = user input`331332---333334### Q1b — Split Recommendation (only if `RECOMMEND_SPLIT = true`)335336<!-- gate: plan-alm:2.q1b-split | category=plan | cancel-leaves=nothing -->337> 🚦 **Gate (plan · plan-alm:2.q1b-split):** Follow recommended split strategy, override to single, accept Asset Advisory first, or show migration guidance.338339The decision tree from Phase 1 Step 10 recommended splitting into multiple solutions. Ask via `AskUserQuestion`:340341> "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?"342343Options:3441. **Use the recommended split** — proceed with `proposedSolutions[]` from the decision tree. `setup-solution` will create all N solutions.3452. **Keep as a single solution anyway** — override to single. Record override reason; `setup-solution` creates one solution with all components.3463. **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.3474. **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.348349**If option 1:** continue with `proposedSolutions`.350351**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`:352353 ```354 You're about to override a {splitStrategy} recommendation. Before doing that, here's what the estimator measured:355356 • Total size: {totalSizeMB.value} MB (tier: {totalSizeMB.tier} — threshold {thresholds.maxSolutionSizeMB} MB)357 • Component count: {componentCount.value} (tier: {componentCount.tier} — threshold {thresholds.maxComponentCount})358 • Schema attributes: {schemaAttrCount.value} (tier: {schemaAttrCount.tier} — threshold {thresholds.maxSchemaAttrs})359 • Web files aggregate: {webFilesAggregateMB.value} MB (tier: {webFilesAggregateMB.tier} — threshold {thresholds.maxAggregateWebFilesMB} MB)360 • Env var definitions: {envVarCount.value} (tier: {envVarCount.tier})361362 {if SPLIT_PLAN.truncationSuspected === true:363 ⚠ The estimator flagged its inputs as possibly truncated:364 {SPLIT_PLAN.truncationWarnings.join('\n ')}365 The numbers above could be UNDER-counted. Investigate before overriding.366 }367368 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.369 ```370371 <!-- gate: plan-alm:2.q1b-override | category=consent | cancel-leaves=nothing -->372 > 🚦 **Gate (consent · plan-alm:2.q1b-override):** Override the data-driven split recommendation to keep as single solution. Free-text `overrideReason` follows on Yes.373374 Then ask via `AskUserQuestion`:375376 | Question | Header | Options |377 |---|---|---|378 | 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 |379380 - **No** → re-route to Option 1 (use the recommended split).381 - **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.382 - **Cancel** → return to Q1b top.383384 > **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.385386**If option 3:** subtract advisory candidate sizes from the estimate, re-run `compute-split-plan.js`, re-present.387**If option 4:** write `docs/alm-migration-plan.md` (see the spec doc `solution-splitting-logic.md` §7), commit it, mark plan as Deferred, exit.388389---390391### Q2 — Strategy Selection (always asked)392393<!-- gate: plan-alm:2.q2-strategy | category=plan | cancel-leaves=nothing -->394> 🚦 **Gate (plan · plan-alm:2.q2-strategy):** Pick promotion strategy — PP Pipelines, manual export/import, existing pipeline, or help-me-decide. Branches the rest of the plan.395396Ask via `AskUserQuestion`:397398> "How do you want to promote your solution between environments?"399400Options:4011. **Power Platform Pipelines** — Microsoft's native CI/CD, managed deployments, approval gates4022. **Manual export/import** — export a zip from dev and import directly to each target environment4033. **I already have a pipeline set up** — run a deployment now4044. **Help me decide** — show a quick comparison405406**If option 4 selected:** Explain:407> "Power Platform Pipelines is recommended for teams and multiple environments — it provides automated promotion, approval gates, and deployment history in one place. Manual export/import is simpler for one-off migrations or when you only need to deploy once. For ongoing CI/CD, choose Power Platform Pipelines."408409Then re-ask Q2 with only options 1–3.410411**If option 3 selected:** Read `docs/alm/last-pipeline.json`, confirm pipeline name and stages, then skip to Phase 3 (generate plan) with `strategy = pp-pipelines`, `PIPELINE_DONE = true`.412413---414415### PP Pipelines Path — Q3 through Q6416417<!-- gate: plan-alm:2.q3-stages | category=plan | cancel-leaves=nothing -->418> 🚦 **Gate (plan · plan-alm:2.q3-stages):** Pick how many deployment stages — Staging only / +Production / Production directly / Custom.419420**Q3:** Ask via `AskUserQuestion`:421> "How many deployment stages do you want in this pipeline?"422423Options:4241. **Staging only** — Dev → Staging (I'll add Production later)4252. **Staging + Production** — Dev → Staging → Production (full promotion chain)4263. **Production directly** — Dev → Production only (bypass staging)4274. **Custom** — I'll describe my own stage layout428429If option 4: accept free-text description (via "Other") and build a stage list from the response.430431Store stages as `PP_STAGES` (array of `{ label, envUrl, envName, type }`). Dev is always the source.432433For each stage, populate `envName` from `ENV_LIST` (gathered in Phase 1 Step 5 via `pac env list --output json`). Match by URL origin (lowercase, trailing slash stripped, path/query ignored) and copy the entry's `DisplayName` (or `displayName`) into `envName`. When no match is found — usually because the user pasted a custom URL via "Other" — leave `envName` unset; the renderer falls back to showing the URL alone in the stage card. The renderer puts `envName` between the stage label and the URL (e.g. *Staging / **Supplier Portal Staging** / https://orgd6a9894f.crm5.dynamics.com/*) so reviewers recognize the env at a glance and the URL stays available as a one-click jump-to-env. Set `type: "source"` for the dev/source stage and `type: "target"` for every downstream stage so the renderer applies the active-stage styling correctly.434435**Q4 (host environment — branches on `HOST_RESOLUTION.status` from Phase 1 step 12):**436437This question consumes `HOST_RESOLUTION` populated by the new detect-only wrapper run in Phase 1 step 12. Each branch sets `HOST_ENV_URL` (which feeds the rest of plan-alm) and may also set the auxiliary flags `CHOSEN_ENV_URL`, `WILL_PROVISION_PLATFORM`, `WILL_PROVISION_CUSTOM`, `WILL_USE_PPAC`, `WILL_ENSURE_HOST`, and `USER_CHOSE_DEFER_TO_SETUP_PIPELINE`. Defaults: `HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl`, all flags `false` / null.438439**Why the NoHost branch presents the env-first menu here instead of deferring to ensure-pipelines-host Phase 3.C:** the original design asked a yes/no "we'll provision new — continue?" question in plan-alm and let 3.C surface the env-first choice at execution time. In practice the agent treated the plan-alm yes-confirmation as authorization to skip 3.C entirely (or to skip 4.A's pre-call gate), and users hit 4.A → 409 trial-license errors when an existing env install (4.B) would have been a clean path. Surfacing the env-first menu **here** — once, at planning time, when the user has full context — eliminates the ambiguity. ensure-pipelines-host then trusts `CHOSEN_ENV_URL` and skips its own 3.C menu (see ensure-pipelines-host Phase 3 skip rule).440441| `status` | Q4 prompt | Result |442|---|---|---|443| `AvailableUsingCustomHost`, `AvailableUsingCustomHostByAdminDefault`, `AvailableUsingPlatformHost` | "Detected host `{finalHostEnvUrl}` (Pipelines v`{pipelinesSolutionVersion}`). Use this host?" Options: 1. Yes, use this / 2. Use a different host environment (Other) | Y → `HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl`. N → fall back to today's "enter different URL" branch (free-text via Other). |444| `AvailableUnboundCustomHost` | "Existing Custom Host `{displayName}` (`{finalHostEnvUrl}`) found in tenant — not yet bound to dev env. setup-pipeline will reuse it (recommended; avoids duplicates). Use this host?" Options: 1. Yes, use this / 2. Use a different host environment (Other) | Y → `HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl`, `WILL_ENSURE_HOST = true`. N → fall back to "enter different URL". |445| `MultipleUnboundCustomHosts` | "{N} Custom Hosts found in tenant. Which one should setup-pipeline use?" Options: enumerate `HOST_RESOLUTION.candidates.existingCustomHosts[]` (up to 3) by display name + URL, plus "Other" for a custom URL, plus "Decide later — setup-pipeline will ask". | Picked candidate → `HOST_ENV_URL = candidate.instanceApiUrl`, `WILL_ENSURE_HOST = true`. Decide-later → `HOST_ENV_URL = null`, `WILL_ENSURE_HOST = true`, `USER_CHOSE_DEFER_TO_SETUP_PIPELINE = true`. |446| `PlatformHostExistsUnbound` | "Tenant Platform Host `{finalHostEnvUrl}` exists. Use it (no admin role required) or create a new Custom Host?" Options: 1. Use Platform Host / 2. Create new Custom Host / 3. Cancel | 1 → `HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl`, `WILL_ENSURE_HOST = true`. 2 → `HOST_ENV_URL = null`, `WILL_PROVISION_CUSTOM = true`, `WILL_ENSURE_HOST = true`. 3 → exit. |447| `NoHost` | **Host-type prompt** — same shape as `ensure-pipelines-host` Phase 3.C so the user makes the host choice once, here, instead of being asked again at execution time. Present: *"No Pipelines host bound to `{devEnvUrl}`. Which environment should host Pipelines? Pipelines lives in one env per tenant; pipelines, stages, and run history are stored there. Source envs deploy through it."* Top-level options: **1.** "Provision a Platform Host (recommended) — Microsoft-managed Dataverse env auto-provisioned in your tenant home geo. Pipelines app pre-installed. Idempotent. ~3–5 min." **2.** "Set up a Custom Host — Pipelines lives in a Dataverse env you control. We'll ask whether to use an existing env or create a brand-new dedicated one." **3.** "Open PPAC and create one manually (admin fallback)." **4.** "Switch to Manual export/import strategy." **5.** "Cancel." When the user picks Option 2, surface the **Custom-Host sub-prompt**: build the eligible-env list from `HOST_RESOLUTION.candidates.eligibleForAppInstall[]` with role labels (`dev env`, `source env`, `staging env`, `production env`) per origin match; cap the visible list at 5 envs with role-aware ranking (see "Eligible-env presentation cap" below). Sub-options: **a.** Each visible env (display name + URL + role labels) labeled "*Install Pipelines app on this env*" — sandbox-sku envs add a "(Sandbox — confirmation gate)" suffix; append "Other (paste URL)" as the last per-env entry. **b.** "Create a brand-new dedicated env (D365_ProjectHost template, ~5–10 min, requires Power Platform admin)." **c.** "Back — return to host-type menu." When the eligible list is empty, drop sub-option `a` and present only `b` / `c`. | Option 1 (Platform Host) → `HOST_ENV_URL = null`, `WILL_PROVISION_PLATFORM = true`, `WILL_ENSURE_HOST = true`. Option 2 → sub-prompt; sub-option `a` picked env → `HOST_ENV_URL = picked.instanceApiUrl`, `CHOSEN_ENV_URL = picked.instanceApiUrl`, `WILL_ENSURE_HOST = true` (Sandbox confirmation gate, if applicable, must be passed before this resolution stands; "Other (paste URL)" → ask for the env URL via free-text, then proceed as a picked eligible env); sub-option `b` → `HOST_ENV_URL = null`, `WILL_PROVISION_CUSTOM = true`, `WILL_ENSURE_HOST = true`; sub-option `c` → re-show top-level menu. Option 3 (PPAC) → `HOST_ENV_URL = null`, `WILL_USE_PPAC = true`, `WILL_ENSURE_HOST = true`. Option 4 (Manual strategy) → restart Phase 2 with `STRATEGY = manual`. Option 5 → exit. |448| `CannotRedirect` | **Block.** Show the org-setting vs tenant-default mismatch error from `HOST_RESOLUTION.candidates`/`warnings` and stop the skill — only a Power Platform admin can resolve. | Exit with the specific error. |449| `OrgSettingStale`, `PermissionDenied`, `DetectionFailed` | Surface the error; ask the user to enter the host URL manually with `pac env list` pre-fill (today's fallback). Pre-fill options from `ENV_LIST` (up to 3 known environment URLs) plus "Other" for a custom URL; pre-fill first option from `docs/alm/last-pipeline.json` if present. | `HOST_ENV_URL = user-supplied`. |450451Store the resulting `HOST_ENV_URL` for use by the rest of plan-alm. The auxiliary flags `CHOSEN_ENV_URL`, `WILL_PROVISION_PLATFORM`, `WILL_PROVISION_CUSTOM`, `WILL_USE_PPAC`, `WILL_ENSURE_HOST`, and `USER_CHOSE_DEFER_TO_SETUP_PIPELINE` feed the planData `hostResolution` block in Phase 3 and the inline summary in Phase 4. ensure-pipelines-host reads `chosenEnvUrl`, `willProvisionPlatform`, `willProvisionCustom`, and `willUsePpac` from that block to bypass its own Phase 3.C menu when the user has already made the choice here.452453**Eligible-env presentation cap** (used by the `NoHost` row above and by `MultipleUnboundCustomHosts`). When the eligible list runs long, build the visible options as follows so the prompt stays scannable:4544551. **Always-visible role-labeled envs first.** Include any eligible env carrying a `dev env`, `source env`, `staging env`, or `production env` label (matched by URL origin against `devEnvUrl` and against `PP_STAGES[].envUrl`). These are the project's own envs and are nearly always the right pick. Dedupe by origin.4562. **Fill remaining slots up to 5** from the rest of the eligible list, in the order returned by `list-tenant-envs.js` (name-hint pattern `pipeline|deploy|host|alm|cicd|govern` → admin-perms → recency).4573. **Append "Other (paste URL)"** as the last per-env entry inside option 1's nested list — escape hatch for envs that didn't make the cap.4584. When `eligible.length > 5`, suffix option 1's headline with: ` Showing top 5 of {N}; the remaining {N-5} eligible env(s) can be reached via the "Other (paste URL)" entry.` When `eligible.length <= 5`, no suffix (all envs visible inline).4595. When the user picks "Other (paste URL)", pre-fill the URL input with `ENV_LIST` (the `pac env list --output json` output gathered in Phase 1) s460461…(truncated)