@jstn-sdk/ma 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.codex/agents/Architect.toml +4 -0
  3. package/.codex/agents/Auditor.toml +4 -0
  4. package/.codex/agents/Builder.toml +4 -0
  5. package/.codex/agents/Flow.toml +4 -0
  6. package/.codex/agents/Sage.toml +4 -0
  7. package/.codex/agents/Vibe.toml +4 -0
  8. package/.codex/hooks.json +15 -0
  9. package/.codex/prompts/enforcement.md +59 -0
  10. package/.codex/prompts/onboarding.md +41 -0
  11. package/.codex/prompts/release-rules.md +28 -0
  12. package/.codex/prompts/skill-contract.md +41 -0
  13. package/LICENSE +21 -0
  14. package/README.md +532 -0
  15. package/bin/ma.js +300 -0
  16. package/bin/meta-architect.js +3 -0
  17. package/docs/README.md +24 -0
  18. package/docs/assets/meta-architect-logo.png +0 -0
  19. package/docs/assets/meta-architect-logo.svg +8 -0
  20. package/docs/getting-started.md +451 -0
  21. package/docs/mcp-setup.md +5 -0
  22. package/docs/onboarding.md +65 -0
  23. package/docs/qa/release-readiness-0.1.0.md +62 -0
  24. package/docs/release-spec.md +86 -0
  25. package/docs/skills-publishing.md +231 -0
  26. package/docs/skills.md +91 -0
  27. package/index.js +28 -0
  28. package/mcp/collections.json +21 -0
  29. package/mcp/fallback.json +7 -0
  30. package/mcp/servers.json +55 -0
  31. package/package.json +84 -0
  32. package/plugins/meta-architect/.app.json +8 -0
  33. package/plugins/meta-architect/.codex-plugin/plugin.json +23 -0
  34. package/plugins/meta-architect/.mcp.json +12 -0
  35. package/plugins/meta-architect/README.md +121 -0
  36. package/plugins/meta-architect/skills/arch/SKILL.md +27 -0
  37. package/plugins/meta-architect/skills/arch/agents/openai.yaml +4 -0
  38. package/plugins/meta-architect/skills/build/SKILL.md +24 -0
  39. package/plugins/meta-architect/skills/build/agents/openai.yaml +4 -0
  40. package/plugins/meta-architect/skills/flow/SKILL.md +24 -0
  41. package/plugins/meta-architect/skills/flow/agents/openai.yaml +4 -0
  42. package/plugins/meta-architect/skills/meta-architect/SKILL.md +30 -0
  43. package/plugins/meta-architect/skills/meta-architect/agents/openai.yaml +4 -0
  44. package/plugins/meta-architect/skills/meta-architect/references/core-release-rules.md +13 -0
  45. package/plugins/meta-architect/skills/sage/SKILL.md +24 -0
  46. package/plugins/meta-architect/skills/sage/agents/openai.yaml +4 -0
  47. package/plugins/meta-architect/skills/vet/SKILL.md +25 -0
  48. package/plugins/meta-architect/skills/vet/agents/openai.yaml +4 -0
  49. package/plugins/meta-architect/skills/vibe/SKILL.md +24 -0
  50. package/plugins/meta-architect/skills/vibe/agents/openai.yaml +4 -0
  51. package/scripts/doctor.js +37 -0
  52. package/scripts/plugin-sync.js +92 -0
  53. package/scripts/postinstall.js +19 -0
  54. package/scripts/release-metadata.js +94 -0
  55. package/scripts/release-verify.js +153 -0
  56. package/scripts/setup-npmrc.js +39 -0
  57. package/scripts/skills-install.js +29 -0
  58. package/scripts/skills-manifest.js +61 -0
  59. package/scripts/skills-pack.js +54 -0
  60. package/scripts/skills-validate.js +118 -0
  61. package/skills/arch/SKILL.md +27 -0
  62. package/skills/arch/agents/openai.yaml +4 -0
  63. package/skills/build/SKILL.md +24 -0
  64. package/skills/build/agents/openai.yaml +4 -0
  65. package/skills/flow/SKILL.md +24 -0
  66. package/skills/flow/agents/openai.yaml +4 -0
  67. package/skills/index.json +40 -0
  68. package/skills/meta-architect/SKILL.md +30 -0
  69. package/skills/meta-architect/agents/openai.yaml +4 -0
  70. package/skills/meta-architect/references/core-release-rules.md +13 -0
  71. package/skills/sage/SKILL.md +24 -0
  72. package/skills/sage/agents/openai.yaml +4 -0
  73. package/skills/vet/SKILL.md +25 -0
  74. package/skills/vet/agents/openai.yaml +4 -0
  75. package/skills/vibe/SKILL.md +24 -0
  76. package/skills/vibe/agents/openai.yaml +4 -0
  77. package/sprint/00-idea.md +27 -0
  78. package/sprint/01-architecture.md +26 -0
  79. package/sprint/02-oss-evidence.md +26 -0
  80. package/sprint/03-logic.md +25 -0
  81. package/sprint/04-security.md +25 -0
  82. package/sprint/05-dx-ux.md +24 -0
  83. package/sprint/06-build-plan.md +26 -0
  84. package/sprint/07-release.md +26 -0
  85. package/src/build-gate.js +52 -0
  86. package/src/decision-log.js +40 -0
  87. package/src/fs-utils.js +25 -0
  88. package/src/launcher.js +55 -0
  89. package/src/mcp-config.js +30 -0
  90. package/src/mcp-live-client.js +186 -0
  91. package/src/paths.js +26 -0
  92. package/src/policy.js +23 -0
  93. package/src/release-state.js +59 -0
  94. package/src/runtime-artifacts.js +363 -0
  95. package/src/skill-installer.js +49 -0
  96. package/src/skills.js +507 -0
  97. package/src/state-sync.js +15 -0
@@ -0,0 +1,25 @@
1
+ # Sprint 4: Security
2
+
3
+ ## Goal
4
+
5
+ Record a baseline security pass and keep the workflow blocked if prerequisites are not satisfied.
6
+
7
+ ## Inputs
8
+
9
+ - `logic_status = GREEN`
10
+
11
+ ## Expected outputs
12
+
13
+ - `.ma/evidence/audits.json`
14
+ - `.ma/evidence/cves.json`
15
+ - `security_status = GREEN | RED`
16
+
17
+ ## Exit criteria
18
+
19
+ - baseline security findings are recorded
20
+ - blockers are explicit when the lane remains red
21
+
22
+ ## Failure conditions
23
+
24
+ - logic not green
25
+ - missing evidence output
@@ -0,0 +1,24 @@
1
+ # Sprint 5: DX/UX
2
+
3
+ ## Goal
4
+
5
+ Record a baseline developer and user experience pass before build unlock.
6
+
7
+ ## Inputs
8
+
9
+ - `security_status = GREEN`
10
+
11
+ ## Expected outputs
12
+
13
+ - `.ma/evidence/outcomes.json`
14
+ - `experience_status = GREEN | RED | WAIVED`
15
+
16
+ ## Exit criteria
17
+
18
+ - baseline experience notes are recorded
19
+ - waiver path is explicit when used
20
+
21
+ ## Failure conditions
22
+
23
+ - security not green
24
+ - no operator-facing or user-facing review output
@@ -0,0 +1,26 @@
1
+ # Sprint 6: Build Plan
2
+
3
+ ## Goal
4
+
5
+ Evaluate all required gates and prepare bounded implementation work.
6
+
7
+ ## Inputs
8
+
9
+ - required upstream gates green or waived per release policy
10
+
11
+ ## Expected outputs
12
+
13
+ - blocked or ready decision entry
14
+ - `build_status = READY` on success
15
+ - suggested `feature/*` branches
16
+ - optional `git worktree add` commands
17
+
18
+ ## Exit criteria
19
+
20
+ - build is either clearly blocked or clearly ready
21
+ - branch/worktree guidance is explicit when ready
22
+
23
+ ## Failure conditions
24
+
25
+ - missing or red gates
26
+ - invalid release-state file
@@ -0,0 +1,26 @@
1
+ # Sprint 7: Release
2
+
3
+ ## Goal
4
+
5
+ Move from completed bounded implementation to a controlled merge and release path.
6
+
7
+ ## Inputs
8
+
9
+ - `build_status = READY | RUNNING`
10
+ - valid branch origin
11
+
12
+ ## Expected outputs
13
+
14
+ - `merge_status = MERGED_TO_DEVELOPMENT`
15
+ - `release_status = SHIPPED_TO_PROD`
16
+
17
+ ## Exit criteria
18
+
19
+ - merge follows `feature/* -> development`
20
+ - release follows `development|release/* -> prod`
21
+
22
+ ## Failure conditions
23
+
24
+ - invalid merge target
25
+ - invalid release origin
26
+ - attempt to bypass protected release flow
@@ -0,0 +1,52 @@
1
+ const gateChecks = [
2
+ ["idea_status", "CLEAR", "ma idea"],
3
+ ["architecture_status", "APPROVED", "$arch"],
4
+ ["evidence_status", "VERIFIED", "$sage"],
5
+ ["logic_status", "GREEN", "$flow"],
6
+ ["security_status", "GREEN", "$vet"],
7
+ ];
8
+
9
+ export function evaluateBuildGate(releaseState) {
10
+ const blockers = [];
11
+
12
+ for (const [field, expected, trigger] of gateChecks) {
13
+ if (releaseState[field] !== expected) {
14
+ blockers.push({ field, expected, actual: releaseState[field], trigger });
15
+ }
16
+ }
17
+
18
+ const experienceAllowed =
19
+ releaseState.experience_status === "GREEN" || releaseState.experience_status === "WAIVED";
20
+ if (!experienceAllowed) {
21
+ blockers.push({
22
+ field: "experience_status",
23
+ expected: "GREEN or WAIVED",
24
+ actual: releaseState.experience_status,
25
+ trigger: "$vibe",
26
+ });
27
+ }
28
+
29
+ if (!["LOCKED", "READY"].includes(releaseState.build_status)) {
30
+ blockers.push({
31
+ field: "build_status",
32
+ expected: "LOCKED or READY",
33
+ actual: releaseState.build_status,
34
+ trigger: "$build",
35
+ });
36
+ }
37
+
38
+ return {
39
+ allowed: blockers.length === 0,
40
+ blockers,
41
+ };
42
+ }
43
+
44
+ export function formatBuildBlockers(evaluation) {
45
+ return evaluation.blockers.map(
46
+ (blocker) => `${blocker.field} must be ${blocker.expected}; current value is ${blocker.actual}`,
47
+ );
48
+ }
49
+
50
+ export function formatNextAllowedTriggers(evaluation) {
51
+ return [...new Set(evaluation.blockers.map((blocker) => blocker.trigger))];
52
+ }
@@ -0,0 +1,40 @@
1
+ import { readJson, writeJson } from "./fs-utils.js";
2
+ import { getRuntimeReadPath, getRuntimeWritePath } from "./paths.js";
3
+
4
+ function validateDecisionShape(entry) {
5
+ const required = ["decision", "status", "evidence", "blockers", "next_allowed_triggers"];
6
+ for (const field of required) {
7
+ if (!(field in entry)) {
8
+ throw new Error(`Decision entry missing field: ${field}`);
9
+ }
10
+ }
11
+ }
12
+
13
+ export async function loadDecisionLog() {
14
+ const parsed = await readJson(getRuntimeReadPath("decisions.json"));
15
+
16
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.decisions)) {
17
+ throw new Error("Decision log must be an object with a decisions array");
18
+ }
19
+
20
+ return parsed;
21
+ }
22
+
23
+ export async function appendDecision(entry) {
24
+ validateDecisionShape(entry);
25
+ const log = await loadDecisionLog();
26
+ log.decisions.push({
27
+ ...entry,
28
+ timestamp: new Date().toISOString(),
29
+ });
30
+
31
+ await writeJson(getRuntimeWritePath("decisions.json"), log);
32
+ }
33
+
34
+ export async function updateDecisionStatuses(statusUpdates) {
35
+ const log = await loadDecisionLog();
36
+ for (const [field, value] of Object.entries(statusUpdates)) {
37
+ log[field] = value;
38
+ }
39
+ await writeJson(getRuntimeWritePath("decisions.json"), log);
40
+ }
@@ -0,0 +1,25 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export async function readJson(filePath) {
5
+ const raw = await fs.readFile(filePath, "utf8");
6
+ return JSON.parse(raw);
7
+ }
8
+
9
+ export async function writeJson(filePath, value) {
10
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
11
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
12
+ }
13
+
14
+ export async function ensureDir(dirPath) {
15
+ await fs.mkdir(dirPath, { recursive: true });
16
+ }
17
+
18
+ export async function writeFileIfMissing(filePath, content) {
19
+ try {
20
+ await fs.access(filePath);
21
+ } catch {
22
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
23
+ await fs.writeFile(filePath, content);
24
+ }
25
+ }
@@ -0,0 +1,55 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+
4
+ const nativeCommands = new Set([
5
+ "setup",
6
+ "init",
7
+ "idea",
8
+ "skills",
9
+ "status",
10
+ "merge",
11
+ "release",
12
+ "run",
13
+ ]);
14
+
15
+ export function shouldDelegateToCodex(args) {
16
+ if (args.length === 0) {
17
+ return true;
18
+ }
19
+
20
+ return !nativeCommands.has(args[0]);
21
+ }
22
+
23
+ function normalizeCodexArgs(args) {
24
+ return args.filter((arg) => arg !== "--madmax" && arg !== "--high");
25
+ }
26
+
27
+ function resolveCodexCommand() {
28
+ if (process.env.MA_CODEX_BIN) {
29
+ return process.env.MA_CODEX_BIN;
30
+ }
31
+
32
+ return "codex";
33
+ }
34
+
35
+ export function runCodex(args) {
36
+ const codexCommand = resolveCodexCommand();
37
+ const codexArgs = normalizeCodexArgs(args);
38
+ const commandArgs =
39
+ path.extname(codexCommand) === ".js" || path.extname(codexCommand) === ".mjs"
40
+ ? [codexCommand, ...codexArgs]
41
+ : codexArgs;
42
+ const command = commandArgs[0] === codexCommand ? process.execPath : codexCommand;
43
+ const finalArgs = command === process.execPath ? commandArgs : codexArgs;
44
+ const result = spawnSync(command, finalArgs, { stdio: "inherit" });
45
+
46
+ if (result.error) {
47
+ if (result.error.code === "ENOENT") {
48
+ throw new Error("Codex CLI not found. Install it with: npm install -g @openai/codex");
49
+ }
50
+
51
+ throw new Error(`Failed to start Codex CLI: ${result.error.message}`);
52
+ }
53
+
54
+ return typeof result.status === "number" ? result.status : 1;
55
+ }
@@ -0,0 +1,30 @@
1
+ import fs from "node:fs/promises";
2
+ import { getMcpServersPath } from "./paths.js";
3
+
4
+ const gitMcpRepoPattern = /^https:\/\/gitmcp\.io\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
5
+
6
+ export function isValidGitMcpEndpoint(url) {
7
+ return gitMcpRepoPattern.test(url);
8
+ }
9
+
10
+ export async function loadMcpServers() {
11
+ const raw = await fs.readFile(getMcpServersPath(), "utf8");
12
+ const parsed = JSON.parse(raw);
13
+
14
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.servers)) {
15
+ throw new Error("mcp/servers.json must be an object with a servers array");
16
+ }
17
+
18
+ return parsed;
19
+ }
20
+
21
+ export async function validateMcpServers() {
22
+ const parsed = await loadMcpServers();
23
+ for (const server of parsed.servers) {
24
+ if (!isValidGitMcpEndpoint(server.endpoint)) {
25
+ throw new Error(`Invalid GitMCP endpoint: ${server.endpoint}`);
26
+ }
27
+ }
28
+
29
+ return parsed;
30
+ }
@@ -0,0 +1,186 @@
1
+ export class McpSseClient {
2
+ constructor(baseUrl) {
3
+ this.baseUrl = baseUrl;
4
+ this.controller = new AbortController();
5
+ this.reader = null;
6
+ this.buffer = "";
7
+ this.pending = new Map();
8
+ this.postUrl = null;
9
+ this.nextId = 1;
10
+ this.readLoopPromise = null;
11
+ }
12
+
13
+ async connect() {
14
+ const response = await fetch(this.baseUrl, {
15
+ headers: { accept: "text/event-stream" },
16
+ signal: this.controller.signal,
17
+ });
18
+
19
+ if (!response.ok || !response.body) {
20
+ throw new Error(`Failed to open MCP SSE stream: ${response.status}`);
21
+ }
22
+
23
+ this.reader = response.body.getReader();
24
+ const endpointEvent = await this.#withTimeout(
25
+ this.#readNextEvent(),
26
+ 5000,
27
+ "Timed out waiting for MCP endpoint event",
28
+ );
29
+ if (!endpointEvent || endpointEvent.event !== "endpoint") {
30
+ throw new Error("MCP server did not provide an endpoint event");
31
+ }
32
+
33
+ this.postUrl = `${this.baseUrl}${endpointEvent.data}`;
34
+ this.readLoopPromise = this.#startReadLoop();
35
+
36
+ const initResult = await this.request("initialize", {
37
+ protocolVersion: "2025-03-26",
38
+ capabilities: {},
39
+ clientInfo: {
40
+ name: "meta-architect",
41
+ version: "0.1.0",
42
+ },
43
+ });
44
+
45
+ await this.notify("notifications/initialized");
46
+ return initResult;
47
+ }
48
+
49
+ async request(method, params = {}) {
50
+ const id = this.nextId++;
51
+ const responsePromise = new Promise((resolve, reject) => {
52
+ const timeout = setTimeout(() => {
53
+ this.pending.delete(id);
54
+ reject(new Error(`Timed out waiting for MCP response to ${method}`));
55
+ }, 15000);
56
+
57
+ this.pending.set(id, {
58
+ resolve: (payload) => {
59
+ clearTimeout(timeout);
60
+ resolve(payload);
61
+ },
62
+ reject: (error) => {
63
+ clearTimeout(timeout);
64
+ reject(error);
65
+ },
66
+ });
67
+ });
68
+
69
+ await this.#post({
70
+ jsonrpc: "2.0",
71
+ id,
72
+ method,
73
+ params,
74
+ });
75
+
76
+ return responsePromise;
77
+ }
78
+
79
+ async notify(method, params = {}) {
80
+ await this.#post({
81
+ jsonrpc: "2.0",
82
+ method,
83
+ params,
84
+ });
85
+ }
86
+
87
+ async close() {
88
+ this.controller.abort();
89
+ if (this.readLoopPromise) {
90
+ try {
91
+ await this.readLoopPromise;
92
+ } catch {
93
+ // Ignore abort-related shutdown failures.
94
+ }
95
+ }
96
+ }
97
+
98
+ async #post(message) {
99
+ const response = await fetch(this.postUrl, {
100
+ method: "POST",
101
+ headers: { "content-type": "application/json" },
102
+ body: JSON.stringify(message),
103
+ signal: this.controller.signal,
104
+ });
105
+
106
+ if (!response.ok && response.status !== 202) {
107
+ throw new Error(`MCP POST failed: ${response.status}`);
108
+ }
109
+ }
110
+
111
+ async #startReadLoop() {
112
+ try {
113
+ while (true) {
114
+ const event = await this.#readNextEvent();
115
+ if (!event) {
116
+ break;
117
+ }
118
+
119
+ if (event.event !== "message") {
120
+ continue;
121
+ }
122
+
123
+ const payload = JSON.parse(event.data);
124
+ if (typeof payload.id === "number" && this.pending.has(payload.id)) {
125
+ const handlers = this.pending.get(payload.id);
126
+ this.pending.delete(payload.id);
127
+ if (payload.error) {
128
+ handlers.reject(new Error(payload.error.message ?? "Unknown MCP error"));
129
+ } else {
130
+ handlers.resolve(payload.result);
131
+ }
132
+ }
133
+ }
134
+ } finally {
135
+ for (const [, handlers] of this.pending) {
136
+ handlers.reject(new Error("MCP stream closed"));
137
+ }
138
+ this.pending.clear();
139
+ }
140
+ }
141
+
142
+ async #readNextEvent() {
143
+ while (true) {
144
+ const result = await this.reader.read();
145
+ if (result.done) {
146
+ return null;
147
+ }
148
+
149
+ this.buffer += Buffer.from(result.value).toString("utf8");
150
+ const index = this.buffer.indexOf("\n\n");
151
+ if (index === -1) {
152
+ continue;
153
+ }
154
+
155
+ const rawEvent = this.buffer.slice(0, index);
156
+ this.buffer = this.buffer.slice(index + 2);
157
+ return parseSseEvent(rawEvent);
158
+ }
159
+ }
160
+
161
+ async #withTimeout(promise, timeoutMs, message) {
162
+ return Promise.race([
163
+ promise,
164
+ new Promise((_, reject) => {
165
+ const timeout = setTimeout(() => {
166
+ clearTimeout(timeout);
167
+ reject(new Error(message));
168
+ }, timeoutMs);
169
+ }),
170
+ ]);
171
+ }
172
+ }
173
+
174
+ export function parseSseEvent(rawEvent) {
175
+ const event = { event: "message", data: "" };
176
+ for (const line of rawEvent.split("\n")) {
177
+ if (line.startsWith("event:")) {
178
+ event.event = line.slice("event:".length).trim();
179
+ } else if (line.startsWith("data:")) {
180
+ event.data += `${line.slice("data:".length).trim()}\n`;
181
+ }
182
+ }
183
+
184
+ event.data = event.data.trimEnd();
185
+ return event;
186
+ }
package/src/paths.js ADDED
@@ -0,0 +1,26 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ const __filename = fileURLToPath(import.meta.url);
5
+ const __dirname = path.dirname(__filename);
6
+
7
+ export const packageRoot = path.resolve(__dirname, "..");
8
+ export function getRepoRoot() {
9
+ return process.env.MA_ROOT ? path.resolve(process.env.MA_ROOT) : process.cwd();
10
+ }
11
+
12
+ export function getRuntimeRoot() {
13
+ return path.join(getRepoRoot(), ".ma");
14
+ }
15
+
16
+ export function getRuntimeWritePath(...parts) {
17
+ return path.join(getRuntimeRoot(), ...parts);
18
+ }
19
+
20
+ export function getRuntimeReadPath(...parts) {
21
+ return path.join(getRuntimeRoot(), ...parts);
22
+ }
23
+
24
+ export function getMcpServersPath() {
25
+ return path.join(getRepoRoot(), "mcp", "servers.json");
26
+ }
package/src/policy.js ADDED
@@ -0,0 +1,23 @@
1
+ export function validateReleaseOrigin(originBranch) {
2
+ if (originBranch === "development") {
3
+ return true;
4
+ }
5
+
6
+ if (originBranch.startsWith("release/")) {
7
+ return true;
8
+ }
9
+
10
+ return false;
11
+ }
12
+
13
+ export function rejectsDirectProdPromotion(originBranch) {
14
+ return originBranch.startsWith("feature/");
15
+ }
16
+
17
+ export function validateMergeTarget(sourceBranch, targetBranch) {
18
+ return sourceBranch.startsWith("feature/") && targetBranch === "development";
19
+ }
20
+
21
+ export function canMarkBuildDone(releaseState) {
22
+ return ["READY", "RUNNING"].includes(releaseState.build_status);
23
+ }
@@ -0,0 +1,59 @@
1
+ import fs from "node:fs/promises";
2
+ import { writeJson } from "./fs-utils.js";
3
+ import { getRuntimeReadPath, getRuntimeWritePath } from "./paths.js";
4
+
5
+ const allowedStatuses = {
6
+ idea_status: ["DRAFT", "CLEAR", "BLOCKED"],
7
+ architecture_status: ["DRAFT", "REVIEWED", "APPROVED"],
8
+ evidence_status: ["MISSING", "PARTIAL", "VERIFIED"],
9
+ logic_status: ["PENDING", "GREEN", "RED"],
10
+ security_status: ["PENDING", "GREEN", "RED"],
11
+ experience_status: ["PENDING", "GREEN", "RED", "WAIVED"],
12
+ build_status: ["LOCKED", "READY", "RUNNING", "DONE"],
13
+ merge_status: ["LOCKED", "READY", "MERGED_TO_DEVELOPMENT"],
14
+ release_status: ["LOCKED", "READY", "SHIPPED_TO_PROD"],
15
+ };
16
+
17
+ function validateWaiver(state) {
18
+ if (state.experience_status !== "WAIVED") {
19
+ return;
20
+ }
21
+
22
+ const waiver = state.waiver;
23
+ if (!waiver?.reason || !waiver.actor || !waiver.timestamp) {
24
+ throw new Error(
25
+ "experience_status=WAIVED requires waiver.reason, waiver.actor, and waiver.timestamp",
26
+ );
27
+ }
28
+ }
29
+
30
+ export function validateReleaseState(state) {
31
+ if (!state || typeof state !== "object" || Array.isArray(state)) {
32
+ throw new Error("Release state must be an object");
33
+ }
34
+
35
+ for (const [field, allowed] of Object.entries(allowedStatuses)) {
36
+ if (!allowed.includes(state[field])) {
37
+ throw new Error(`Invalid ${field}: ${state[field]}`);
38
+ }
39
+ }
40
+
41
+ validateWaiver(state);
42
+ return state;
43
+ }
44
+
45
+ export async function loadReleaseState() {
46
+ const raw = await fs.readFile(getRuntimeReadPath("release.json"), "utf8");
47
+ const parsed = JSON.parse(raw);
48
+ return validateReleaseState(parsed);
49
+ }
50
+
51
+ export async function saveReleaseState(state) {
52
+ validateReleaseState(state);
53
+ const next = {
54
+ ...state,
55
+ updatedAt: new Date().toISOString(),
56
+ };
57
+ await writeJson(getRuntimeWritePath("release.json"), next);
58
+ return next;
59
+ }