Skip to content

Commit 356f63f

Browse files
tyaginidhiclaude
andcommitted
plan-alm: deterministic Draft/Approved status write + consistency guard + in-place approve
The Draft/Approved tag was the only PLAN_STATUS transition with no helper behind it — plan-alm Phase 4 set it via hand-authored Edits to the HTML spans AND the JSON. Since the badge + approver are re-derived from docs/.alm-plan-data.json on every render, the manual HTML Edit was non-durable, and a partial write left the plan "approver recorded but PLAN_STATUS=Draft" — stuck forever (check-alm-plan only promotes from Approved; evaluatePlanCompletion ignores Draft). - NEW scripts/lib/set-plan-status.js: single deterministic owner of the creation-time Draft/Approved write. Writes PLAN_STATUS + PLAN_MODE + APPROVED_BY + APPROVAL_DATE together (atomic temp+rename) and optionally re-renders (reuses refresh-alm-plan-data.js findRendererPath/invokeRenderer, now exported). Invariants: only Draft/Approved settable here; Approved requires a non-empty approver; Draft clears the approver; a live (In Execution/Completed) plan is not re-drafted without --force. + 10 unit tests. - validate-plan-alm.js: consistency guard blocks the two half-written states (Draft+approver, Approved+no-approver) for plans created the old way or hand-edited. + 5 tests. - plan-alm Phase 4: both save options now call set-plan-status.js instead of hand-editing the HTML spans + JSON. - plan-alm Phase 1 step 0b: in-place Draft -> Approved fast-path (new gate plan-alm:1.approve-draft) — approve an existing draft without a full re-plan. - approval-gates.md: catalog the new gate. AGENTS.md: document the helper + lifecycle ownership. Version -> 2.6.0 (2.5.0 held by #194). Full suite: 1262 pass. alm-lint: 0 findings. legacy-compat: in sync. End-to-end: stuck state blocks (2) -> set-plan-status -> badge Approved -> validator approves (0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent af087e7 commit 356f63f

10 files changed

Lines changed: 505 additions & 11 deletions

File tree

marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"source": "./plugins/power-pages",
1414
"description": "Power Pages development and management plugin for Claude Code and GitHub Copilot",
1515
"category": "development",
16-
"version": "2.5.0",
16+
"version": "2.6.0",
1717
"license": "MIT",
1818
"tags": [
1919
"power platform",

plugins/power-pages/.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.5.0",
3+
"version": "2.6.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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ Shared lib modules live at `scripts/lib/` and are imported by other scripts via
201201
- `scripts/lib/detect-project-context.js`: Reads Power Pages project context from the project root. The `siteType` discriminator is the **build axis** — code/SPA vs declarative (design-studio) site — NOT the Dataverse data-model axis (a declarative site can be on the standard OR enhanced data model; both download to a `.powerpages-site/` tree). `siteType: "data-model"` is the (compat-named) declarative bucket; a future pass may rename it `"declarative"`. Resolves identity in order: (1) `powerpages.config.json` → `siteType: "code"` (code/SPA sites); (2) `.powerpages-site/` → `siteType: "data-model"` (declarative design-studio sites — standard or enhanced data model — which have **no** `powerpages.config.json`). The **authoritative declarative marker is the `.powerpages-site/.portalconfig/` directory** (only declarative sites have it); `website.yml` is the identity source (`id`→`websiteRecordId`, `name`→`siteName`) but is NOT a reliable declarative signal alone because **both** site types carry it. `environmentUrl: null` for declarative sites (no env URL in the local files — callers re-confirm via `pac env who`). Also reads `.solution-manifest.json` and `.datamodel-manifest.json`. Args: `--projectRoot` (opt). Output: `{ projectRoot, siteType, siteName, websiteRecordId, environmentUrl, solutionManifest, datamodelManifest }`. Exit 0 on success, exit 1 only if neither `powerpages.config.json` nor a `.powerpages-site/` (`.portalconfig/`/`website.yml`) marker is found. Note: `findProjectRoot` (in `validation-helpers.js`) likewise treats a `.powerpages-site/` directory as a project-root marker.
202202
- `scripts/lib/alm-paths.js`: Single source of truth for ALM artifact paths. Exports `ALM_DIR` (always `docs/alm`), `FILE_NAMES` (frozen object mapping logical key → filename for all 14 ALM artifacts), `almDir(projectRoot) → path`, `almPath(projectRoot, key) → path`, `ensureAlmDir(projectRoot) → path` (mkdir -p idempotent). Every ALM-only state file (5 plan/decision JSONs + 9 `last-*.json` skill-run markers including `last-export.json`) writes under `<projectRoot>/docs/alm/`. **Always resolve through this helper** — never inline a raw `docs/alm/...` path in a script. Files intentionally NOT moved here (and not in `FILE_NAMES`): `.solution-manifest.json`, `.datamodel-manifest.json`, `.alm-config.json`, `.alm-deferred`, `deployment-settings.json`. Adding a new ALM marker means adding its key + filename to `FILE_NAMES` first; `almPath` throws on unknown keys to catch typos at call-site.
203203
- `scripts/lib/check-alm-plan.js`: Phase 0 gate helper used by every ALM skill to detect (a) whether an ALM plan exists for this project, (b) whether the user has explicitly deferred ALM via the `.alm-deferred` marker, and (c) whether an existing plan is stale (the source solution was modified after the plan was generated). Args: `--projectRoot`, `--envUrl` (opt — required for staleness check), `--token` (opt), `--solutionId` (opt — required for staleness check). Output: `{ exists, deferred, deferral, planPath, htmlPath, stale, staleness: { reason, detail }, generatedAt, planStatus, solution: {...} }`. Without env/solution context the helper does an existence-only check; with them it queries Dataverse for `solutions(solutionId)?$select=modifiedon` and compares against `planData.generatedAt`. Used by `setup-solution`, `setup-pipeline`, `deploy-pipeline`, `export-solution`, `import-solution`, `configure-env-variables`, `ensure-pipelines-host`, `force-link-environment` Phase 0 gates — the "fail closed when no plan" pattern. **PLAN_STATUS lifecycle — promotes `Approved` → `In Execution`:** plan-alm is plan-only and leaves the plan `Approved`; this helper performs the `Approved` → `In Execution` transition (and writes the first heartbeat) the **first time an execution skill's Phase 0 runs** — it is the only thing that sets `In Execution`, so without it the heartbeat/active-chain machinery (multi-hour-deploy `stale-heartbeat` reclassification) never engages. Gated on heartbeat-write: read-only callers pass `--no-heartbeat` (plan-alm's own deferral check, audits, tests) and are never promoted. The terminal `In Execution` → `Completed` transition is owned by `refresh-alm-plan-data.js` (completion evaluator).
204+
- `scripts/lib/set-plan-status.js`: **The single deterministic owner of the creation-time `Draft` / `Approved` write** — the one PLAN_STATUS transition that used to be done by hand-authored `Edit`s in plan-alm Phase 4 (to the HTML spans *and* the JSON), with no helper. Because the badge + `approved-by` / `approval-date` spans are re-derived from `docs/.alm-plan-data.json` on every render, the old manual HTML Edit was non-durable (reverted on the next refresh) and a partial write left the plan "approver recorded but PLAN_STATUS=Draft" — stuck forever, since `check-alm-plan.js` only promotes from `Approved`. This helper writes `PLAN_STATUS` + `PLAN_MODE` + `APPROVED_BY` + `APPROVAL_DATE` **together** (atomic temp+rename) and optionally re-renders (reuses `refresh-alm-plan-data.js → findRendererPath`/`invokeRenderer`). Enforced invariants: only `Draft` / `Approved` are settable here (`In Execution` is owned by `check-alm-plan.js`, `Completed` by `refresh-alm-plan-data.js`); `Approved` **requires** a non-empty `--approver`; `Draft` **clears** the approver fields; a plan already `In Execution` / `Completed` is **not** re-drafted without `--force`. Args: `--projectRoot`, `--status Draft|Approved`, `--approver`, `--approvalDate` (opt — defaults to now), `--force`, `--render`, `--rendererPath` (opt). Output: `{ ok, previousStatus, status, mode, approver, approvalDate, rendered }`. Called by plan-alm Phase 4 (both save options) and the Phase 1 step-0b in-place Draft→Approved fast-path. The `validate-plan-alm.js` consistency guard blocks the two half-written states (`Draft`+approver, `Approved`+no-approver) for plans created the old way or hand-edited.
204205
- `scripts/lib/resolve-target-solution.js`: Resolves "which solution should this new Dataverse record land in?" Implements the strict 3-step order from the ALM-aware-by-default principle: (1) explicit `--solutionUniqueName` (or equivalent caller arg) wins; (2) `.solution-manifest.json` in the project root; (3) neither → throw `NoSolutionConfiguredError`. **The module NEVER auto-picks from Dataverse** — interactive prompt UX is the caller's responsibility (catch the error, present an `AskUserQuestion` list, re-invoke with `explicit` populated). Callers that need to confirm the solution still exists in Dataverse can pass `verifyExists: true`; the module then enriches the result with `{ solutionId, version, ismanaged }`. Component-creation scripts must require this helper and pass through `--solutionUniqueName` so records land in the user's solution instead of `Default`.
205206

206207
#### Solution Splitting Decision Tree (v1.3.0+)

plugins/power-pages/references/approval-gates.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ Each section lists every `AskUserQuestion` in that skill. Catalog rows are marke
263263
| ID | Kind | Category | Phase | Trigger / question | Cancel leaves |
264264
|---|---|---|---|---|---|
265265
| `plan-alm:1.deferral` | gate | progress | 1 | `.alm-deferred` marker present — *"Continue with deferral / remove and proceed / cancel"* | `deferral-marker` |
266+
| `plan-alm:1.approve-draft` | gate | plan | 1 (0b) | Existing **Draft** plan found — *"Approve this draft now (no re-plan) / re-plan from scratch / cancel"*. Approve writes status via `set-plan-status.js` and exits | nothing |
266267
| `plan-alm:1.completeness` | gate | progress | 1 | Completeness check found gaps — *"Sync first / plan with gaps / cancel"* | nothing |
267268
| `plan-alm:2.q1-existing` | gate | plan | 2 (Q1) | `SOLUTION_DONE=true`*"Use existing solution **{name}**?"* | nothing |
268269
| `plan-alm:2.q1-fresh` | gate | plan | 2 (Q1) | `SOLUTION_DONE=false`*"Include solution setup in plan?"* | nothing |

plugins/power-pages/scripts/lib/refresh-alm-plan-data.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,4 +1212,9 @@ module.exports = {
12121212
STEP_TO_SKILL,
12131213
MARKER_TO_PHASE,
12141214
PHASES,
1215+
// Exported so other plan-data writers (e.g. set-plan-status.js) reuse the SAME
1216+
// renderer-invocation instead of re-implementing the execFileSync call — keeps
1217+
// the "where is render-alm-plan.js / how is it invoked" knowledge in one place.
1218+
findRendererPath,
1219+
invokeRenderer,
12151220
};
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
4+
// set-plan-status.js — the single deterministic owner of the CREATION-TIME ALM
5+
// plan status write (`Draft` / `Approved`).
6+
//
7+
// Background / why this exists:
8+
// The plan-status badge and the "Approved by" stamp in docs/alm-plan.html are
9+
// BOTH re-derived from docs/.alm-plan-data.json every time the plan is rendered
10+
// (render-alm-plan.js reads PLAN_STATUS / APPROVED_BY / APPROVAL_DATE). Every
11+
// OTHER status transition is owned by a deterministic helper:
12+
// - Approved -> In Execution : check-alm-plan.js (first execution skill)
13+
// - In Execution -> Completed: refresh-alm-plan-data.js (evaluatePlanCompletion)
14+
// ...but the Draft/Approved write was historically done by HAND-AUTHORED Edits
15+
// in plan-alm Phase 4 — to two places (the HTML spans AND the JSON), with no
16+
// helper. That produced two real bugs:
17+
// 1. Editing the HTML span is non-durable — the next refresh re-derives the
18+
// badge from plan-data and reverts it if plan-data wasn't also updated.
19+
// 2. A partial write (APPROVED_BY set in plan-data but PLAN_STATUS left at
20+
// "Draft") leaves the plan shown-as-approved but stuck on Draft forever,
21+
// because check-alm-plan.js only promotes from "Approved".
22+
// This helper makes plan-data the single source of truth and writes all four
23+
// fields together (atomically), so neither bug can recur. Phase 4 (and the
24+
// in-place Draft->Approved fast-path) call this instead of hand-editing.
25+
//
26+
// Usage:
27+
// node set-plan-status.js --projectRoot <root> --status Approved --approver "Jane Doe" [--render]
28+
// node set-plan-status.js --projectRoot <root> --status Draft [--render]
29+
// node set-plan-status.js --projectRoot <root> --status Draft --force (re-draft a running plan)
30+
//
31+
// Output (JSON to stdout):
32+
// { "ok": true, "projectRoot": "...", "previousStatus": "Draft", "status": "Approved",
33+
// "mode": "approved", "approver": "Jane Doe", "approvalDate": "2026-…Z", "rendered": true }
34+
//
35+
// Exit 0 on success, exit 1 on any validation error (missing plan, bad status,
36+
// Approved-without-approver, or a refused regression of a live plan).
37+
38+
const fs = require('fs');
39+
const { planDataPath, planHtmlPath } = require('./alm-paths');
40+
// Reuse the SAME renderer-invocation as the post-run refresh, rather than
41+
// re-implementing the execFileSync call. Requiring this module is side-effect
42+
// free (its CLI body is guarded by `require.main === module`).
43+
const { findRendererPath, invokeRenderer } = require('./refresh-alm-plan-data');
44+
45+
// The two statuses this helper owns. In Execution / Completed are owned by
46+
// check-alm-plan.js and refresh-alm-plan-data.js respectively and must NOT be
47+
// settable here — that would let a caller fabricate lifecycle state.
48+
const CREATION_STATUSES = new Set(['Draft', 'Approved']);
49+
// A plan in one of these states is past the creation/approval stage; re-writing
50+
// it back to Draft/Approved would erase live execution state, so it is refused
51+
// unless --force is passed.
52+
const LIVE_STATUSES = new Set(['In Execution', 'Completed']);
53+
54+
/**
55+
* Atomically set the creation-time plan status in docs/.alm-plan-data.json.
56+
*
57+
* @param {object} opts
58+
* @param {string} opts.projectRoot
59+
* @param {'Draft'|'Approved'} opts.status
60+
* @param {string} [opts.approver] required (non-empty) when status === 'Approved'
61+
* @param {string} [opts.approvalDate] ISO string; defaults to now when status === 'Approved'
62+
* @param {boolean} [opts.force] allow overwriting an In Execution / Completed plan
63+
* @param {boolean} [opts.render] re-render docs/alm-plan.html after writing
64+
* @param {string} [opts.rendererPath] override the renderer path (tests)
65+
* @param {() => string} [opts.makeNow] injectable clock (tests); returns an ISO string
66+
* @returns {{ ok: true, projectRoot, previousStatus, status, mode, approver, approvalDate, rendered }}
67+
*/
68+
function setPlanStatus(opts) {
69+
const {
70+
projectRoot,
71+
status,
72+
approver,
73+
approvalDate,
74+
force = false,
75+
render = false,
76+
rendererPath = null,
77+
makeNow = () => new Date().toISOString(),
78+
} = opts || {};
79+
80+
if (!projectRoot) throw new Error('--projectRoot is required');
81+
if (!CREATION_STATUSES.has(status)) {
82+
throw new Error(
83+
`--status must be one of: ${[...CREATION_STATUSES].join(', ')} ` +
84+
`(got ${JSON.stringify(status)}). "In Execution"/"Completed" are owned by ` +
85+
'check-alm-plan.js / refresh-alm-plan-data.js, not this helper.',
86+
);
87+
}
88+
89+
const dataPath = planDataPath(projectRoot);
90+
if (!fs.existsSync(dataPath)) {
91+
throw new Error(`No ALM plan found at ${dataPath}. Run /power-pages:plan-alm first.`);
92+
}
93+
94+
let planData;
95+
try {
96+
planData = JSON.parse(fs.readFileSync(dataPath, 'utf8'));
97+
} catch (e) {
98+
throw new Error(`Could not parse ${dataPath}: ${e.message}`);
99+
}
100+
101+
const previousStatus = planData.PLAN_STATUS || null;
102+
103+
// Never silently erase live execution state. A plan that has started executing
104+
// (In Execution) or finished (Completed) should not be quietly reset to a
105+
// creation-time status — that would drop heartbeat/step state and confuse the
106+
// downstream gates. Require an explicit --force to override.
107+
if (LIVE_STATUSES.has(previousStatus) && !force) {
108+
throw new Error(
109+
`Refusing to set status to "${status}": the plan is already "${previousStatus}". ` +
110+
'Pass --force to override (this discards live execution state).',
111+
);
112+
}
113+
114+
const approverTrimmed = (approver || '').trim();
115+
let mode;
116+
let finalApprover;
117+
let finalApprovalDate;
118+
119+
if (status === 'Approved') {
120+
// Approved without an approver is exactly the half-written state the
121+
// consistency guard flags — refuse to create it here.
122+
if (!approverTrimmed) {
123+
throw new Error('--approver is required (and must be non-empty) when --status is Approved.');
124+
}
125+
mode = 'approved';
126+
finalApprover = approverTrimmed;
127+
finalApprovalDate = (approvalDate && approvalDate.trim()) || makeNow();
128+
} else {
129+
// Draft: per plan-alm Phase 4 option 2, a draft does NOT carry an approver.
130+
// Clear any stale approver fields so we never leave "Draft + approver" behind.
131+
mode = 'draft';
132+
finalApprover = '';
133+
finalApprovalDate = '';
134+
}
135+
136+
planData.PLAN_STATUS = status;
137+
planData.PLAN_MODE = mode;
138+
planData.APPROVED_BY = finalApprover;
139+
planData.APPROVAL_DATE = finalApprovalDate;
140+
141+
// Atomic write: temp + rename, so a crash mid-write can't truncate the plan
142+
// file that every downstream Phase 0 gate depends on.
143+
const tmp = dataPath + '.tmp';
144+
fs.writeFileSync(tmp, JSON.stringify(planData, null, 2));
145+
fs.renameSync(tmp, dataPath);
146+
147+
let rendered = false;
148+
if (render) {
149+
const htmlPath = planHtmlPath(projectRoot);
150+
invokeRenderer(findRendererPath(rendererPath), dataPath, htmlPath);
151+
rendered = true;
152+
}
153+
154+
return {
155+
ok: true,
156+
projectRoot,
157+
previousStatus,
158+
status,
159+
mode,
160+
approver: finalApprover,
161+
approvalDate: finalApprovalDate,
162+
rendered,
163+
};
164+
}
165+
166+
function parseArgs(argv) {
167+
const args = argv.slice(2);
168+
const out = {
169+
projectRoot: null, status: null, approver: null, approvalDate: null,
170+
force: false, render: false, rendererPath: null,
171+
};
172+
for (let i = 0; i < args.length; i++) {
173+
if (args[i] === '--projectRoot' && args[i + 1]) out.projectRoot = args[++i];
174+
else if (args[i] === '--status' && args[i + 1]) out.status = args[++i];
175+
else if (args[i] === '--approver' && args[i + 1]) out.approver = args[++i];
176+
else if (args[i] === '--approvalDate' && args[i + 1]) out.approvalDate = args[++i];
177+
else if (args[i] === '--force') out.force = true;
178+
else if (args[i] === '--render') out.render = true;
179+
else if (args[i] === '--rendererPath' && args[i + 1]) out.rendererPath = args[++i];
180+
}
181+
return out;
182+
}
183+
184+
if (require.main === module) {
185+
try {
186+
const result = setPlanStatus(parseArgs(process.argv));
187+
process.stdout.write(JSON.stringify(result) + '\n');
188+
process.exit(0);
189+
} catch (err) {
190+
process.stderr.write(`set-plan-status: ${err.message}\n`);
191+
process.exit(1);
192+
}
193+
}
194+
195+
module.exports = { setPlanStatus, parseArgs, CREATION_STATUSES, LIVE_STATUSES };

0 commit comments

Comments
 (0)