关于 plan-alm
plan-alm 是面向 Power Pages 团队的 ALM 规划技能,用于制定解决方案晋级方式。它检测项目状态、收集策略选择并生成待审批的 HTML 计划;不会执行部署。
主要功能
- 01检测站点、解决方案和管道状态
- 02收集晋级策略选择
- 03生成 HTML 部署计划
- 04请求审批并保存计划状态
- 05将规划与执行分开
适用场景
- 01选择 Power Platform Pipelines 或手动迁移
- 02部署前审阅建议的 ALM 步骤
- 03无需重新规划即可审批已保存的草稿
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ microsoft/ power-platform-skills --skill plan-alm安装后在 Claude Code 中输入/plan-alm确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
assets/alm-plan-template.htmlscripts/render-alm-plan.jsvalidate-plan-alm.js详细介绍
插件检查:运行
node "${PLUGIN_ROOT}/scripts/check-version.js"— 如果输出消息,请在继续之前向用户显示。
plan-alm
一个分为 4 个阶段的规划器,用于收集用户的 ALM 策略、生成 HTML 部署计划并获取批准。它不会执行任何操作 — 执行工作由各个 ALM 技能负责,用户之后运行这些技能。
概述
此技能会检测当前项目状态(现有解决方案、管道),针对所需的推广策略(Power Platform Pipelines 或手动导出/导入)提出有针对性的问题,生成可视化的 docs/alm-plan.html,并获取用户批准。四个阶段分别是:阶段 1 — 检测、阶段 2 — 收集策略、阶段 3 — 生成计划、阶段 4 — 批准并保存。
plan-alm 从不部署。 计划的 steps[] 数组记录的是建议的执行顺序。批准后,用户按顺序调用各个技能 — setup-solution、setup-pipeline(或 export-solution),以及 deploy-pipeline(或 import-solution)。这些技能会通过其阶段 0 门控检测已批准的计划,继续执行而不会再次催促用户,并在完成时刷新计划。这种分离是有意为之:它让 plan-alm 可以安全地在无人值守的情况下运行(例如在自动驾驶模式下),因为任何单个回答都不会触发不可逆的部署。
请勿在开始时创建任务 — 在阶段 2 完成之前,策略尚未确定。策略确定后,在阶段 3 创建两个任务。
阶段 1 — 检测项目状态
暂时不要创建任务。 在此阶段仅使用自然语言报告进度。
步骤:
-
检测此项目是否曾推迟 ALM。 在进行任何发现工作之前,检查项目根目录是否包含
.alm-deferred标记文件。用户明确选择不让 ALM 技能验证器针对“缺少构件”发出警告时(例如 “此网站单独处理” 或 “ni-dev — 不需要 ALM”),会写入该标记。如果用户现在调用plan-alm,我们应告知其标记存在并询问如何处理,而不是默默继续(这会生成用户此前决定不维护的计划),也不应默默删除标记(这会让其他每个 ALM 技能重新开始发出提醒)。node "${PLUGIN_ROOT}/scripts/lib/check-alm-plan.js" --projectRoot "." --no-heartbeat在此处使用
--no-heartbeat:这是规划器进行的只读延期检查,不是执行技能的阶段 0 门控。如果不使用此参数,check-alm-plan.js会仅仅因为你重新打开了plan-alm,就将已处于Approved状态的计划升级为In Execution(并刷新心跳)——但重新规划并不等于执行。执行技能调用时不使用--no-heartbeat,因此第一个运行的技能会执行Approved → In Execution升级。🚦 门控(进度 · plan-alm:1.deferral): 存在
.alm-deferred标记 — 继续并删除标记、继续并保留标记,或取消。此选择决定下游 ALM 技能是否恢复门控检查。此辅助程序会返回
{ deferred, deferral, ... }。如果deferred === true,请读取延期原因(deferral.reason或标记的原始文本),并通过AskUserQuestion询问:“此项目存在
.alm-deferred标记 —{reason}。此前已在此处推迟 ALM,因此其他 ALM 技能(setup-solution、setup-pipeline、deploy-pipeline、……)会跳过对此项目的计划完整性检查。你希望如何继续?”问题 标题 选项 你希望如何继续? ALM 延期标记 继续规划并删除标记(推荐)、继续规划但保留标记(在计划中记录延期背景)、取消 - 继续并移除标记(推荐) → 删除
.alm-deferred(用户正在重新参与 ALM)。设置DEFERRAL_CLEARED = true,并继续执行步骤 1。 - 继续并保留标记 → 设置
DEFERRAL_PRESERVED = true和DEFERRAL_REASON = {reason}。继续执行步骤 1。在 Phase 1 步骤 9 的用户报告中显示一行提示(例如 “注意:已保留.alm-deferred— 其他 ALM 技能仍会跳过此项目的计划完整性检查。”),提醒用户规划完成后该标记仍然有效。 - 取消 → 正常退出(不要动此标记)。
如果
deferred === false,则静默跳过此步骤并继续执行步骤 0b。 - 继续并移除标记(推荐) → 删除
0b. 提供直接批准现有 Draft 的选项(跳过重新规划)。 步骤 0 中的同一份 check-alm-plan.js 输出也包含 exists 和 planStatus。当 exists === true 且 planStatus === "Draft" 时,用户已经保存了 Draft 计划 — 提供直接批准该计划的选项,而不是重新生成整个计划。(这是 Draft → Approved 的唯一途径;否则,批准 Draft 意味着完整地重新规划。)
🚦 门槛(plan · plan-alm:1.approve-draft): 找到现有的 Draft 计划 — 可直接批准(无需重新规划)、从头重新规划或取消。此处批准会通过
set-plan-status.js写入状态,然后退出,不会重新运行发现流程;不会触发部署。
通过 AskUserQuestion 询问:
“此网站已保存一份状态为 Draft 的 ALM 计划(
docs/alm-plan.html)。你想怎么做?”
| 问题 | 标题 | 选项 |
|---|---|---|
| 你想怎么做? | 现有 Draft 计划 | 立即批准此 Draft — 无需重新规划(推荐),从头重新规划,取消 |
-
立即批准此 Draft(推荐) → 使用 Phase 4 批准者采集流程获取批准者信息(始终交互式询问,并预填 git/操作系统名称),然后使用辅助程序以原子方式写入状态:
node "${PLUGIN_ROOT}/scripts/lib/set-plan-status.js" --projectRoot "." --status Approved --approver "{APPROVER}" --render提交(
git add docs/alm-plan.html docs/.alm-plan-data.json && git commit -m "Approve ALM plan for {siteName}"),运行技能跟踪(Phase 4 收尾),打印 Phase 4 后续步骤指南,然后退出。不要继续执行步骤 1 — 无需重新规划。 -
从头重新规划 → 继续执行步骤 1(Phase 1 的其余部分会重新生成计划;Phase 4 会保存新版本)。
-
取消 → 正常退出(保持 Draft 原样)。
如果 exists === false,或 planStatus 不是 "Draft"(Approved / In Execution / Completed / null),则静默跳过此步骤并继续执行步骤 1。
-
从本地项目确定网站身份。
.powerpages-site/website.yml是websiteRecordId和siteName的真实信息来源,且两种 Power Pages 网站类型中都存在:- Code / SPA 网站 — 由
/power-pages:create-site搭建,并使用pac pages download-code-site下载。这类网站也包含powerpages.config.json和 SPA 源代码(src/,构建输出位于dist//build/)。 - 数据模型网站(标准和增强数据模型 / “EDM”) — 使用
pac pages download --modelVersion 1|2下载。这类网站没有powerpages.config.json;.powerpages-site/中存放配置树(web-pages/、web-templates/、content-snippets/等)以及.powerpages-site/.portalconfig/清单文件对。这类网站没有本地构建输出。 解析顺序(首个匹配项优先):
.powerpages-site/website.yml(首选,每个已下载/部署的网站都包含此文件)——使用Read工具读取并提取:id字段 →websiteRecordIdname字段 →siteName(文件使用简短键;键名是name:,不是adx_name:)
powerpages.config.json(备用,仅适用于 SPA 网站;在此仓库根目录进行插件开发时,或为尚未部署的网站搭建脚手架时使用)——读取siteName和websiteRecordId。
确定
SITE_TYPE(记录在 planData 中,作为siteType使用,用于跳过下文中仅适用于 SPA 的假设;它是docs/.alm-plan-data.json中的数据字段,不会渲染到 HTML 中):- 如果
.powerpages-site/.portalconfig/存在,或已通过.powerpages-site/website.yml解析出网站且不存在powerpages.config.json,则为non-spa。(重命名前编写的计划可能仍包含旧值declarative或data-model,它们含义相同。) - 如果存在
powerpages.config.json,则为spa。(重命名前编写的计划可能仍包含旧值code,含义相同。)
如果未找到任何标记,则停止并提示:
"No Power Pages site found in the current directory. Run this skill from your site project root — that's where
.powerpages-site/lives afterpac pages download-code-site(SPA site) orpac pages download --modelVersion 2(enhanced data-model site). If you haven't created the site yet, run/power-pages:create-sitefirst."environmentUrl始终会在步骤 4 中通过pac env who重新确认;无需从上述任一来源获取。 - Code / SPA 网站 — 由
-
检查项目根目录中是否存在
.solution-manifest.json:- 如果找到,存储
SOLUTION_DONE = true;否则存储false - 如果找到,读取
solution.uniqueName并存储为SOLUTION_UNIQUE_NAME
- 如果找到,存储
-
检查项目根目录中是否存在
docs/alm/last-pipeline.json:- 如果找到,存储
PIPELINE_DONE = true;否则存储false - 如果找到,读取
pipelineName和stages[],供后续使用
- 如果找到,存储
-
静默运行:
pac env who捕获环境 URL 和显示名称。分别存储为
DEV_ENV_URL和DEV_ENV_NAME。URL 标签因 PAC 版本而异:当前版本 PAC(2.8.x)会在Org URL:下方打印 URL;旧版本使用Environment URL:——读取实际出现的标签(2.8.x 中没有Environment URL:行,因此不要只查找该标签)。显示名称是Friendly Name:/Connected to...的值。如果无法可靠解析,则将DEV_ENV_URL留空——步骤 6 中的verify-alm-prerequisites.js会通过共享的getEnvironmentUrl()辅助函数,从pac env who解析权威 URL(该函数可匹配这两种标签),并将其作为.envUrl返回。 -
静默运行:
node "${PLUGIN_ROOT}/scripts/lib/list-environments.js"将 JSON 数组存储为
ENV_LIST,用于在阶段 2 中预填环境 URL。(此辅助程序会解析pac env list;旧命令pac env list --output json在当前 PAC CLI 中无效——pac env list仅接受--filter——因此需要此辅助程序来生成表单所需的 JSON。若 PAC 未通过身份验证,它会打印[]并以状态码 0 退出,因此预填功能会退化为手动输入。)每个条目格式为{ displayName, environmentId, environmentUrl, uniqueName, active }。 -
获取开发环境令牌(静默执行):
node "${PLUGIN_ROOT}/scripts/lib/verify-alm-prerequisites.js" --envUrl "{DEV_ENV_URL}"将
.token存储为DEV_TOKEN,将.userId存储为userId。跟踪计划质量。 在阶段 1 开始时,将
PLAN_QUALITY累加器初始化为"complete"。如果令牌获取失败(身份验证错误),则设置DEV_TOKEN = null,将PLAN_QUALITY设为"degraded",并记录原因(例如 "开发环境身份验证失败——已跳过内容/大小/主机发现"),然后继续。此时会妥善跳过内容发现,但最终计划将基于不完整的输入;阶段 3 会将此情况作为显著风险提示,以便用户在执行前进行检查。(此处无需阻止执行——plan-alm只负责规划——但必须明确标记降级计划。) 6b. 环境匹配检查 — 在运行发现之前,确认pac env who指向项目的环境。DEV_ENV_URL取自 PAC 当前连接的环境,不一定是项目的环境。如果不是,步骤 7–12 中的每个查询都会针对错误的环境运行,并悄然生成质量下降的计划(站点设置为零或错误、大小错误、主机错误),但看起来有效。交叉核对以下两项可用信号:- 已记录 URL 比对(无需令牌):收集项目已记录的任何环境 URL —
powerpages.config.json→ 顶层environmentUrl(SPA 站点;非 SPA/EDM 站点中不存在)以及.solution-manifest.json→ 顶层environmentUrl(如果存在)。按源规范化(主机名转为小写,去掉尾随斜杠及路径/查询)。如果存在任何已记录的 URL,且其源与DEV_ENV_URL的源不同 → 不匹配。 - 站点存在性探测(适用于未记录 URL 的非 SPA/EDM 站点;仅当
DEV_TOKEN可用时):验证已连接环境中是否确实存在该站点的websiteRecordId:GET {DEV_ENV_URL}/api/data/v9.2/powerpagesites({websiteRecordId})?$select=powerpagesiteid Authorization: Bearer {DEV_TOKEN}404(或空结果)表示已连接的环境中不存在此站点 → 不匹配。(当DEV_TOKEN = null时跳过此探测 — 步骤 6 已将计划降级;不要重复提示。)
如果两个信号都未显示不匹配,则静默继续到步骤 7 — 不要提示。仅在检测到不匹配时提示:
🚦 关卡(进度 · plan-alm:1.env-match): PAC CLI 当前连接的环境与项目不匹配。请切换环境并重新运行,或继续使用当前连接的环境(计划将降级)。
通过
AskUserQuestion提问:问题 标题 选项 PAC CLI 当前连接到 {DEV_ENV_NAME}( {DEV_ENV_URL}),与此项目配置的环境不匹配({recorded URL, or "this site was not found there"})。发现操作将针对当前连接的环境运行。你要如何继续?环境不匹配 切换 PAC 环境并重新运行(推荐),仍要继续使用 {DEV_ENV_NAME} 仅有两种结果(均为停止或继续;没有单独的“取消”选项 — “切换并重新运行”会直接停止此技能):
- 切换 PAC 环境并重新运行(推荐):停止此技能。告知用户将 PAC 指向正确的环境(
pac auth select --name <profile>或pac org select --environment <url>),然后重新运行/power-pages:plan-alm。尚未写入任何内容。 - 仍要继续使用 {DEV_ENV_NAME}:针对
DEV_ENV_URL继续执行步骤 7,但设置PLAN_QUALITY = "degraded",并记录原因("发现操作针对 {DEV_ENV_NAME} 运行,该环境可能不是项目的环境 — 执行前请核实计划中的站点设置 / 大小 / 主机"),以便第 3 阶段将其作为显著风险呈现。
为何需要此检查:一次真实的 EDM 站点运行中,PAC 悄然保持连接到与项目目标环境不同的环境,却生成了看似有效的计划。站点存在性探测与已记录 URL 比对会在最早的关卡捕获此问题,避免任何发现操作运行。
- 已记录 URL 比对(无需令牌):收集项目已记录的任何环境 URL —
-
发现并分类站点设置(如果
DEV_TOKEN可用且已知websiteRecordId):使用 Node.js
https模块进行查询。通过@odata.nextLink分页 — 否则,设置超过 500 个的站点会被悄然截断,导致层级分类丢失,并低估plannedEnvVarCount。发送Prefer: odata.maxpagesize=5000,让 Dataverse 返回延续链接,然后循环处理,直至全部取完: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收到每个响应时,将
value[]追加到累积数组中。如果存在@odata.nextLink,则使用相同的标头对该 URL 发起 GET(无需重新添加筛选条件 — nextLink 已包含查询)。当响应中不再包含@odata.nextLink时停止。为安全起见,最多循环 100 次。 使用${PLUGIN_ROOT}/scripts/lib/classify-site-settings.js对返回的设置进行分类——它是与setup-solution第 5 阶段共享的凭据正则和层级映射的唯一事实来源。可以将{name, value}行的 JSON 数组通过管道传入脚本的 stdin(命令行模式),也可以内联require()它:echo '<JSON array of {name,value}>' \ | node "${PLUGIN_ROOT}/scripts/lib/classify-site-settings.js"输出(下游各阶段 +
setup-solution直接消费的四桶结构):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 1(
credentialNeedsDecision)——凭据风格的名称。Setup-solution 第 5.4.C 阶段会运行一次批量提示:按名称自动分类(*Secret/*Password/*ApiKey/*AppKey使用 Secret 类型的环境变量;*Id/*ConsumerKey使用 String 类型)、全部设为 Secret、全部设为 String、全部跳过,或逐个凭据选择。 - Tier 2a(
promoteToEnvVar)——带有开发值的 auth 配置。Setup-solution 第 5.4.A 阶段会询问哪些要用环境变量支撑,以便每个阶段可以使用不同的值。 - Tier 2b(
authNoValue)——尚无开发值的 auth 配置。按原样添加到解决方案中;用户在部署后于每个目标环境中设置该值。 - Tier 3(
keepAsIs)——其他所有内容。原样添加。
如果 OData 查询失败或辅助脚本报错,请设置
SITE_SETTINGS_DATA = null并继续——计划仍会渲染,只是无法按层级细分站点设置。 - Tier 1(
-
构建
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 }如果
SOLUTION_DONE = false且清单不存在,tables和botComponents将是空数组——计划会显示一条说明,指出它们将在 setup-solution 期间被发现。 -
向用户报告:
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}. -
估算解决方案大小并评估拆分决策树。 首先确保 ALM 工件目录存在(所有
.alm-*和last-*工件都存放在docs/alm/下,以保持项目根目录整洁):node -e "require('fs').mkdirSync('docs/alm',{recursive:true})"运行估算辅助脚本来按大小、组件数量、schema 重量、Web 文件总量和环境变量数量对站点进行分类。使用临时文件写入模式——如果估算器失败,先前有效的
docs/alm/alm-size-estimate.json会被保留,而不会被空文件或不完整文件覆盖。当SOLUTION_DONE = true(存在.solution-manifest.json)时,传入--solutionId {solutionId},以便环境变量数量限定在目标解决方案范围内——否则,估算器会回退到按发布者前缀进行租户级查询,只要该前缀在多个项目间共享就会多计(常见的new_/cr5fe_回归):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当
SOLUTION_DONE = false时,省略--solutionId;估算器的输出将包含envVarCountScope: "publisher-prefix"以表明范围更广,渲染器会在 Env Variables 标签页中呈现这一注意事项,以便评审者知道该数字反映的是租户视图,而非特定解决方案。--projectRoot "."会启用磁盘交叉校验——估算器会遍历本地构建输出(dist/、public-output/、build/、.output/)并给出webFilesDiskMeasuredMB。当该数字远大于 Dataverse 测得的webFilesAggregateMB时,估算器会将truncationSuspected: true置位并给出警告——其字节未被$select=content返回的文件类型列是常见原因,此时计划应信任磁盘上的数字。
SITE_TYPE = "non-spa"(EDM/标准数据模型)站点没有构建输出,因此磁盘交叉检查找不到dist//build/目录,webFilesDiskMeasuredMB会保持为null——这是预期行为,不是问题。非 SPA 站点的 Web 文件以记录形式存储在.powerpages-site/web-files/下,并通过 Dataverse 查询测量,因此大小估算仍然有效;只是磁盘上没有 SPA 包可供交叉检查。无论如何都传入--projectRoot "."——对这些站点而言,这不会产生任何影响。 然后运行决策树(使用相同的临时文件模式):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如果任一命令以非零状态退出,请停止并向用户报告 stderr 消息。没有有效的拆分计划时,不要继续执行第 2 阶段的 Q1b。 将输出存储为SPLIT_PLAN。需读取的字段:splitStrategy、proposedSolutions[]、appliedStrategies[]、assetAdvisory、sizeAnalysis、recommendations[]。
如果 `SPLIT_PLAN.proposedSolutions.length > 1`,则设置 `RECOMMEND_SPLIT = true`。否则设置为 `false`。
向用户报告:
```
估算大小:{totalSizeMB} MB — 组件:{count} — 层级:{overall tier}。
表:{tableCount} — 按站点的表权限范围统计({tableCountScope})。
决策树结果:{splitStrategy} → 建议拆分为 {N} 个解决方案。
资源建议:已标记 {K} 个文件,建议外部化到 Azure Blob。
```
> **表数量按站点引用范围统计,而非按发布者前缀统计。** 估算器会根据站点实际引用的表(其表权限 + 数据模型清单)统计自定义表,因此共享/默认发布者(`new_`)不会再导致数量虚高。`tableCountScope` 会说明统计范围:`site-referenced`(表权限)、`manifest-only` 或 `unavailable`(没有本地 `.powerpages-site/` 信号——表数量为 0,绝不会导出整个环境的所有表)。如果为 `unavailable`,请注明已跳过基于表数量的拆分信号。估算命令已传入 `--projectRoot "."`,该参数会提供本地表权限信息。
10b. 枚举环境变量定义(只要 DEV_TOKEN 可用就运行——大小估算器会提供数量,但不会提供各变量的元数据)。
渲染器的 Env Variables 选项卡需要每个定义的架构名称、类型、默认值和绑定的站点设置。如果不执行此步骤,该选项卡只能显示数量摘要说明,而大小信号和警告引用的却是一个数字——这三处信息无法完全一致。运行此查询会生成逐行数据,以便正确呈现表格。
当 `SOLUTION_DONE = true` 时传入 `--solutionId`,这样返回的 envVars[] 就会限定在目标解决方案范围内。无论是否指定范围,助手都会正确处理分页(Prefer: odata.maxpagesize + @odata.nextLink)——差别仅在于返回哪些环境变量。
```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
```
读取 JSON:`{ envVars: [{ schemaName, type, defaultValue, siteSetting }], count, scope }`。如果传入了 `--solutionId` 且解决方案包含环境变量定义,`scope` 字段为 `'solution'`;否则为 `'publisher-prefix'`;如果助手提前结束(没有前缀或认证失效),则为 `'none'`。将 `envVars` 存储为 `ENV_VARS_DETAILS`,将 `scope` 存储为 `ENV_VARS_SCOPE`,以便在第 3 阶段构建 `planData.envVars` 时使用(原样传递)。
当发布者前缀未知、令牌已过期或查询出错时,辅助脚本会优雅降级(返回 `{ envVars: [], count: 0, scope: 'none' }`)。在这些情况下,渲染器会使用大小估算器的计数作为回退,即 `sizeAnalysis.envVarCount.value`(提交 `8cbc39a`)——`ENV_VARS_DETAILS = []` 可以接受。
> **为什么这里的作用域限定很重要**:如果不传入 `--solutionId`,辅助脚本会在整个租户范围内按发布者前缀筛选环境变量定义。对于使用通用前缀(`new_`、`cr5fe_`)的租户,这会返回无关项目的环境变量,导致计数和渲染器生成的 `envVars[]` 表格行数虚高。计划看起来没问题(“检测到 12 个环境变量”),但实际上显示的是其他项目的行。使用 `--solutionId` 后,辅助脚本会将结果与目标解决方案的 `solutioncomponents.componenttype=380` 求交集——只保留实际归属于该计划解决方案的环境变量。
**跳过规则**:如果 `DEV_TOKEN` 为 null(步骤 6 中无法获取身份验证),则跳过此步骤,并将 `ENV_VARS_DETAILS = []`。渲染器的计数摘要回退逻辑会覆盖这种情况。
11. 计划前完整性检查(仅在 SOLUTION_DONE = true 时运行)。
在用户批准计划之前,验证现有解决方案是否已涵盖实时站点上的所有内容。在上次运行 `/power-pages:setup-solution` 后创建的组件(来自 `add-server-logic` 的服务器逻辑、来自 `add-cloud-flow` 的流、来自 `configure-env-variables` 或 `setup-auth` 的环境变量),会被基于过期解决方案构建的计划悄然排除。
针对源环境运行共享发现辅助脚本:
```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 "."
```
解析标准输出并检查 `missing.*`:
- **所有 `missing.*` 数组均为空** → 报告“解决方案内容与站点一致——继续使用最新的计划输入。”继续执行阶段 2。
- **任一 `missing.*` 数组非空** → 报告简要摘要:
> “你的解决方案**缺少 {N} 个**站点上存在的组件:
>
> - **{X}** 个站点组件(例如 {前 3 个名称})
> - **{L}** 种站点语言(powerpagesitelanguage — 必需;缺少这些语言会导致目标站点在身份验证后无法呈现,且不会显示错误)
> - **{Y}** 个云流
> - **{Z}** 个环境变量定义
> - **{W}** 个自定义表
>
> 现在构建的计划会忽略这些组件。你想如何继续?”
即使其他类别的数量为零,只要 `missing.siteLanguages.length > 0`,也始终显示**站点语言**这一行——在 `discover-site-components` 开始枚举 `powerpagesitelanguages` 之前,这是反复出现的静默失败模式。有关 3 实体模型,请参阅 `references/solution-api-patterns.md`。
> 🚦 **关卡(进度 · plan-alm:1.completeness):**完整性检查发现与实时站点存在差异。请先同步、记录差异后继续计划,或取消。
通过 `AskUserQuestion` 询问:
| 问题 | 标题 | 选项 |
|---|---|---|
| 是否先以同步模式运行 `/power-pages:setup-solution`,在制定计划前纳入缺失的组件? | 完整性检查 | 是 — 先同步(推荐),否 — 使用当前解决方案内容制定计划,取消 |
- **是,先同步(推荐)**:调用 `/power-pages:setup-solution`(会自动检测现有清单并进入同步模式)。完成后,重新运行发现辅助脚本;如果 `missing.*` 现在为空,则继续执行阶段 2,否则重复提示。
- **否,使用当前内容制定计划**:将差异摘要存储为 `KNOWN_GAPS`,以便阶段 3 在计划 HTML 的 Risks 部分中显示,然后继续。
- **取消**:停止此技能,让用户自行调查。
> **存在原因**:同一项检查会在导出阶段(`export-solution` Phase 2.5)和部署阶段(`deploy-pipeline` Phase 3.5)运行。在此处添加检查,可在最早的关卡发现缺口,避免用户花时间审查基于过时输入制定的计划。请参阅 AGENTS.md → 默认考虑 ALM。
> **当 `SOLUTION_DONE = false` 时跳过**:如果尚无清单,就无从判断是否过时——Phase 2 Q1 会处理首次解决方案设置。
12. 运行主机解析(仅限 PP Pipelines 路径——在完整性检查后运行)。
**跳过规则:**如果 `PIPELINE_DONE = true`,则完全跳过此步骤——主机信息来自 `docs/alm/last-pipeline.json`。只有新建流水线的项目才需要解析。
获取 BAP 受众的访问令牌(BAP API 使用的受众与 Dataverse 不同):
```bash
az account get-access-token --resource "https://service.powerapps.com/" --query accessToken -o tsv
```
将输出保存为 `BAP_TOKEN`。如果获取失败,则设置 `HOST_RESOLUTION = { status: 'DetectionFailed', error: '<stderr>' }`,并跳过检测调用。
运行仅检测包装器。使用与 Phase 1 步骤 10 相同的“先写入临时文件,再用 mv 替换”模式,这样即使脚本在写入过程中失败,也能保留之前有效的 `docs/alm/alm-host-resolution.json`。传入 `--skus Production,Sandbox,Trial`,使使用试用许可证的租户和开发人员租户能在以环境优先的菜单中看到其符合条件的环境(帮助程序默认值为 `Production,Sandbox`;此处将其扩展为包括 Trial,因为 plan-alm 的 NoHost 分支始终提供使用现有环境安装的路径,Trial 环境可以使用此路径,尽管它们不能使用快速创建新环境路径):
```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
```
> **注意**:`ensure-pipelines-host-detect.js` 是供编排器使用的 `ensure-pipelines-host` 技能提供的**仅检测包装器**。它会运行该工作流的 Phase 1.0(缓存快速路径)+ Phase 2(包括租户范围枚举在内的解析顺序)+ Phase 5(找到主机时进行验证),但绝不会进入 Phase 3(决策树)或 Phase 4(预配)。输出符合 `docs/alm/last-host-check.json` 的 schemaVersion 2 格式,且 `actionTaken: "none"` 始终不变。
**失败处理:**如果检测脚本以非零状态退出,则设置 `HOST_RESOLUTION = { status: 'DetectionFailed', error: '<stderr>' }` 并继续。Phase 2 Q4 会回退到当前的“手动输入 URL”分支。
成功时,解析 `docs/alm/alm-host-resolution.json` 并保存为 `HOST_RESOLUTION`(将包装器的字段名称映射为 plan-alm 使用的格式):
```js
HOST_RESOLUTION = {
status: parsed.resolutionStatus, // 以下其中之一: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[] }
}
```
输出一行:
```
Pipeline host: {finalHostEnvUrl} ({status})
```
或者,如果尚未设置 URL:
```
Pipeline host: will be ensured during setup-pipeline ({status})
```
阶段 2 — 收集 ALM 策略
按顺序提问。Solution 始终是 Q1——这是所有其他步骤的前置条件。根据 Q2 中选择的推广策略进行分支。
记录每个重要决定。 做出下列每项决定时(Q1 solution、Q1b 拆分/覆盖、Q2 策略、Q3 阶段/目标、Q4 主机、Q5 审批模式、Q5 手动导出类型),将 { field, value, source } 追加到 DECISIONS_LOG 数组中:当用户接受建议值/自动值且未主动更改时,source = "default";当用户选择非默认选项时,source = "explicit"。阶段 4 会根据此日志生成 “默认决定(请审核)” 部分,让审核者一目了然地看到哪些选择是默认值,哪些是有意选择的(弥补 “我从未同意托管导出” 这一信息缺口)。这不会增加新的提示词,只会记录现有提示词产生的结果。
Q1 — Solution 设置(始终首先询问)
如果 SOLUTION_DONE = true(在阶段 1 中找到清单):
🚦 关卡(计划 · plan-alm:2.q1-existing): 找到现有 solution — 重用它(跳过 setup-solution)或新建一个(运行 setup-solution)。
通过 AskUserQuestion 询问:
"此站点已配置 Dataverse solution:{SOLUTION_UNIQUE_NAME}。使用此现有 solution 吗?"
选项:
- 是,使用现有 solution — 计划中将跳过
setup-solution - 否,创建新的 solution — 设置
SOLUTION_DONE = false;将运行setup-solution
如果 SOLUTION_DONE = false(未找到清单):
告知用户(不要通过 AskUserQuestion——仅提供信息):
"此站点尚未设置 Dataverse solution。
setup-solution将是计划中的第一步。 设置期间选择的发布者前缀不可撤销——请谨慎选择。"
🚦 关卡(计划 · plan-alm:2.q1-fresh): 没有现有 solution — 在计划中包含 setup-solution,或接受用户提供的唯一名称。
通过 AskUserQuestion 询问:
"是否准备在计划中包含 solution 设置?"
选项:
- 是,包含 solution 设置 — 继续
- 我已有 solution(输入名称) — 接受自由文本形式的 solution 唯一名称,设置
SOLUTION_DONE = true、SOLUTION_UNIQUE_NAME = user input
Q1b — 拆分建议(仅当 RECOMMEND_SPLIT = true 时)
🚦 Gate(计划 · plan-alm:2.q1b-split): 遵循推荐的拆分策略、改为单个解决方案、先接受资产建议,或查看迁移指南。
Phase 1 Step 10 的决策树建议拆分为多个解决方案。通过 AskUserQuestion 询问:
“根据站点规模和组件分析,推荐的方案是 {splitStrategy} — 使用 {N} 个解决方案,而不是一个。您想遵循此建议吗?”
选项:
- 使用推荐的拆分方案 — 使用决策树生成的
proposedSolutions[]继续。setup-solution将创建全部 N 个解决方案。 - 仍然保留为单个解决方案 — 覆盖为单个解决方案。记录覆盖原因;
setup-solution将创建一个包含所有组件的解决方案。 - 先接受资产建议(仅当
assetAdvisory.candidates.length > 0时提供)— 用户承诺将标记的资产外置。排除这些文件后重新计算大小,重新运行决策树,并展示新的建议。 - 查看迁移指南(仅当找到现有
.solution-manifest.json且其内容与建议不匹配时提供)— 生成docs/alm-migration-plan.md并退出。不要执行。
如果选择选项 1: 使用 proposedSolutions 继续。
如果选择选项 2 — 仍然保留为单个解决方案: 这会覆盖一项通常正确的数据驱动建议。在接受覆盖之前,再次展示层级信号,确保用户是在知情的情况下做出选择,而不是一键略过。 从 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): 覆盖数据驱动的拆分建议,保留为单个解决方案。选择 Yes 后,需填写自由文本
overrideReason。
然后通过 AskUserQuestion 询问:
| 问题 | 标题 | 选项 |
|---|---|---|
| 仍要保留为单个解决方案吗? | 确认覆盖 | No — 使用推荐的 {splitStrategy} 拆分方案(推荐),Yes — 仍然覆盖并说明原因,Cancel — 重新考虑策略 |
- No → 转到选项 1(使用推荐的拆分方案)。
- Yes → 通过后续的
AskUserQuestion(“简要说明:为什么单个解决方案适合此站点?”)要求用户填写自由文本overrideReason。在计划中记录overrideReason和overrideConfirmedSignals(上方展示的分层级信号)。仅在此之后,才将SPLIT_PLAN.proposedSolutions覆盖为用于渲染的单个解决方案结构。 - Cancel → 返回 Q1b 开头。
为何要增加这一步: 根据实地反馈的会话记录,“仍然保留为单个解决方案”曾是一键覆盖选项,结果成了导致建议错误的最常见途径。再次确认并非为了劝用户放弃,而是为了确保覆盖是在知情的情况下做出的,并记录原因以供审计。完全接受并记录原因的覆盖;此 Gate 只阻止用户在未确认的情况下直接略过。
如果选择选项 3: 从估算值中扣除建议外置的候选资产大小,重新运行 compute-split-plan.js,并再次展示建议。
如果选择选项 4: 编写 docs/alm-migration-plan.md(参见规范文档 solution-splitting-logic.md §7),提交,将计划标记为 Deferred,然后退出。
Q2 — 策略选择(始终询问)
🚦 关卡(plan · plan-alm:2.q2-strategy): 选择推广策略 — PP Pipelines(推荐)、手动导出/导入、现有管道,或帮我决定。后续计划将据此分支。
推荐说明(内联显示在提示正文中)。 在列出选项前,先展示推荐方案及影响推荐的状态,让用户一开始就能看到,而不必深入查看选项 4:
推荐:Power Platform Pipelines,适用于持续 CI/CD — 在一个位置实现自动推广、审批关卡和部署历史。手动导出/导入仅适用于一次性迁移;每次更改都重复此操作会丢失审计跟踪,并会悄然引入版本不一致问题。
(条件内容,当
HOST_RESOLUTION.resolutionStatus∈ {AvailableUsingCustomHost,AvailableUsingCustomHostByAdminDefault,AvailableUsingPlatformHost,AvailableUnboundCustomHost} 时追加): 你的租户中已存在一个 Pipelines 主机:{HOST_RESOLUTION.finalHostEnvUrl}(Pipelines v{HOST_RESOLUTION.pipelinesSolutionVersion})。PP Pipelines 无需为此项目新增基础设施。
通过 AskUserQuestion 询问:
“你希望如何在不同环境之间推广解决方案?”
选项:
- Power Platform Pipelines(推荐用于持续 CI/CD) — Microsoft 原生 CI/CD、托管部署、审批关卡
- 手动导出/导入(仅限一次性迁移) — 从开发环境导出 zip,并直接导入到每个目标环境
- 我已经设置好管道 — 立即运行部署
- 帮我决定 — 再次显示完整对比
将所选项记录到 DECISIONS_LOG({ field: "strategy", value, source })。
如果选择了选项 4: 重新打印上述推荐说明,然后重新询问 Q2,仅保留选项 1–3。
如果选择了选项 3: 读取 docs/alm/last-pipeline.json,确认管道名称和阶段,然后跳至阶段 3(生成计划),并设置 strategy = pp-pipelines、PIPELINE_DONE = true。
PP Pipelines 路径 — Q3 至 Q6
🚦 关卡(plan · plan-alm:2.q3-stages): 选择部署阶段数量 — 仅 Staging / +Production / 直接部署到 Production / 自定义。
Q3: 通过 AskUserQuestion 询问:
“你希望此管道包含多少个部署阶段?”
选项:
- 仅 Staging — Dev → Staging(稍后我会添加 Production)
- Staging + Production — Dev → Staging → Production(完整推广链)
- 直接部署到 Production — 仅 Dev → Production(跳过 Staging)
- 自定义 — 我会描述自己的阶段布局
如果选择选项 4:接受自由文本描述(通过“其他”),并根据回复构建阶段列表。
将阶段存为 PP_STAGES({ label, envUrl, envName, type } 数组)。Dev 始终为源环境。
对于每个阶段,从 ENV_LIST 中填充 envName(在阶段 1 步骤 5 中通过 list-environments.js 收集)。按 URL 源站匹配(转为小写、去除末尾斜杠、忽略路径/查询),与每个条目的 environmentUrl 比较,并将条目的 displayName 复制到 envName。如果未找到匹配项(通常是因为用户通过“其他”粘贴了自定义 URL),则不设置 envName;渲染器会回退为仅在阶段卡片中显示 URL。渲染器会将 envName 放在阶段标签和 URL 之间(例如 Staging / Supplier Portal Staging / https://orgd6a9894f.crm5.dynamics.com/*),让审核人员一眼识别环境,同时保留 URL 作为一键跳转到环境的入口。为 Dev/源阶段设置 type: "source",为每个下游阶段设置 type: "target",以便渲染器正确应用当前阶段样式。
根据现有管道核对所选阶段(软警告,PP 路径)。 当 PIPELINE_DONE = true(存在 docs/alm/last-pipeline.json)时,将 PP_STAGES 中每个选定的目标阶段与该文件的 stages[] 进行比较(先按 targetEnvironmentUrl 的来源匹配,再按阶段 name 匹配)。对于现有管道中没有匹配阶段的每个选定目标,在计划的“风险”部分记录一条 { type: "warning" } 条目:"所选目标 '{label}'({envUrl})在现有管道 '{pipelineName}' 中没有匹配的阶段 — setup-pipeline 需要添加该阶段。"* 这可以捕获实际不匹配的情况,例如已保存的计划指示 Dev → Production 直接部署,但实际管道中只有一个 Deploy to Staging 阶段。此项仅供参考——不要阻止流程或再次提示;用户仍可批准计划,setup-pipeline 会在执行时协调实际阶段。
🚦 关卡(plan · plan-alm:2.q4-host): 主机环境选择 — 根据
HOST_RESOLUTION.status分支并显示相应菜单(使用检测到的环境/从列表中选择/NoHost 主机类型/Sandbox 确认/CannotRedirect 阻止/手动粘贴)。为 plan-alm 其余部分和 ensure-pipelines-host 设置HOST_ENV_URL和WILL_PROVISION_*标志。每个分支都使用AskUserQuestion。触发条件: 第 2 阶段 Q4 入口;
HOST_RESOLUTION已在第 1 阶段步骤 12 中填充。 询问原因: 自动选择主机可能会预配新的 Custom Host (WILL_PROVISION_CUSTOM),消耗用户原本无意使用的 Azure 容量配额;也可能选错环境,导致管道经过外部主机。下游的 ensure-pipelines-host 技能会信任此回答,并跳过自己的 3.C 菜单。 取消后的状态: 无 — 尚未触发预配。
Q4(主机环境 — 根据第 1 阶段步骤 12 中的 HOST_RESOLUTION.status 分支):
此问题使用第 1 阶段步骤 12 中由新的仅检测包装器运行所填充的 HOST_RESOLUTION。每个分支都会设置 HOST_ENV_URL(供 plan-alm 其余部分使用),还可能设置辅助标志 CHOSEN_ENV_URL、WILL_PROVISION_PLATFORM、WILL_PROVISION_CUSTOM、WILL_USE_PPAC、WILL_ENSURE_HOST 和 USER_CHOSE_DEFER_TO_SETUP_PIPELINE。默认值:HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl,所有标志均为 false / null。
为什么 NoHost 分支在此处显示先选环境的菜单,而不是推迟到 ensure-pipelines-host 第 3.C 阶段: 原始设计是在 plan-alm 中询问一个是非题:“我们将预配新环境 — 是否继续?”,并在执行时让 3.C 显示先选环境的选项。实际中,Agent 会把 plan-alm 中的确认视为授权,因此完全跳过 3.C(或跳过 4.A 的调用前关卡),用户随后会在 4.A 遇到 409 试用许可证错误,而使用现有环境安装(4.B)本可以顺利完成。在此处——规划时一次性向用户显示先选环境的菜单,让用户掌握完整背景——即可消除歧义。ensure-pipelines-host 随后会信任 CHOSEN_ENV_URL,并跳过自己的 3.C 菜单(参见 ensure-pipelines-host 第 3 阶段的跳过规则)。
status | Q4 提示词 | 结果 |
|---|---|---|
AvailableUsingCustomHost, AvailableUsingCustomHostByAdminDefault, AvailableUsingPlatformHost | “检测到主机 {finalHostEnvUrl}(Pipelines v{pipelinesSolutionVersion})。使用此主机吗?”选项:1. 是,使用此主机 / 2. 使用其他主机环境(其他) | 是 → HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl。否 → 回退到今天的“输入其他 URL”分支(通过“其他”输入自由文本)。 |
AvailableUnboundCustomHost | “在租户中找到现有的 Custom Host {displayName}({finalHostEnvUrl})——尚未绑定到开发环境。setup-pipeline 将重复使用它(推荐;可避免重复项)。使用此主机吗?”选项:1. 是,使用此主机 / 2. 使用其他主机环境(其他) | 是 → HOST_ENV_URL = HOST_RESOLUTION.finalHostEnvUrl,WILL_ENSURE_HOST = true。否 → 回退到“输入其他 URL”。 |
MultipleUnboundCustomHosts | “在租户中找到 {N} 个 Custom Host。setup-pipeline 应使用哪一个?”选项:列出 HOST_RESOLUTION.candidates.existingCustomHosts[](最多 3 个),显示名称和 URL,并提供“其他”以输入自定义 URL,以及“稍后决定 — setup-pipeline 将询问”。 | 选择候选项 → HOST_ENV_URL = candidate.instanceApiUrl,WILL_ENSURE_HOST = true。稍后决定 → HOST_ENV_URL = null,WILL_ENSURE_HOST = true,USER_CHOSE_DEFER_TO_SETUP_PIPELINE = true。 |
PlatformHostExistsUnbound | “租户 Platform Host {finalHostEnvUrl} 已存在。使用它(无需管理员角色)还是创建新的 Custom Host?”选项:1. 使用 Platform Host / 2. 创建新的 Custom Host / 3. 取消 | 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 → 退出。 |
NoHost | 主机类型提示——与 ensure-pipelines-host 第 3.C 阶段的提示形式相同,让用户在此处一次性选择主机,而不是到执行时再次询问。显示:“{devEnvUrl} 未绑定 Pipelines 主机。应由哪个环境托管 Pipelines?每个租户的 Pipelines 只存在于一个环境中;管道、阶段和运行历史记录都存储在该环境中。源环境通过此环境部署。”顶级选项:1.“预配 Platform Host(推荐)——由 Microsoft 管理的 Dataverse 环境,在租户的主地理区域中自动预配。已预装 Pipelines 应用。操作幂等。约 3–5 分钟。”2.“设置 Custom Host——Pipelines 位于你控制的 Dataverse 环境中。我们会询问你要使用现有环境还是创建全新的专用环境。”3.“打开 PPAC 并手动创建(管理员备用选项)。”4.“切换到手动导出/导入策略。”5.“取消。”用户选择选项 2 时,显示 Custom-Host 子提示:根据 HOST_RESOLUTION.candidates.eligibleForAppInstall[] 构建符合条件的环境列表,并依据来源匹配情况添加角色标签(dev env、source env、staging env、production env);最多显示 5 个环境,并按角色优先级排序(参见下文“符合条件的环境列表显示上限”)。子选项:**a.**每个可见环境(显示名称 + URL + 角色标签)标记为“在此环境上安装 Pipelines 应用”——sandbox-sku 环境添加“(Sandbox — 确认关卡)”后缀;在环境列表末尾添加“其他(粘贴 URL)”。b.“创建全新的专用环境(D365_ProjectHost 模板,约 5–10 分钟,需要 Power Platform 管理员)。”c.“返回——回到主机类型菜单。”符合条件的环境列表为空时,移除子选项 a,仅显示 b / c。 | 选项 1(Platform Host)→ HOST_ENV_URL = null,WILL_PROVISION_PLATFORM = true,WILL_ENSURE_HOST = true。选项 2 → 显示子提示;选择子选项 a 中的环境 → HOST_ENV_URL = picked.instanceApiUrl,CHOSEN_ENV_URL = picked.instanceApiUrl,WILL_ENSURE_HOST = true(如适用,必须通过 Sandbox 确认关卡,此解析才生效;选择“其他(粘贴 URL)”→ 通过自由文本询问环境 URL,然后按选择符合条件的环境继续);子选项 b → HOST_ENV_URL = null,WILL_PROVISION_CUSTOM = true,WILL_ENSURE_HOST = true;子选项 c → 重新显示顶级菜单。选项 3(PPAC)→ HOST_ENV_URL = null,WILL_USE_PPAC = true,WILL_ENSURE_HOST = true。选项 4(手动策略)→ 使用 STRATEGY = manual 重新启动第 2 阶段。选项 5 → 退出。 |
CannotRedirect | **阻止。**显示来自 HOST_RESOLUTION.candidates/warnings 的组织设置与租户默认值不匹配错误,并停止该技能——只有 Power Platform 管理员才能解决。 | 显示具体错误并退出。 |
OrgSettingStale, PermissionDenied, DetectionFailed | 显示错误;让用户使用 pac env list 预填选项,手动输入主机 URL(今天的回退方案)。预填选项取自 ENV_LIST(最多 3 个已知环境 URL),并提供“其他”以输入自定义 URL;如果存在 docs/alm/last-pipeline.json,则使用其中的第一个选项进行预填。 | HOST_ENV_URL = 用户提供的值。 |
存储生成的 HOST_ENV_URL,供 plan-alm 的其余部分使用。辅助标志 CHOSEN_ENV_URL、WILL_PROVISION_PLATFORM、WILL_PROVISION_CUSTOM、WILL_USE_PPAC、WILL_ENSURE_HOST 和 USER_CHOSE_DEFER_TO_SETUP_PIPELINE 会传入 Phase 3 的 planData hostResolution 块,以及 Phase 4 的内联摘要。ensure-pipelines-host 会从该块读取 chosenEnvUrl、willProvisionPlatform、willProvisionCustom 和 willUsePpac;如果用户已在此处做出选择,就会跳过其自身 Phase 3.C 菜单。 |
符合条件的环境展示上限(用于上方的 NoHost 行和 MultipleUnboundCustomHosts)。当符合条件的环境列表很长时,按以下方式构建可见选项,让提示易于浏览:
- 始终优先显示带角色标签的环境。 包含所有带有
dev env、source env、staging env或production env标签的符合条件的环境(通过 URL origin 与devEnvUrl以及PP_STAGES[].envUrl匹配)。这些是项目自己的环境,几乎总是正确的选择。按 origin 去重。 - 从其余符合条件的列表中补足,最多 5 个,顺序采用
list-tenant-envs.js返回的顺序(名称提示模式pipeline|deploy|host|alm|cicd|govern→ 管理员权限 → 最近使用)。 - 将“其他(粘贴 URL)” 作为选项 1 嵌套列表中的最后一个按环境列出的条目 — 为未纳入上限的环境提供备用入口。
- 当
eligible.length > 5时,在选项 1 的标题末尾追加:Showing top 5 of {N}; the remaining {N-5} eligible env(s) can be reached via the "Other (paste URL)" entry.当eligible.length <= 5时,不追加后缀(所有环境都以内联方式显示)。 - 当用户选择“其他(粘贴 URL)”时,使用
ENV_LIST预填 URL 输入框(即 Phase 1 中收集的list-environments.js输出 — 每个条目的environmentUrl),这样用户可以从完整清单中粘贴或选择,而不必手动输入 URL。
相同的上限策略也适用于 ensure-pipelines-host Phase 3.C — 相同规则请参阅该技能的 Step 3a。保持两种实现一致,确保用户无论通过 plan-alm 进入,还是直接通过 setup-pipeline → ensure-pipelines-host 进入,都能看到相同形态的提示。
🚦 Gate(plan · plan-alm:2.q5-approval): 选择审批模式 — 每个阶段都需审批 / 暂存环境自动审批 + 生产环境需审批 / 不设审批门槛。
Q5: 通过 AskUserQuestion 提问:
“部署前是否需要对每个阶段进行审批?”
选项:
- 每个阶段开始前都需要审批(建议用于生产环境)
- 暂存环境自动审批,生产环境需要审批
- 不设审批门槛 — 自动部署
存储为 PP_APPROVAL_MODE。
注意: PP Pipelines 始终以托管解决方案的形式导出到目标环境。自动设置 EXPORT_TYPE = "managed" — 无需提问。
Q6(自动检测,无需提问): 按以下顺序解析 HAS_ENV_VARS:
- 如果
ENV_VARS_DETAILS.length > 0(Phase 1 Step 10b 返回了逐变量行):HAS_ENV_VARS = true。这是最准确的信号 — 我们已直接枚举这些定义。 - 否则,如果
SPLIT_PLAN.sizeAnalysis.envVarCount.value > 0(大小估算器统计了定义,但枚举器未返回行 — 通常是因为缺少发布者前缀或令牌):HAS_ENV_VARS = true。计划中的计数摘要备用说明会告知用户,稍后将枚举逐变量详情。 - 否则,如果
SOLUTION_DONE = true且.solution-manifest.json列出了componenttype 380的组件:HAS_ENV_VARS = true。(清单过期时的备用判断 — 现在有了 10b 后,这种情况很少会触发。) - 否则:
HAS_ENV_VARS = false。将在 setup-solution 期间发现变量。
当 HAS_ENV_VARS = true 时,计划会注明 deploy-pipeline 将提示用户输入各阶段的环境变量值(参见 Phase 3 的风险信息填充)。
手动路径 — Q3 至 Q6
🚦 关口(计划 · plan-alm:2.q3-manual): 选择手动导出/导入路径的目标环境数量。
Q3: 通过 AskUserQuestion 询问:
“需要部署到多少个目标环境?”
选项:
- 一个目标(例如 Production)
- 两个目标(例如先 Staging,再 Production)
- 仅 Dev — 暂不部署
保存为 MANUAL_TARGET_COUNT。
如果选择选项 3:设置 MANUAL_TARGET_COUNT = 0。继续执行 Q5。
🚦 关口(计划 · plan-alm:2.q4-manual-target): 选择每个手动目标环境的 URL。在
MANUAL_TARGET_COUNT循环中针对每个目标触发。 两个目标(Staging + Production)= 两个独立提示。每个提示都会预填pac env list中的信息,并接受不同的 URL。不要在一个多输入提示中收集所有目标 URL — 每个目标都是独立的决策(受众不同、环境特征可能不同,也可能使用不同的 SKU)。
Q4(每个阶段一次): 对于每个所需的目标环境,通过 AskUserQuestion 询问:
“目标环境 {N} 的 URL 是什么?”
从 ENV_LIST 预填:显示 pac env list 中最多 3 个已知环境 URL 作为选项,并将“输入其他 URL”作为最后一个选项。
将目标 URL 保存为 MANUAL_TARGETS(数组)。
🚦 关口(同意 · plan-alm:2.q5-manual-type): 选择托管或非托管导出 — 这会对生成的 zip 造成不可逆的影响。
Q5: 通过 AskUserQuestion 询问:
“应如何导出解决方案?”
选项:
- Managed — 用于 staging/production(无法在目标环境中编辑)
- Unmanaged — 用于 dev-to-dev(可在目标环境中编辑)
保存为 EXPORT_TYPE,并记录此决策({ field: "exportType", value: EXPORT_TYPE, source: "default"|"explicit" })。
不会收集检查点偏好。
plan-alm不再自行运行导出/导入,因此旧的 “在导出和导入之间暂停?” 问题(仅用于控制执行)已移除。计划会记录建议的顺序 — 每个目标依次执行export-solution,然后执行import-solution— 用户运行这些步骤时会在两步之间检查 zip。第三阶段会在计划的风险/步骤中添加一条长期备注,说明这一点。
Q6(自动检测,不提问): 与 PP Pipelines Q6 相同 — 检查环境变量定义。
第三阶段 — 生成 HTML 计划
现在创建两个规划任务 — 策略已确定。plan-alm 是规划器,因此无论采用哪种路径,都恰好有两个任务:
| # | 主题 | activeForm | 描述 |
|---|---|---|---|
| 1 | 生成 ALM 计划 | 正在生成 ALM 计划 | 构建 planData,渲染 docs/alm-plan.html |
| 2 | 批准并保存 ALM 计划 | 正在等待计划批准 | 显示内联摘要,记录批准者,保存(已批准或草稿) |
不要创建 Setup/Deploy/Activate/Test/Import/Finalize 任务。 这些是用户批准后运行的各个 ALM 技能所执行的执行步骤 — 它们不是
plan-alm任务。建议的顺序仍会显示在计划中:下方的 planDatasteps[]数组会列出这些步骤(全部为status: "pending"),渲染后的 HTML 执行清单则会将其显示为正式计划。用户运行后,每个下游技能都会通过refresh-alm-plan-data.js将自己的步骤标记为已完成。
将任务 1(“生成 ALM 计划”)标记为 in_progress。
构建 planData
使用所有收集到的策略输入构建一个 planData 对象:
{
"SITE_NAME": "{siteName}",
"siteType": "spa | non-spa", // from Phase 1 Step 1; readers also accept legacy code/declarative/data-model values
"GENERATED_AT": "{ISO timestamp}",
"STRATEGY": "pp-pipelines | manual",
"EXPORT_TYPE": "managed | unmanaged", // PP Pipelines path: always "managed"
"APPROVAL_MODE": "{approvalMode description}",
"HAS_ENV_VARS": true | false,
"SOLUTION_DONE": true | false,
"PIPELINE_DONE": true | false,
"PLAN_STATUS": "Draft", // set to "Approved" or "Draft" in Phase 4; plan-alm never sets "In Execution"/"Completed"
"PLAN_MODE": "draft", // "approved" | "draft" — set in Phase 4. Tooling/downstream skills distinguish an approved plan from a draft. Never "executed" (plan-alm doesn't execute).
"PLAN_QUALITY": "complete", // "complete" | "degraded" — degraded when discovery was incomplete (Phase 1 auth failure or the Phase 3 completeness check failed). Surfaced as a prominent risk.
"decisionsLog": [ // from DECISIONS_LOG (Phase 2) — drives the Phase 4 "Decisions defaulted" section
{ "field": "strategy", "value": "pp-pipelines", "source": "explicit" }
],
"LAST_INVOCATION_AT": null,
"APPROVED_BY": "",
"APPROVAL_DATE": "",
"stages": [
{ "label": "Dev", "envUrl": "{devEnvUrl}", "type": "source" },
{ "label": "Staging", "envUrl": "{stagingUrl}", "type": "target" },
{ "label": "Production", "envUrl": "{prodUrl}", "type": "target" }
],
"steps": [
{ "name": "Setup solution", "status": "pending", "skip": false },
{ "name": "Setup pipeline", "status": "pending", "skip": false },
{ "name": "Deploy via pipeline to Staging", "status": "pending", "skip": false },
{ "name": "Activate site in Staging", "status": "pending", "skip": false },
{ "name": "Test site in Staging", "status": "pending", "skip": false },
{ "name": "Deploy via pipeline to Production", "status": "pending", "skip": false },
{ "name": "Activate site in Production", "status": "pending", "skip": false },
{ "name": "Test site in Production", "status": "pending", "skip": false }
],
// The steps[] above is the PP Pipelines path. For the MANUAL path (STRATEGY = "manual")
// emit this shape instead — do NOT include "Setup pipeline"/"Deploy via pipeline":
// { "name": "Setup solution", ... },
// { "name": "Export solution", ... },
// then PER Manual target {targetLabel}:
// { "name": "Import to {targetLabel}", ... },
// { "name": "Activate site in {targetLabel}", ... }, // import-solution does NOT activate
// { "name": "Test site in {targetLabel}", ... }
// These names are what refresh-alm-plan-data.js step-sync (export/import/activate/test)
// and computeNextStep match on, so the manual checklist and next-step nudges resolve
// to /power-pages:export-solution → /power-pages:import-solution → /power-pages:activate-site → /power-pages:test-site.
"validationRuns": {
"Staging": null,
"Production": null
},
"pipelineMeta": null, // populated when docs/alm/last-pipeline.json exists — see "pipelineMeta block" below
"risks": [
{ "type": "info", "message": "..." }
],
"solutionContents": {
"tables": ["{table1}", "{table2}"],
"botComponents": [{ "name": "..." }],
"siteSettings": {
"keepAsIs": [{ "name": "..." }],
"promoteToEnvVar": [{ "name": "...", "value": "..." }],
"credentialNeedsDecision": [{ "name": "..." }]
}
},
// --- v2 fields from the split decision tree (Phase 1 Step 10) ---
"sizeAnalysis": { /* tier-classified signals from SPLIT_PLAN.sizeAnalysis */ },
"assetAdvisory": { /* candidates + recommendation from SPLIT_PLAN.assetAdvisory */ },
"splitStrategy": "single | strategy-1-layer | strategy-2-change-frequency | strategy-3-schema-segmentation | strategy-4-config-isolation",
"appliedStrategies": ["strategy-1-layer"], // may include "composite-sub-partition" when a Layer-split child still exceeded the size or count cap and was sub-divided
"compositeSubPartitioned": false, // mirrors SPLIT_PLAN.compositeSubPartitioned — true when the renderer's strategy rationale should mention sub-partitioning
"proposedSolutions": [ /* from SPLIT_PLAN.proposedSolutions — ALWAYS at least 1 entry */ ],
"recommendations": [ /* from SPLIT_PLAN.recommendations */ ],
"envVars": [ /* from ENV_VARS_DETAILS (Phase 1 Step 10b) — { schemaName, type, defaultValue, siteSetting } per definition; empty array when DEV_TOKEN unavailable */ ],
"plannedEnvVarCount": 0, // sum of SITE_SETTINGS_DATA.promoteToEnvVar.length + credentialNeedsDecision.length — env vars setup-solution will offer to create. Renderer shows this as "N planned" alongside the existing-count stat so a fresh project (envVars: []) doesn't look like nothing is happening when the risks list says auth settings will be promoted. Reset to 0 by refresh-alm-plan-data setup-solution phase.
"breakdown": { /* bytes-per-category from the estimate */ },
"estimationMethod": "metadata-based",
"estimationAccuracyPct": 15,
"truncationSuspected": false, // true when the estimator's truncation canary fired — surfaced as a red banner on the rendered plan and gates the "keep as single solution anyway" override in Phase 2 Q1b
"truncationWarnings": [], // specific category-level reasons the canary fired (e.g. "Dataverse reports 6050 powerpagecomponent rows but discovery returned 500")
"webFilesDiskMeasuredMB": null, // disk-measured total when `--projectRoot` was passed to estimate-solution-size.js and a build-output dir (dist/public-output/build/.output) was found; null otherwise. Renderer surfaces this under the Web Files signal when it disagrees materially with `webFilesAggregateMB` — useful when file-typed columns hold bytes that $select=content can't return.
"webFilesDiskMeasuredPath": null, // the build-output directory that produced webFilesDiskMeasuredMB; null when not measured.
"webFileSampleSize": 0, // how many web-file rows the stratified sampler actually read for size measurement. Compared with webFileCount this tells reviewers how aggressively the aggregate was extrapolated.
// --- Raw discovery snapshot — embedded verbatim for diagnosability ---
// The mapped fields above (sizeAnalysis, splitStrategy, hostResolution, …)
// are derived from these. When a rendered plan looks wrong, this block is
// what a reviewer (or another agent) needs to diagnose without re-running
// discovery. Never reference these fields from the renderer's display
// paths — keep them as a sealed diagnostic envelope.
"rawDiscovery": {
"estimate": { /* full output of estimate-solution-size.js, verbatim */ },
"splitPlan": { /* full output of compute-split-plan.js, verbatim */ },
"hostResolution": { /* full output of ensure-pipelines-host-detect.js (PP path only) or null */ }
},
// --- v3 fields from the host resolution (Phase 1 Step 12) — PP Pipelines path only ---
"hostResolution": {
"status": "AvailableUsingCustomHost | AvailableUsingCustomHostByAdminDefault | AvailableUsingPlatformHost | AvailableUnboundCustomHost | MultipleUnboundCustomHosts | PlatformHostExistsUnbound | CannotRedirect | NoHost | OrgSettingStale | PermissionDenied | DetectionFailed",
"hostEnvUrl": "https://pascalepipelineshost.crm.dynamics.com" | null,
"hostEnvId": "0817fd3d-a664-e99a-a758-dd9dc03ceb01" | null,
"hostType": "custom | platform | null",
"pipelinesSolutionVersion": "9.x.y.z" | null,
"candidatesCount": 0,
"willEnsureDuringExecution": true | false,
"willProvisionPlatform": true | false,
"willProvisionCustom": true | false,
"willUsePpac": true | false,
"chosenEnvUrl": "https://orgc4f78248.crm5.dynamics.com/" | null,
"userChoseDeferToSetupPipeline": false
}
}
solutionContents 从第一阶段构建的 SOLUTION_CONTENTS_DATA 填充。如果无法进行发现,则传入 null — 渲染器会显示一条回退说明。
plannedEnvVarCount 根据 SITE_SETTINGS_DATA 计算(Phase 1 Step 7):(SITE_SETTINGS_DATA.promoteToEnvVar?.length || 0) + (SITE_SETTINGS_DATA.credentialNeedsDecision?.length || 0)。当 SITE_SETTINGS_DATA 为 null(Step 7 查询失败)时,设 plannedEnvVarCount = 0。渲染器会读取此值,以及 envVars.length(现有值)和 sizeAnalysis.envVarCount.value(大小估算器的计数),在概览统计卡片和大小分析信号中显示“今日 N 个 / 计划新增 +M 个”。
validationRuns 块(仅限 PP Pipelines 路径 — 为每个目标阶段初始化一个值为 null 的条目)。plan-alm 在计划阶段会将这些值保留为 null;之后,用户针对每个阶段运行 test-site 时,会由 test-site 自身的最终阶段刷新(refresh-alm-plan-data.js --phase test-site --stageName {stage})填充。完整的分类测试报告会驱动渲染 HTML 中的验证选项卡。每个阶段的结构如下:
{
"validationRuns": {
"Staging": {
"url": "https://example.powerappsportals.com",
"runAt": "2026-04-27T15:00:00.000Z",
"durationSec": 120,
"runOutcome": "passed | passed-with-warnings | failed",
"summary": {
"critical": 0, "high": 1, "medium": 0, "low": 2,
"total": 3, "automated": 2, "manual": 1,
"passed": 2, "failed": 1, "skipped": 0
},
"categories": [
{
"id": "site-load",
"name": "Site Load",
"icon": "📦",
"tests": [
{
"id": "t01",
"name": "Homepage returns 200 OK",
"severity": "critical",
"type": "automated",
"status": "passed",
"description": "...",
"steps": ["GET /", "Expect 200"],
"expected": "200 OK",
"actual": "200 OK",
"validates": "Site activation"
}
]
}
]
},
"Production": null
}
}
该结构与 test-site Phase 6.7a 写入的 docs/alm/last-test-site.json 完全相同 — 刷新辅助程序会读取该文件的原始内容,并在运行 test-site 时将其赋给 validationRuns[stageName]。渲染器会将 runOutcome 映射为绿色 / 黄色 / 红色结果徽标,并在验证选项卡中为每个阶段生成一个子选项卡。对于 Manual 路径,请从 planData 中省略 validationRuns。
pipelineMeta 块(仅限 PP Pipelines 路径 — 在构建 planData 时读取 docs/alm/last-pipeline.json 和 docs/alm/last-deploy.json。对于尚未配置管道的新计划,值为 null。用户运行 setup-pipeline 和 deploy-pipeline 时,之后会由它们各自的最终阶段刷新更新)。突出显示实际用于移动此项目配置的管道。结构如下:
{
"pipelineMeta": {
"isActive": true,
"pipelineId": "2b8b5de8-8f43-f111-bec7-6045bd569497",
"pipelineName": "BYOC Supplier Portal Pipeline",
"reusedByWiring": null,
"lastDeploy": {
"status": "Succeeded",
"stageName": "Deploy to Staging",
"deployedAt": "2026-04-29T08:42:00.000Z",
"artifactVersion": "1.0.0.2",
"componentCount": 118
}
}
}
isActive:只要项目配置了管道(docs/alm/last-pipeline.json存在),值始终为true。用于驱动 Pipelines 选项卡上的 ACTIVE 标签。pipelineName:取自docs/alm/last-pipeline.json。如果缺少pipelineMeta,渲染器会回退到${SITE_NAME}-Pipeline。reusedByWiring:如果管道是新建的,则值为null;如果create-deployment-pipeline.js按源+目标接线匹配到现有管道并以其原有名称复用,则值为对象{ originalName, requestedName }。渲染器会显示说明性注释,帮助审核者理解计划中的名称与实际管道名称可能不同的原因。lastDeploy:根据docs/alm/last-deploy.json得出。首次部署前省略(设为null)。
填充方式。 在 Phase 3 构建 planData 期间,读取这两个文件(内联 Node.js 代码)并注入:
node -e "
const fs = require('fs');
const meta = { isActive: false, pipelineId: null, pipelineName: null, reusedByWiring: null, lastDeploy: null };
try {
const lp = JSON.parse(fs.readFileSync('docs/alm/last-pipeline.json','utf8'));
meta.isActive = true;
meta.pipelineId = lp.pipelineId || null;
meta.pipelineName = lp.pipelineName || null;
meta.reusedByWiring = lp.reusedByWiring || null;
} catch {}
try {
const ld = JSON.parse(fs.readFileSync('docs/alm/last-deploy.json','utf8'));
meta.lastDeploy = {
status: ld.status, stageName: ld.stageName, deployedAt: ld.deployedAt,
artifactVersion: ld.artifactVersion, componentCount: ld.componentCount,
};
} catch {}
process.stdout.write(JSON.stringify(meta));
"
将结果嵌入为 planData.pipelineMeta。
v2 字段(sizeAnalysis、assetAdvisory、splitStrategy、appliedStrategies、compositeSubPartitioned、proposedSolutions、recommendations、breakdown)直接取自 Phase 1 Step 10 中计算并经 Q1b 用户选择修改后的 SPLIT_PLAN。原样传递给渲染器。envVars 取自 Phase 1 Step 10b 中填充的 ENV_VARS_DETAILS — 原样传递。当数组为空时,渲染器会使用基于计数的回退逻辑,读取 sizeAnalysis.envVarCount.value,以便“环境变量”选项卡仍显示计数摘要说明。
磁盘测量交叉核对(webFilesDiskMeasuredMB、webFilesDiskMeasuredPath、webFileSampleSize)— 将这些值从估算器输出(rawDiscovery.estimate.webFilesDiskMeasuredMB 等)提升到 planData 顶层。仅当向 estimate-solution-size.js 传入 --projectRoot "." 且检测到构建输出目录时,才会填充这些值。当 Dataverse 测量的 Web Files 信号与 webFilesDiskMeasuredMB 存在显著差异时(即触发 truncationSuspected 的相同条件),渲染器会在该信号旁显示 webFilesDiskMeasuredMB,让审核者一眼就能判断应信任哪个数值。如果估算器未测量磁盘,则这三个值都传入 null。
截断警报(truncationSuspected、truncationWarnings)——原样传递自 SPLIT_PLAN.truncationSuspected / SPLIT_PLAN.truncationWarnings(compute-split-plan.js 本身从估算结果中读取)。当 truncationSuspected === true 时,渲染后的计划会在大小分析上方显示红色横幅,并且 Phase 2 Q1b 中的“仍作为单个解决方案保留”覆盖选项需要额外确认步骤(见 Phase 2 Q1b)。常见原因:estimate-solution-size.js 中的 Dataverse 分页回归,或 Web 文件统计不足(文件类型列的字节数未通过 $select=content 返回)——磁盘交叉核验(启用时)会明确标出后一种情况。
rawDiscovery 块——在任何字段映射之前,原样嵌入完整的估算器、拆分计划和主机解析输出。这是一个诊断封装:一般来说,渲染器不得在显示路径中引用其内容。有限例外:渲染器的 webFilesDiskMeasured* / webFileSampleSize 读取路径会在顶层提升字段缺失时回退到 rawDiscovery.estimate.*,因此较旧的计划文件(在指定提升字段之前写入)仍会渲染磁盘比较说明。如果触发了该回退,正确的修复方法是使用已填充提升字段的 planData 重新构建计划,而不是继续保留回退路径。除此之外,原则不变:rawDiscovery 的存在是为了让审核者(或未来排查错误计划的 Agent)能够读取 docs/.alm-plan-data.json,并并排查看发现脚本实际生成的内容和渲染器使用的映射字段。每个源文件只读取一次并赋值:
planData.rawDiscovery = {
estimate: readJson('./docs/alm/alm-size-estimate.json'),
splitPlan: readJson('./docs/alm/alm-split-plan.json'),
hostResolution: PIPELINE_DONE
? null // host info comes from .last-pipeline.json in that path
: readJson('./docs/alm/alm-host-resolution.json'), // PP path with detection; null for Manual path
};
对于跳过的任何来源,请使用 null(Manual 路径不会运行主机解析;预流水线项目没有清单)。不要删减或概述——此快照的价值在于它是原始输入。
proposedSolutions[]永远不为空。 即使splitStrategy === "single"且决策树建议使用一个解决方案,compute-split-plan.js也会返回一条描述基础解决方案的条目(uniqueName、displayName、sizeMB、componentCount、componentTypes)。请原样传递该条目。渲染器根据此数组生成 Solutions 选项卡——留空会显示无益的“结构将另行确定”占位内容。如果由于未运行compute-split-plan而导致proposedSolutions = [],请根据solutionContents.solution/data.SITE_NAME/componentCount/totalSizeMB合成一个基础条目,而不要传递空数组。渲染器为此情况提供了安全兜底合成器,但正确的修复方式是在上游显式填充该字段。
hostResolution 块(仅限 PP Pipelines 路径——Manual 路径应省略)。根据 HOST_RESOLUTION(Phase 1 步骤 12)以及 Phase 2 Q4 设置的辅助标志构建:
status←HOST_RESOLUTION.statushostEnvUrl←HOST_ENV_URL(来自 Q4)——用户选择暂缓或新建时,可能为nullhostEnvId←HOST_RESOLUTION.finalHostEnvIdhostType←HOST_RESOLUTION.hostTypepipelinesSolutionVersion←HOST_RESOLUTION.pipelinesSolutionVersioncandidatesCount←HOST_RESOLUTION.candidates.existingCustomHosts.lengthwillEnsureDuringExecution← Q4 中的WILL_ENSURE_HOST标志(只要执行时 setup-pipeline 需要查询 ensure-pipelines-host,就为 true;也就是说,状态为NoHost、任意*Unbound*,或用户选择暂缓时)willProvisionPlatform← Q4 中的WILL_PROVISION_PLATFORM标志(用户在NoHost主机类型菜单中选择选项 1「预配平台主机」时设置;ensure-pipelines-host 会将其视为直接进入 Phase 4.0 的指令)willProvisionCustom← Q4 中的WILL_PROVISION_CUSTOM标志willUsePpac← Q4 中的WILL_USE_PPAC标志(用户在NoHost主机类型菜单中选择「打开 PPAC 并手动创建」时设置;ensure-pipelines-host 会将其视为直接进入 Phase 4.C 的指令)chosenEnvUrl← Q4 中的CHOSEN_ENV_URL标志(用户在NoHost主机类型菜单中选择选项 2 → 子选项a时设置;ensure-pipelines-host 会将其视为跳过其 Phase 3.C 菜单,并直接进入使用此环境的 Phase 4.B 的指令)userChoseDeferToSetupPipeline← Q4 中的USER_CHOSE_DEFER_TO_SETUP_PIPELINE标志(仅在MultipleUnboundCustomHosts的「稍后决定」分支中设置) 根据收集的数据填充risks:- 如果
HAS_ENV_VARS = true:{ type: "warning", message: "此解决方案包含环境变量(检测到 {N} 个)——部署期间会提示你输入各阶段的值。" }。如果ENV_VARS_DETAILS.length> 0,则用它替换{N};否则使用SPLIT_PLAN.sizeAnalysis.envVarCount.value(大小估算器报告的数量)。如果这两个来源都没有正数,则去掉括号部分("此解决方案包含环境变量——部署期间会提示你输入各阶段的值。")。 - 如果
SITE_SETTINGS_DATA.promoteToEnvVar.length > 0:{ type: "info", message: "检测到 {N} 个带有值的身份验证相关站点设置(Authentication/* 和 AzureAD/*)。setup-solution 会询问你要将哪些设置改由环境变量提供,以便各阶段使用不同的值(例如,不同的 OAuth 回调 URL)。跳过不需要按阶段设置不同值的项。" }。用SITE_SETTINGS_DATA.promoteToEnvVar.length替换{N}。(替换旧版的“将会提升”措辞——那会让人以为是自动执行;实际上,用户会逐项选择。) - 如果
SITE_SETTINGS_DATA.credentialNeedsDecision.length > 0:{ type: "info", message: "检测到 {N} 个凭据类站点设置(ConsumerKey / ClientId / ClientSecret 等)。setup-solution 将通过一次批量提示处理所有这些设置——按名称自动分类(推荐;*Secret/Password/ApiKey/AppKey 将成为 Secret 环境变量,*Id/ConsumerKey 将成为 String 环境变量)、全部设为 Secret、全部设为 String、全部跳过,或转入逐个凭据选择器以进行精细控制。" }。用SITE_SETTINGS_DATA.credentialNeedsDecision.length替换{N}。不要输出任何“从解决方案中排除/手动配置”措辞——那是 IronItOut 之前的行为,现已移除。 - 如果
EXPORT_TYPE = "unmanaged"且策略包含生产目标:{ type: "warning", message: "非托管解决方案可以在目标环境中编辑——生产环境建议使用 Managed。" } - 如果
SOLUTION_DONE = false:{ type: "info", message: "将先创建 Dataverse 解决方案——发布者前缀一经选择便无法更改。" } - 始终(规划 PP Pipelines 路径,且 Phase 4 完成后
SOLUTION_DONE将变为 true 时):{ type: "info", message: "以后添加新组件(服务器逻辑、云端流、环境变量、自定义表)时,请以同步模式重新运行 /power-pages:setup-solution,将它们纳入此解决方案。此技能中的完整性检查(Phase 1 Step 11)会在下次 plan-alm 运行前标记实时站点与解决方案之间的任何差异。" }。如果 plan-alm 启动时SOLUTION_DONE已为 true,则跳过(此时同步模式已显而易见)。 - 如果已设置
KNOWN_GAPS(Phase 1 Step 11 中的计划前完整性检查发现缺口,且用户选择继续):{ type: "warning", message: "站点上有 {X} 个站点组件、{Y} 个云端流、{Z} 个环境变量和 {W} 个自定义表,但它们不在当前解决方案中。此计划不会将它们部署到其他环境——请在部署前以同步模式运行 /power-pages:setup-solution,或同步后重新运行 plan-alm。" }。使用KNOWN_GAPS.missing.*.length中的数量替换各占位符。 - 如果
HOST_RESOLUTION.status === "NoHost"且WILL_PROVISION_PLATFORM === true:{ type: "info", message: "未检测到 Pipelines 主机。setup-pipeline 将预配新的 Platform Host(幂等,约 3–5 分钟)。计划执行将在调用前暂停,等待租户身份确认。" }。不要提及管理员角色要求或 API 名称——这些是实现细节。 - 如果
HOST_RESOLUTION.status === "NoHost"且WILL_PROVISION_CUSTOM === true:{ type: "info", message: "未检测到 Pipelines 主机。setup-pipeline 将创建新的 Custom Host。计划执行将在调用前暂停,等待管理员角色声明和确认。" } - 如果
HOST_RESOLUTION.status === "NoHost"且未设置任何预配标志(用户通过选项 2 → 子选项a选择了现有环境,或选择了 PPAC 手动方式,或暂缓操作):根据CHOSEN_ENV_URL/WILL_USE_PPAC输出与状态相符的信息条目。 - 如果
HOST_RESOLUTION.status === "AvailableUnboundCustomHost":{ type: "info", message: "将重用现有 Custom Host(" + HOST_RESOLUTION.finalHostEnvUrl + ")。源环境将自动绑定到该主机。" } - 如果
HOST_RESOLUTION.status === "MultipleUnboundCustomHosts":{ type: "info", message: "租户中找到 " + HOST_RESOLUTION.candidates.existingCustomHosts.length + " 个现有 Custom Host。setup-pipeline 将提示你选择。" } - 如果
HOST_RESOLUTION.status === "PlatformHostExistsUnbound":{ type: "info", message: "租户中有 Platform Host。重用它是最省事的选择;另行创建 Custom Host 则更适合需要租户隔离或治理的场景。" } - 如果
HOST_RESOLUTION.status === "CannotRedirect":{ type: "warning", message: "CannotRedirect:源环境的 ProjectHostEnvironmentId 指向 PE,但租户默认自定义主机设在其他位置。需要 Power Platform 管理员处理。" }(注意:Phase 2 Q4 通常会在此状态下阻止生成计划;这是为了应对计划意外生成的防御性条目。) - 手动路径(当
STRATEGY = "manual"时始终输出):{ type: "info", message: "建议顺序:运行 /power-pages:export-solution,检查生成的 zip,然后为每个目标运行 /power-pages:import-solution。plan-alm 本身不会执行导出/导入。" } - 原始发现数据缺失(#9)——对于
rawDiscovery.estimate、rawDiscovery.splitPlan,以及(PP 路径中的)rawDiscovery.hostResolution,如果它们在构建 planData 时为null:{ type: "warning", message: "{X} 未运行相关发现;此计划中与之相关的大小/拆分/主机决策未经验证。" }(将{X}替换为“解决方案大小估算”/“拆分分析”/“Pipelines 主机解析”。)**例外:**当PIPELINE_DONE = true时,rawDiscovery.hostResolution是有意跳过的(Phase 1 Step 12 的跳过规则——主机信息来自docs/alm/last-pipeline.json,而不是重新探测),因此hostResolution为null是预期情况,不属于缺口。当PIPELINE_DONE = true时,不要输出“Pipelines 主机解析”警告——对于已有 Pipelines 的项目,输出“主机解析未运行”会造成误报。无论PIPELINE_DONE的值如何,仍适用estimate和splitPlan的缺口警告。 计划完整性检查(#10)。 写入 planData 前,验证计划基于真实发现: sizeAnalysis.totalSizeMB不为 null,stages[]非空,- 已配置解决方案时,
solutionContents已填充(SOLUTION_DONE = true)。
如果任一检查失败,设置 PLAN_QUALITY = "degraded",并记录未通过的检查。(此外,Phase 1 Step 6 中的身份验证失败路径也会设置 degraded。)
计划降级风险(#8)——如果 PLAN_QUALITY === "degraded"(由身份验证失败路径或完整性检查导致),则将以下内容置于 risks 开头:{ type: "warning", message: "⚠ PLAN QUALITY: DEGRADED — discovery was incomplete ({cause}). Review carefully before executing; re-run /power-pages:plan-alm after fixing the cause (commonly dev-environment auth) to regenerate a complete plan." }。使用 type: "warning"(渲染器会为其设置样式)。根据记录的原因替换 {cause}。
将 planData 写入 docs/.alm-plan-data.json(如果 docs/ 不存在,则创建它)。
渲染 HTML 计划
node "${PLUGIN_ROOT}/skills/plan-alm/scripts/render-alm-plan.js" \
--output "<projectRoot>/docs/alm-plan.html" \
--data "<projectRoot>/docs/.alm-plan-data.json"
将 docs/.alm-plan-data.json 保留在磁盘上——绝不要删除。 plan-alm 退出后有两个使用方依赖此文件:(1)执行技能的最终阶段刷新(refresh-alm-plan-data.js)会读取该文件,并在各技能运行时更新 hostResolution、pipelineMeta、validationRuns、risks、steps[] 状态和页脚,然后重新渲染 docs/alm-plan.html,使渲染后的标签页反映实际运行状态(而非运行前的计划);(2)check-alm-plan.js 会读取该文件,用于 setup-pipeline / deploy-pipeline / setup-solution / export-solution / import-solution / configure-env-variables 中的 Phase 0 ALM 计划门禁——删除该文件会导致所有下游技能认为不存在计划(并触发无计划门禁)。先前关于初次渲染后删除此文件的指导有误,导致 Pipelines 标签页和风险列表在整个计划生命周期内都停留在运行前的状态。
写入 docs/alm/alm-plan-context.json(持续保留,以便 setup-solution 读取):
{
"generatedAt": "{ISO timestamp}",
"siteName": "{siteName}",
"siteSettings": {
"keepAsIs": [{name}],
"authNoValue": [{name}],
"promoteToEnvVar": [{name, value}],
"excluded": [{name}]
}
}
此文件不会被删除,这是有意为之——setup-solution 和其他技能会读取它,以跳过重新发现。
在用户的默认浏览器中打开 HTML 计划
Phase 4 中呈现的 Markdown 摘要有意保持简洁——评审者需要查看完整渲染后的计划(规模指示器、信号卡片、各解决方案明细、资产建议、流水线阶段),才能做出知情批准。在批准提示之前,先在默认浏览器中打开 docs/alm-plan.html,这样用户在阅读 CLI 摘要时就能查看完整计划。
为何不使用 Node 包装器。 之前的
node -e "spawn('powershell.exe', [...])"调用链会触发 Agent 的沙箱分类器(Node 启动一个进程,而该进程又启动另一个进程,这是分类器会拦截的典型模式)。Agent 应直接使用 操作系统原生 shell 工具——不经过 Node,不使用child_process,也不启动分离的子进程。
步骤 1. 首先醒目地打印绝对 file:// URL。 如果启动器被拦截,这就是用户可靠的备用方案:
node -e "process.stdout.write('Plan URL: file:///' + require('path').resolve('docs/alm-plan.html').replace(/\\\\/g, '/') + '\n')"
(单次 Node 调用,不启动进程,永远不会被拦截。输出:Plan URL: file:///C:/Projects/.../docs/alm-plan.html。)
步骤 2. 通过操作系统原生 shell 启动浏览器。 选择 Agent 可用的 shell 工具:
-
Windows / PowerShell 工具——直接调用
Start-Process:Start-Process "docs/alm-plan.html" -
Windows / Bash 工具(Git Bash、WSL 透传)——从 Bash 调用 PowerShell,但作为一次直接调用(不使用 Node 包装器):
powershell.exe -NoProfile -Command "Start-Process 'docs/alm-plan.html'" -
macOS——直接调用
open:open docs/alm-plan.html -
Linux——直接调用
xdg-open:xdg-open docs/alm-plan.html
步骤 3. 向用户提供 URL。 尝试启动后,显示 Agent 在步骤 1 中打印的 file:// URL,确保用户始终有可点击的备用链接:
“已在浏览器中打开
docs/alm-plan.html。如果未自动打开,请使用此链接:file:///C:/Projects/.../docs/alm-plan.html。请查看计划,然后回答下方的批准提示。”
如果启动悄然失败(终端处于沙箱中、SSH 会话、无头环境),不要重试、不要阻塞,也不要循环执行。步骤 1 打印的 URL 就是约定——用户可以自行点击或粘贴。继续进入 Phase 4,并以 CLI 摘要作为备用。
将任务 1 标记为 completed。
Phase 4 — 呈现计划、批准并保存
将任务 2(“批准并保存 ALM 计划”)标记为 in_progress。
plan-alm 到此为止。 此阶段会保存计划(已批准或草稿)并退出。它不会调用 setup-solution、setup-pipeline、deploy-pipeline、export-solution 或 import-solution。用户之后自行运行这些命令。
呈现简洁的 Markdown 摘要:
## ALM Plan: {siteName}
**Strategy:** {PP Pipelines / Manual export/import}
**Stages:** {Dev} → {Staging} → {Production (if applicable)}
**Approval gates:** {description from PP_APPROVAL_MODE, or "N/A — manual path"}
**Solution export:** {Managed / Unmanaged}
**Pipeline host:** {hostEnvUrl} ({status}) — *(PP Pipelines path only; when `WILL_ENSURE_HOST = true`, render as `Will be ensured during setup-pipeline ({status})` instead)*
**Plan quality:** {complete / ⚠ DEGRADED — <cause>}
**Decisions defaulted (please review):**
{For each DECISIONS_LOG entry, one line: "- {field}: {value} ({default} or {your pick})". Tag source:"default" as (default) and source:"explicit" as (your pick). If every decision was explicit, write "- (none — every choice was an explicit pick)".}
**Recommended execution sequence (you run these after approval):**
- [ ] /power-pages:setup-solution {(SKIP — already set up) if SOLUTION_DONE}
- [ ] /power-pages:setup-pipeline {(SKIP — already set up) if PIPELINE_DONE} {(PP path only)}
- [ ] /power-pages:export-solution {(manual path only)}
- [ ] /power-pages:deploy-pipeline — per stage {(PP path only)}
- [ ] /power-pages:import-solution — per target {(manual path only)}
Full plan written to: docs/alm-plan.html
🚦 关卡(计划 · plan-alm:4.approve): 最后一步——用户保存渲染后的 HTML 计划。
plan-alm不会执行任何操作;此关卡仅用于选择将计划保存为已批准(用户可以运行执行技能),还是保存为草稿,或返回修改。此处不会调用任何下游技能。
通过 AskUserQuestion 询问:
“你希望如何保存此 ALM 计划?”
选项:
- 保存计划——已批准,可执行(保存为已批准;接下来由你运行执行技能)
- 将计划保存为草稿(保存为草稿;之后重新运行 plan-alm 以批准)
- 我想修改某些内容——返回问题
-
如果选择选项 3: 重新运行 Phase 2(询问要修改哪个部分,然后重新收集相关答案)。重新生成计划(重复 Phase 3)。再次呈现计划以供批准。
-
如果选择选项 1(已批准): 记录批准者(见下文),然后使用确定性辅助工具,以原子方式写入状态和批准者。不要手动编辑 HTML span——徽章、
approved-by和approval-date都是在渲染时根据docs/.alm-plan-data.json派生的,因此手动编辑 span 不会持久保留(下次执行技能刷新时会重新派生)。辅助工具会更新计划数据(PLAN_STATUS、PLAN_MODE、APPROVED_BY、APPROVAL_DATE——四项一起更新),并重新渲染docs/alm-plan.html:node "${PLUGIN_ROOT}/scripts/lib/set-plan-status.js" --projectRoot "." --status Approved --approver "{APPROVER}" --render然后运行以下最终步骤(技能跟踪 + 提交)、打印后续步骤指引、将任务 2 标记为
completed,并退出。 -
**如果选择选项 2(草稿):**不要记录审批人。使用同一个辅助程序保存为草稿(写入计划数据 + 重新渲染;清除任何过时的审批人字段,确保计划不会出现“草稿 + 审批人”的状态):
node "${PLUGIN_ROOT}/scripts/lib/set-plan-status.js" --projectRoot "." --status Draft --render运行以下最终步骤(仅提交——跳过技能跟踪,或自行决定是否运行;提交消息为
"Add ALM plan for {siteName} (draft)"),告知用户准备好审批时重新运行/power-pages:plan-alm,将任务 2 标记为completed,并退出。
记录审批人(仅选项 1)——始终交互式确认(#1):
绝不要静默自动填入姓名。首先根据 git / OS 用户信息计算一个预填建议:
node -e "const {execSync}=require('child_process');let n='';try{n=execSync('git config user.name',{encoding:'utf8'}).trim();}catch{};if(!n){n=process.env.USER||process.env.USERNAME||'';}process.stdout.write(n);"
然后始终通过 AskUserQuestion 询问(即使建议不为空),确保由人工主动确认记录在审计跟踪中的审批人:
"谁在审批此计划?(将记录在 docs/alm-plan.html 的审计跟踪中)"
选项:1. {git/OS 中的建议姓名(如有)} · 2. 其他(输入姓名)
如果命令返回空字符串,则仅显示选项 2(自由文本)。将确认结果存为 APPROVER,并传递给 set-plan-status.js(上面的选项 1)——不要手动编辑 approved-by / approval-date spans。该辅助程序会将 APPROVED_BY + APPROVAL_DATE(当前 ISO 时间戳)写入 docs/.alm-plan-data.json 并重新渲染,模板会从计划数据中填入这两个 spans。这样可确保审计跟踪与状态保持一致——不会出现“已记录审批人但状态仍为草稿”的半完成状态(validate-plan-alm.js 现在会阻止这种情况)。
最终步骤(两种保存选项均适用):
- 技能跟踪(选项 1;草稿可选):
参考:
${PLUGIN_ROOT}/references/skill-tracking-reference.mdnode "${PLUGIN_ROOT}/scripts/update-skill-tracking.js" \ --projectRoot "." --skillName "PlanAlm" --authoringTool "ClaudeCode" - 提交计划——根据用户选择的保存选项选择提交消息:
- 选项 1(已批准):
git add docs/alm-plan.html && git commit -m "Add ALM plan for {siteName}" - 选项 2(草稿):
git add docs/alm-plan.html && git commit -m "Add ALM plan for {siteName} (draft)"
docs/.alm-plan-data.json会保留在磁盘上——每个下游技能的 Phase 0 门禁都会通过check-alm-plan.js读取它,且这些技能运行时会更新它。绝不要删除它。 - 选项 1(已批准):
后续步骤指引(选项 1——打印给用户,不要调用):
"计划已批准并保存。plan-alm 不会部署——按以下顺序运行这些步骤。每一步都会检测此计划并继续执行,不会再次询问,然后在完成时更新计划:
- PP Pipelines 路径:
/power-pages:setup-solution→/power-pages:setup-pipeline→/power-pages:deploy-pipeline(每个目标阶段运行一次;部署流程会为你激活站点——但如果你推迟激活,请运行/power-pages:activate-site)→/power-pages:test-site,以验证每个阶段。- 手动路径:
/power-pages:setup-solution→/power-pages:export-solution→ 检查 zip →/power-pages:import-solution(每个目标运行一次)。 跳过任何标记为已设置的步骤。你可以随时在docs/alm-plan.html中重新打开计划。"
进度跟踪表
| 任务主题 | activeForm | 描述 |
|---|---|---|
| 生成 ALM 计划 | 正在生成 ALM 计划 | 收集策略输入、构建 planData、渲染 docs/alm-plan.html |
| 批准并保存 ALM 计划 | 等待计划审批 | 展示内联摘要(包括默认决策 + 计划质量)、记录审批人、保存为“已批准”或“草稿”、提交、打印后续步骤 |
plan-alm恰好包含这两个任务。设置 / 管道 / 部署 / 导出 / 导入 / 激活 / 测试由用户在批准后运行的各个 ALM 技能执行——它们会出现在计划的steps[](建议顺序)中,但绝不会成为plan-alm任务。
关键决策点(等待用户确认)
- 阶段 1:处理
.alm-deferred标记;检查预案是否完整(如果已有解决方案) - 阶段 2,Q1:解决方案设置——确认已有解决方案,或在计划中加入
setup-solution - 阶段 2,Q1b:拆分建议及覆盖确认(如有拆分建议)
- 阶段 2,Q2:推广策略——PP Pipelines、手动,或已完成设置
- 阶段 2,Q3–Q5(PP 路径):阶段数、主机环境、审批关卡(托管模式下自动设置) 阶段 2,Q3–Q5(手动路径):目标数、目标环境 URL、导出类型
- 阶段 4:保存计划——批准、草稿或修改。这是唯一一次“批准”;之后不会执行任何操作。
错误处理
- 不存在
.powerpages-site/website.yml且不存在powerpages.config.json:停止并建议使用/power-pages:create-site(阶段 1 会从任一标记中解析站点身份——数据模型/EDM 站点只有website.yml,因此不要仅因缺少配置文件就停止) pac env list失败:跳过 ENV_LIST 预填;手动询问环境 URLrender-alm-plan.js失败(退出码非零):报告错误,将 planData JSON 作为备用方案展示,并询问用户是否继续- 发现或身份验证失败:将
PLAN_QUALITY设为"degraded",明确提示重大风险,但仍生成计划(用户修复身份验证问题后重新运行即可重新生成) - 计划审批选择了选项 3(修改内容):完整重新运行阶段 2,然后重新生成计划——不要沿用过时的回答