|
| 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