Skip to content

Commit b639ff4

Browse files
tyaginidhiclaude
andcommitted
Enforce ALM plan refresh via PostToolUse reconcile backstop (auto-heal)
The refresh-alm-plan-data.js calls in each ALM SKILL.md are advisory markdown — silently dropped on session fragmentation, manual execution, or oversight. Three observed gaps where the rendered plan never reflected real run state: ensure-pipelines-host (no refresh call at all), setup-pipeline (refresh lives in Phase 7; phases 5-6 run manually after a resume skip it), activate-site (Phase 5.2b refresh missed). Fix is auto-heal, not fail-validation (per feedback_skill_validation_hooks: never hard-block). After ANY ALM plan skill completes, the centralized PostToolUse hook spawnSyncs `refresh-alm-plan-data.js --reconcile --render`, which ingests any marker (docs/alm/last-*.json) newer than the plan. Because it fires on any ALM skill (not just the marker's writer), a skip in skill A is healed when the next ALM skill B completes. Best-effort and non-blocking: never changes the hook's exit code, honors .alm-deferred, idempotent. - powerpages-hook-utils.js: ALM_PLAN_SKILLS set + isAlmPlanSkill(value) - run-skill-posttool-validation.js: reconcile backstop after the validator - ensure-pipelines-host SKILL.md: explicit Phase 6 self-refresh (direct gap-1 fix; hook reconcile remains the backstop) The reconcile mode + ensure-pipelines-host phase in refresh-alm-plan-data.js ship in the plan-only PR earlier in this stack. 1212 tests pass, alm-lint 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f87f3a5 commit b639ff4

6 files changed

Lines changed: 107 additions & 3 deletions

File tree

plugins/power-pages/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "power-pages",
3-
"version": "2.4.0",
3+
"version": "2.5.0",
44
"description": "Create and deploy Power Pages sites using modern development approaches. Supports code sites (SPAs) with React, Angular, Vue, or Astro. Includes ALM orchestration (plan-alm) with a solution-splitting decision tree, per-solution pipelines, Azure Blob asset advisory, manifest schema v2 for multi-solution deployments, and force-link remediation for cross-host pipeline migrations.",
55
"author": {
66
"name": "Microsoft",

plugins/power-pages/AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ Skills are defined in `SKILL.md` files with YAML frontmatter (name, description,
170170

171171
Hook registration is centralized in `hooks/hooks.json` — a single PostToolUse hook (matcher `Skill`) runs `hooks/run-skill-posttool-validation.js` after every Skill tool call. The runner derives tracked skills directly from `skills/*/SKILL.md` via `scripts/lib/powerpages-hook-utils.js`, looks up an optional `skills/<skill>/scripts/validate*.js` validator for the skill that just completed, and invokes it with the current cwd.
172172

173+
**ALM plan reconcile backstop (auto-heal).** After any **ALM plan skill** completes (`powerpages-hook-utils.js → ALM_PLAN_SKILLS` / `isAlmPlanSkill`) and a `docs/.alm-plan-data.json` exists in the cwd, the runner also `spawnSync`s `refresh-alm-plan-data.js --reconcile --render`. The `refresh-alm-plan-data.js` calls in each SKILL.md are advisory — silently dropped on session fragmentation, manual execution, or oversight — so the reconcile *performs* any refresh whose marker (`docs/alm/last-*.json`) is newer than the plan. This is **best-effort and non-blocking**: it never changes the hook's exit code, honors `.alm-deferred`, and is idempotent. Because it fires on *any* ALM skill (not just the marker's writer), a skip in skill A is healed when the next ALM skill (B) completes. Skills keep their explicit per-phase refresh calls as defense-in-depth + immediate render; the hook is the backstop.
174+
173175
To wire a new skill into validation:
174176

175177
1. Write the validator at `skills/<skill>/scripts/validate-<skill>.js` using the `runValidation((cwd) => { ... })` pattern from `scripts/lib/validation-helpers.js`.
@@ -225,7 +227,7 @@ Shared lib modules live at `scripts/lib/` and are imported by other scripts via
225227
- `scripts/lib/link-site-setting-to-env-var.js`: Links an `mspp_sitesetting` record to an `environmentvariabledefinition` via OData PATCH on the v9.0 API (not v9.2). HAR-confirmed: navigation property is `EnvironmentValue@odata.bind`; headers `if-match: *` and `clienthost: Browser` are required (omitting causes 400). Args: `--envUrl`, `--token`, `--siteSettingId`, `--definitionId`, `--schemaName`. Output: `{ ok, verified, siteSettingId, definitionId }`.
226228
- `scripts/lib/install-pipelines-app.js`: Installs the Power Platform Pipelines application package on an existing Dataverse env (replaces ensure-pipelines-host Phase 4.B's manual PPAC click-through). Resolution: BAP `applicationPackages` LIST + `/install` POST → 200 sync / 202 + Location poll, with PAC CLI fallback (`pac application install --environment-id ... --application-list msdyn_AppDeploymentAnchor`) on 401/403/5xx. 409 on install POST treated as idempotent (already-installed). Args: `--bapToken`, `--envId`, `--instanceApiUrl` (opt — for verification probe), `--hostToken` (opt), `--no-pac-fallback` (opt), `--correlationId`, `--timeoutSec`, `--apiVersion`, `--bapBase`. Output: `{ status, alreadyInstalled, installPath: 'bap'\|'pac'\|'cached', packageUniqueName, pipelinesSolutionVersion, durationSec, correlationId, pollAttempts, locationHeader, pacFallbackReason }`.
227229
- `scripts/lib/discover-env-var-definitions.js`: Enumerates env var definitions matching a publisher prefix and joins each with its bound `mspp_sitesetting` (if any). Used by `plan-alm` Phase 1 Step 10b to populate `planData.envVars[]` with row-level metadata so the rendered plan's Env Variables tab shows schema name, type, default value, and bound site setting per definition (instead of just a count). Args: `--envUrl`, `--publisherPrefix`, `--websiteRecordId`, `--token` (opt). Output: `{ envVars: [{ schemaName, type, defaultValue, siteSetting }], count }`. Degrades gracefully (empty array, exit 0) on auth failure or query errors so the renderer's count-summary fallback can take over.
228-
- `scripts/lib/refresh-alm-plan-data.js`: Updates `docs/.alm-plan-data.json` with post-run state from the marker files written by setup-pipeline / deploy-pipeline / ensure-pipelines-host / test-site / import-solution / activate-site / configure-env-variables / setup-solution / export-solution, then optionally re-renders `docs/alm-plan.html`. Used by plan-alm Phases 6 / 7 / 8 so the rendered Pipelines tab, Validation tab, hostResolution card, env var values matrix, checklist, and risks list reflect actual run state instead of frozen pre-run intent. Args: `--projectRoot`, `--phase` (`setup-solution`/`setup-pipeline`/`configure-env-variables`/`deploy-pipeline`/`export-solution`/`import-solution`/`activate-site`/`test-site`/`finalize`), `--render` (also invoke renderer), `--stageName` (required for `test-site`; preferred for `import-solution`/`activate-site` though both can resolve via marker URL match). Output: `{ ok, phase, dataPath, htmlPath, rendered }`. Returns `ok:false` (soft no-op) when `docs/.alm-plan-data.json` is missing — caller should preserve that file across phases for the helper to work. Plan-alm Phase 3 must NOT delete the file after the initial render — it's read by `check-alm-plan.js` for downstream Phase 0 ALM-plan gates and by this helper for post-run refreshes. **Cross-cutting behaviors**: (a) `setStepStatus` flips the matching entry in `planData.steps[]` to `completed` (or `failed` when the phase's marker indicates failure) — case-insensitive keyword match + stage filter, respects `skip: true`, never regresses completed→pending; (b) `deploy-pipeline` AND `configure-env-variables` both backfill `planData.envVars[i].values{}` from the project root's `deployment-settings.json` so the rendered plan's "Values by Environment" matrix auto-populates (accepts both top-level-stage and nested-`stages` shapes; `SchemaName`/`Value` and camelCase variants; never overwrites a populated cell — manual override wins); (c) `configure-env-variables` and `setup-solution` both re-ingest `docs/alm/last-env-vars.json` (when present) so freshly-created definitions appear in `planData.envVars[]` and `plannedEnvVarCount` zeros out; (d) `export-solution` ingests `docs/alm/last-export.json` into `planData.manualMeta.lastExport` (all 10 marker fields: solutionUniqueName/solutionId/previousVersion/version/managed/sourceEnvironmentUrl/zipPath/fileSizeBytes/asyncOperationId/exportedAt) so the Manual-path tab can show the most recent export. Marker absence is a silent step-sync-only no-op (no `manualMeta.lastExport: null` row in the rendered plan); (e) `deploy-pipeline` ingests the `batchValidation` block from `last-deploy.json` into `planData.pipelineMeta.lastDeploy.batchValidation` (totalSolutions/succeeded/failed/pendingApproval/timedOut/elapsedSeconds/perSolutionStageRunIds) so the rendered plan can show the Phase 3.6 parallel-validation outcome distinct from the serial deploy outcome. Explicitly set to `null` for single-solution / legacy v2 deploys so renderers can branch on it; legacy `elapsedSecondsApprox` field name is accepted and normalized to `elapsedSeconds` on ingest.
230+
- `scripts/lib/refresh-alm-plan-data.js`: Updates `docs/.alm-plan-data.json` with post-run state from the marker files written by setup-pipeline / deploy-pipeline / ensure-pipelines-host / test-site / import-solution / activate-site / configure-env-variables / setup-solution / export-solution, then optionally re-renders `docs/alm-plan.html`. Used by plan-alm Phases 6 / 7 / 8 so the rendered Pipelines tab, Validation tab, hostResolution card, env var values matrix, checklist, and risks list reflect actual run state instead of frozen pre-run intent. Args: `--projectRoot`, `--phase` (`setup-solution`/`setup-pipeline`/`configure-env-variables`/`deploy-pipeline`/`export-solution`/`import-solution`/`activate-site`/`test-site`/`ensure-pipelines-host`/`finalize`) **OR `--reconcile`** (mutually exclusive with `--phase`), `--render` (also invoke renderer), `--stageName` (required for `test-site`; preferred for `import-solution`/`activate-site` though both can resolve via marker URL match). Output: `{ ok, phase, dataPath, htmlPath, rendered }`. Returns `ok:false` (soft no-op) when `docs/.alm-plan-data.json` is missing — caller should preserve that file across phases for the helper to work. Plan-alm Phase 3 must NOT delete the file after the initial render — it's read by `check-alm-plan.js` for downstream Phase 0 ALM-plan gates and by this helper for post-run refreshes. **Cross-cutting behaviors**: (a) `setStepStatus` flips the matching entry in `planData.steps[]` to `completed` (or `failed` when the phase's marker indicates failure) — case-insensitive keyword match + stage filter, respects `skip: true`, never regresses completed→pending; (b) `deploy-pipeline` AND `configure-env-variables` both backfill `planData.envVars[i].values{}` from the project root's `deployment-settings.json` so the rendered plan's "Values by Environment" matrix auto-populates (accepts both top-level-stage and nested-`stages` shapes; `SchemaName`/`Value` and camelCase variants; never overwrites a populated cell — manual override wins); (c) `configure-env-variables` and `setup-solution` both re-ingest `docs/alm/last-env-vars.json` (when present) so freshly-created definitions appear in `planData.envVars[]` and `plannedEnvVarCount` zeros out; (d) `export-solution` ingests `docs/alm/last-export.json` into `planData.manualMeta.lastExport` (all 10 marker fields: solutionUniqueName/solutionId/previousVersion/version/managed/sourceEnvironmentUrl/zipPath/fileSizeBytes/asyncOperationId/exportedAt) so the Manual-path tab can show the most recent export. Marker absence is a silent step-sync-only no-op (no `manualMeta.lastExport: null` row in the rendered plan); (e) `deploy-pipeline` ingests the `batchValidation` block from `last-deploy.json` into `planData.pipelineMeta.lastDeploy.batchValidation` (totalSolutions/succeeded/failed/pendingApproval/timedOut/elapsedSeconds/perSolutionStageRunIds) so the rendered plan can show the Phase 3.6 parallel-validation outcome distinct from the serial deploy outcome. Explicitly set to `null` for single-solution / legacy v2 deploys so renderers can branch on it; legacy `elapsedSecondsApprox` field name is accepted and normalized to `elapsedSeconds` on ingest. **`ensure-pipelines-host` phase**: host-only update of `planData.hostResolution` from `last-host-check.json` (drops NoHost risks) WITHOUT touching `pipelineMeta` or the `Setup pipeline` step — for when the host was resolved but the pipeline doesn't exist yet. **`--reconcile` mode**: the enforcement backstop — scans the `last-*.json` markers and, for each one newer than `docs/.alm-plan-data.json` (a skipped refresh), applies the mapped phase (`MARKER_TO_PHASE`; `lastPipeline`→setup-pipeline supersedes the host-only phase; `lastEnvVars`→configure-env-variables if `deployment-settings.json` exists else setup-solution) against a single loaded planData, writes once, renders once. Honors `.alm-deferred`, soft no-op when no plan, idempotent. Output `{ ok, reconciled:[phases], rendered }`. Invoked by the PostToolUse hook after every ALM skill (see Hooks).
229231

230232
#### PP Pipelines
231233

plugins/power-pages/hooks/run-skill-posttool-validation.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
#!/usr/bin/env node
22

3+
const fs = require('fs');
34
const path = require('path');
45
const { spawnSync } = require('child_process');
56
const {
67
getTrackedSkillFromToolInput,
78
getValidatorScript,
9+
isAlmPlanSkill,
810
} = require('../scripts/lib/powerpages-hook-utils');
911

1012
const DEBUG = process.env.DEBUG === '1' || process.env.DEBUG === 'true';
@@ -36,19 +38,51 @@ process.stdin.on('end', () => {
3638
process.exit(0);
3739
}
3840

41+
const cwd = input.cwd || process.cwd();
42+
3943
const validatorScript = getValidatorScript(skillName);
4044
if (validatorScript) {
4145
const validatorPath = path.join(__dirname, '..', validatorScript);
4246
const result = spawnSync(process.execPath, [validatorPath], {
4347
input: inputData,
4448
encoding: 'utf8',
45-
cwd: input.cwd || process.cwd(),
49+
cwd,
4650
});
4751
if (result.stdout) process.stdout.write(result.stdout);
4852
if (result.stderr) process.stderr.write(result.stderr);
4953
validatorStatus = result.status ?? 0;
5054
debug(`[power-pages hook] Validator exited with code ${validatorStatus}\n`);
5155
}
56+
57+
// ALM plan reconcile backstop (auto-heal). The refresh-alm-plan-data.js calls
58+
// in each SKILL.md are advisory — silently dropped on session fragmentation,
59+
// manual execution, or oversight. After ANY ALM plan skill completes, reconcile
60+
// the plan against the marker files: any marker newer than the plan (a skipped
61+
// refresh) is ingested automatically. Best-effort and NON-blocking — it never
62+
// changes the hook's exit code (the validator's status stands). Triggering on
63+
// any ALM skill (not just the marker's writer) catches a skip that surfaces only
64+
// when the NEXT ALM skill runs. Honors .alm-deferred + no-plan inside reconcile.
65+
if (isAlmPlanSkill(skillName) && fs.existsSync(path.join(cwd, 'docs', '.alm-plan-data.json'))) {
66+
try {
67+
const refreshPath = path.join(__dirname, '..', 'scripts', 'lib', 'refresh-alm-plan-data.js');
68+
const rec = spawnSync(process.execPath, [refreshPath, '--projectRoot', cwd, '--reconcile', '--render'], {
69+
encoding: 'utf8',
70+
cwd,
71+
timeout: 20000,
72+
});
73+
let reconciled = [];
74+
try { reconciled = (JSON.parse((rec.stdout || '').trim()).reconciled) || []; } catch {}
75+
if (reconciled.length > 0) {
76+
process.stdout.write(
77+
`[power-pages] ALM plan was out of sync with ${reconciled.length} run marker(s) — refreshed automatically (${reconciled.join(', ')}).\n`,
78+
);
79+
}
80+
debug(`[power-pages hook] reconcile reconciled=${JSON.stringify(reconciled)}\n`);
81+
} catch (e) {
82+
// Best-effort — a reconcile failure must never break the skill or the hook.
83+
debug(`[power-pages hook] reconcile error (ignored): ${e.message}\n`);
84+
}
85+
}
5286
} catch (err) {
5387
process.stderr.write(`[power-pages hook] Unexpected error: ${err.message}\n`);
5488
validatorStatus = 0;

plugins/power-pages/scripts/lib/powerpages-hook-utils.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,40 @@ function getValidatorScript(skillName) {
105105
return TRACKED_SKILLS[skillName]?.validatorScript ?? null;
106106
}
107107

108+
// Skills that write a `docs/alm/last-*.json` marker or otherwise consume the ALM
109+
// plan. After any of these completes, the PostToolUse hook runs a plan reconcile
110+
// (auto-heal) — so a refresh step skipped by ONE skill is caught when the NEXT
111+
// ALM skill completes (covers manual/cross-session execution).
112+
const ALM_PLAN_SKILLS = new Set([
113+
'setup-solution',
114+
'setup-pipeline',
115+
'deploy-pipeline',
116+
'export-solution',
117+
'import-solution',
118+
'configure-env-variables',
119+
'activate-site',
120+
'test-site',
121+
'ensure-pipelines-host',
122+
'force-link-environment',
123+
]);
124+
125+
/**
126+
* True when `value` (a raw skill name, `/skill`, or `power-pages:skill`) resolves
127+
* to an ALM plan skill. Normalizes via `detectTrackedSkill`, so it also confirms
128+
* the skill actually exists in this plugin.
129+
* @param {string} value
130+
* @returns {boolean}
131+
*/
132+
function isAlmPlanSkill(value) {
133+
const name = detectTrackedSkill(value);
134+
return name != null && ALM_PLAN_SKILLS.has(name);
135+
}
136+
108137
module.exports = {
109138
TRACKED_SKILLS,
139+
ALM_PLAN_SKILLS,
110140
detectTrackedSkill,
111141
getTrackedSkillFromToolInput,
112142
getValidatorScript,
143+
isAlmPlanSkill,
113144
};

plugins/power-pages/scripts/tests/powerpages-hook-utils.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ const path = require('path');
55

66
const {
77
TRACKED_SKILLS,
8+
ALM_PLAN_SKILLS,
89
detectTrackedSkill,
910
getTrackedSkillFromToolInput,
1011
getValidatorScript,
12+
isAlmPlanSkill,
1113
} = require('../lib/powerpages-hook-utils');
1214

1315
const SKILLS_DIR = path.join(__dirname, '..', '..', 'skills');
@@ -148,3 +150,27 @@ test('Object.prototype keys are not mistaken for tracked skills', () => {
148150
assert.equal(detectTrackedSkill('__proto__'), null);
149151
assert.equal(getTrackedSkillFromToolInput({ skill: 'toString' }), null);
150152
});
153+
154+
// --- ALM_PLAN_SKILLS / isAlmPlanSkill (reconcile-trigger gating) -------------
155+
156+
test('isAlmPlanSkill: true for ALM plan skills, normalizing prefixes', () => {
157+
assert.equal(isAlmPlanSkill('activate-site'), true);
158+
assert.equal(isAlmPlanSkill('/power-pages:activate-site'), true);
159+
assert.equal(isAlmPlanSkill('/activate-site'), true);
160+
assert.equal(isAlmPlanSkill('ensure-pipelines-host'), true);
161+
assert.equal(isAlmPlanSkill('setup-pipeline'), true);
162+
});
163+
164+
test('isAlmPlanSkill: false for non-ALM skills and junk', () => {
165+
assert.equal(isAlmPlanSkill('create-site'), false);
166+
assert.equal(isAlmPlanSkill('add-seo'), false);
167+
assert.equal(isAlmPlanSkill('not-a-real-skill'), false);
168+
assert.equal(isAlmPlanSkill(null), false);
169+
assert.equal(isAlmPlanSkill(undefined), false);
170+
});
171+
172+
test('ALM_PLAN_SKILLS members are all real tracked skills', () => {
173+
for (const name of ALM_PLAN_SKILLS) {
174+
assert.ok(TRACKED_SKILLS[name], `ALM_PLAN_SKILLS member "${name}" must be a tracked skill (have a SKILL.md)`);
175+
}
176+
});

plugins/power-pages/skills/ensure-pipelines-host/SKILL.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,17 @@ Record skill usage:
900900
901901
Follow the skill tracking instructions in the reference to record this skill's usage. Use `--skillName "EnsurePipelinesHost"`.
902902

903+
**Refresh the ALM plan (if one exists):**
904+
905+
```bash
906+
node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/refresh-alm-plan-data.js" \
907+
--projectRoot "." \
908+
--phase ensure-pipelines-host \
909+
--render
910+
```
911+
912+
This updates `planData.hostResolution` from the `docs/alm/last-host-check.json` you just wrote (host-only — no pipeline yet) and drops the pre-run NoHost risks, then re-renders `docs/alm-plan.html`. **Do this here, not just in setup-pipeline Phase 7** — a host install can take 18+ minutes and cross a session boundary, so deferring the refresh risks the plan never reflecting the host. When `docs/.alm-plan-data.json` is absent (standalone, not part of an ALM plan), the helper returns `ok:false` as a soft no-op. The centralized PostToolUse hook also reconciles the plan as a backstop, but refreshing at the source keeps the rendered plan current immediately.
913+
903914
Present summary table:
904915

905916
| Field | Value |

0 commit comments

Comments
 (0)