release-skill 0.1.1

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 (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,735 @@
1
+ /**
2
+ * Release plan schema validation, digest calculation, and atomic write.
3
+ *
4
+ * Provides:
5
+ * - `validatePlan(plan)` -- validates a plan object against the release-plan schema
6
+ * - `computePlanDigest(plan)` -- deterministic SHA-256 digest of the plan
7
+ * - `writePlanAtomic(planPath, plan)` -- compute digest, embed it, validate, and
8
+ * write the plan atomically (temp-file + rename)
9
+ *
10
+ * Schema authority: the release-plan schema is loaded from the package's
11
+ * `schemas/release-plan.schema.json` file at module init time. The root
12
+ * workspace `schemas/release-plan.schema.json` is the generation source;
13
+ * both copies are kept byte-identical.
14
+ *
15
+ * @module core/plan
16
+ */
17
+
18
+ import { readFile, writeFile, rename, mkdir, open, link, unlink, lstat } from 'node:fs/promises';
19
+ import { basename, dirname, join, resolve, parse, sep } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import Ajv from 'ajv';
22
+ import addFormats from 'ajv-formats';
23
+ import { canonicalJson, sha256Hex } from './digest.mjs';
24
+ import { ReleaseError, GATE_FAILED } from './errors.mjs';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Schema loaded from the authoritative JSON file (single source of truth)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const __dirname = dirname(fileURLToPath(import.meta.url));
31
+ const SCHEMA_PATH = join(__dirname, '..', '..', 'schemas', 'release-plan.schema.json');
32
+ const RELEASE_PLAN_SCHEMA = JSON.parse(await readFile(SCHEMA_PATH, 'utf8'));
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Schema validator (compiled once at module init)
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const ajv = new Ajv({ allErrors: true, strict: false });
39
+ addFormats(ajv);
40
+ const validatePlanSchema = ajv.compile(RELEASE_PLAN_SCHEMA);
41
+
42
+ async function assertNoSymlinkAncestors(directory) {
43
+ const absolute = resolve(directory);
44
+ const parsed = parse(absolute);
45
+ const segments = absolute.slice(parsed.root.length).split(sep).filter(Boolean);
46
+ const releaseIndex = segments.lastIndexOf('.release-skill');
47
+ // Production authority is rooted below `.release-skill`, so inspect every
48
+ // project-owned component from that anchor. Non-production custom outputs
49
+ // inspect their target directory only; platform prefixes such as macOS
50
+ // `/tmp -> /private/tmp` are outside project authority and are allowed.
51
+ const firstChecked = releaseIndex >= 0 ? releaseIndex : Math.max(0, segments.length - 1);
52
+ let current = join(parsed.root, ...segments.slice(0, firstChecked));
53
+ for (const segment of segments.slice(firstChecked)) {
54
+ current = join(current, segment);
55
+ let stat;
56
+ try {
57
+ stat = await lstat(current);
58
+ } catch (error) {
59
+ if (error.code === 'ENOENT') continue;
60
+ throw error;
61
+ }
62
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
63
+ throw new ReleaseError(
64
+ GATE_FAILED,
65
+ 'release authority path contains a symlink or non-directory ancestor',
66
+ { directory: absolute, unsafeAncestor: current },
67
+ );
68
+ }
69
+ }
70
+ }
71
+
72
+ export async function prepareAuthorityDirectory(directory) {
73
+ const absolute = resolve(directory);
74
+ await assertNoSymlinkAncestors(absolute);
75
+ await mkdir(absolute, { recursive: true });
76
+ await assertNoSymlinkAncestors(absolute);
77
+ return absolute;
78
+ }
79
+
80
+ export async function assertAuthorityFileTarget(filePath) {
81
+ const absolute = resolve(filePath);
82
+ await prepareAuthorityDirectory(dirname(absolute));
83
+ try {
84
+ const stat = await lstat(absolute);
85
+ if (stat.isSymbolicLink() || !stat.isFile()) {
86
+ throw new ReleaseError(
87
+ GATE_FAILED,
88
+ 'release authority target must be a regular file, never a symlink or special file',
89
+ { filePath: absolute },
90
+ );
91
+ }
92
+ } catch (error) {
93
+ if (error.code !== 'ENOENT') throw error;
94
+ }
95
+ return absolute;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Public API
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /** Require production consumers to use the digest-addressed plan authority. */
103
+ export function assertImmutablePlanAuthority(planPath, plan) {
104
+ if (!plan?.production) return;
105
+ const digest = computePlanDigest(plan);
106
+ const absolute = resolve(planPath);
107
+ if (basename(dirname(absolute)) !== 'plans' || basename(absolute) !== `${digest}.json`) {
108
+ throw new ReleaseError(
109
+ GATE_FAILED,
110
+ 'production commands require plans/<planDigest>.json immutable authority; mutable release-plan.json aliases are not accepted',
111
+ { planPath, expectedFile: `plans/${digest}.json` },
112
+ );
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Validate a plan object against the embedded release-plan schema.
118
+ *
119
+ * @param {object} plan - The plan object to validate.
120
+ * @throws {ReleaseError} GATE_FAILED if the plan does not match the schema.
121
+ */
122
+ export function validatePlan(plan) {
123
+ const valid = validatePlanSchema(plan);
124
+ if (!valid) {
125
+ const errors = validatePlanSchema.errors ?? [];
126
+ const summary = errors
127
+ .map((e) => `${e.instancePath || '/'}: ${e.message}`)
128
+ .join('; ');
129
+ throw new ReleaseError(
130
+ GATE_FAILED,
131
+ `release plan schema validation failed: ${summary}`,
132
+ { validationErrors: errors },
133
+ );
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Compute a deterministic SHA-256 digest of a plan object.
139
+ *
140
+ * The digest is computed from the canonical JSON of the plan. The `digest`
141
+ * field itself is excluded from the computation so that the digest is
142
+ * self-consistent: `computePlanDigest(plan) === plan.digest` when the plan
143
+ * was written by `writePlanAtomic`.
144
+ *
145
+ * @param {object} plan - A plan object (must not include a `digest` field,
146
+ * or the field will be stripped before hashing).
147
+ * @returns {string} Lowercase 64-char hex SHA-256 digest.
148
+ */
149
+ export function computePlanDigest(plan) {
150
+ // Strip the digest field if present so the hash is self-consistent.
151
+ const { digest: _digest, ...rest } = plan;
152
+ return sha256Hex(canonicalJson(rest));
153
+ }
154
+
155
+ /**
156
+ * Write a release plan atomically to disk.
157
+ *
158
+ * Steps:
159
+ * 1. Compute the plan's deterministic digest.
160
+ * 2. Embed the digest into the plan object.
161
+ * 3. Validate the augmented plan against the release-plan schema.
162
+ * 4. Serialise to pretty-printed JSON.
163
+ * 5. Write to a temporary file in the same directory.
164
+ * 6. Rename (atomic on POSIX) to the final path.
165
+ *
166
+ * @param {string} planPath - Absolute path to write the plan to.
167
+ * @param {object} plan - The plan object (must not already include `digest`).
168
+ * @returns {Promise<{ planPath: string, planDigest: string }>}
169
+ *
170
+ * @throws {ReleaseError} GATE_FAILED on schema validation failure.
171
+ */
172
+ export async function writePlanAtomic(planPath, plan) {
173
+ // 1. Compute digest
174
+ const planDigest = computePlanDigest(plan);
175
+
176
+ // 2. Embed digest
177
+ const augmented = { ...plan, digest: planDigest };
178
+
179
+ // 3. Validate
180
+ validatePlan(augmented);
181
+
182
+ // 4. Serialise
183
+ const json = JSON.stringify(augmented, null, 2);
184
+
185
+ // 5. Write to temp file in the same directory
186
+ const dir = dirname(planPath);
187
+ await prepareAuthorityDirectory(dir);
188
+ await assertAuthorityFileTarget(planPath);
189
+ const tmpPath = `${dir}/.release-plan-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`;
190
+ await writeFile(tmpPath, json, 'utf8');
191
+
192
+ // 6. Atomic rename
193
+ await rename(tmpPath, planPath);
194
+
195
+ return { planPath, planDigest };
196
+ }
197
+
198
+ /**
199
+ * Create a digest-addressed plan authority exactly once.
200
+ *
201
+ * The target must be named by the computed digest. An existing byte-identical
202
+ * authority is reused; an existing divergent file fails closed. A temporary
203
+ * file is fsynced and atomically linked into place so concurrent prepares can
204
+ * never replace an authority that another process already created.
205
+ */
206
+ export async function writePlanImmutable(planPath, plan) {
207
+ const planDigest = computePlanDigest(plan);
208
+ const augmented = { ...plan, digest: planDigest };
209
+ validatePlan(augmented);
210
+ const json = JSON.stringify(augmented, null, 2);
211
+
212
+ if (!planPath.endsWith(`${planDigest}.json`)) {
213
+ throw new ReleaseError(
214
+ GATE_FAILED,
215
+ 'immutable plan path must be named with the plan digest',
216
+ { planPath, planDigest },
217
+ );
218
+ }
219
+
220
+ const dir = dirname(planPath);
221
+ await prepareAuthorityDirectory(dir);
222
+ await assertAuthorityFileTarget(planPath);
223
+ const tmpPath = join(dir, `.release-plan-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);
224
+ const handle = await open(tmpPath, 'wx', 0o600);
225
+ try {
226
+ await handle.writeFile(json, 'utf8');
227
+ await handle.sync();
228
+ } finally {
229
+ await handle.close();
230
+ }
231
+
232
+ try {
233
+ await link(tmpPath, planPath);
234
+ } catch (error) {
235
+ if (error.code !== 'EEXIST') throw error;
236
+ const existing = await readFile(planPath, 'utf8');
237
+ if (existing !== json) {
238
+ throw new ReleaseError(
239
+ GATE_FAILED,
240
+ 'immutable plan authority already exists with different bytes',
241
+ { planPath, planDigest },
242
+ );
243
+ }
244
+ } finally {
245
+ await unlink(tmpPath).catch(() => {});
246
+ }
247
+
248
+ return { planPath, planDigest };
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Plan action completeness gate
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /** Expected adapter for each action type. */
256
+ const EXPECTED_ADAPTER = {
257
+ 'push-snapshot': 'git-github',
258
+ 'create-tag': 'git-github',
259
+ 'github-release': 'github',
260
+ 'npm-publish': 'npm',
261
+ 'claude-marketplace-install': 'plugin-marketplace',
262
+ 'codex-marketplace-install': 'plugin-marketplace',
263
+ };
264
+
265
+ /** Required action types for every unit. */
266
+ const REQUIRED_ACTION_TYPES = ['push-snapshot', 'create-tag', 'github-release'];
267
+
268
+ /**
269
+ * Derive the expected actions from plan units and validate completeness.
270
+ *
271
+ * For each unit the plan must contain exactly:
272
+ * - 1 push-snapshot (adapter: git-github)
273
+ * - 1 create-tag (adapter: git-github)
274
+ * - 1 github-release (adapter: github)
275
+ * - 1 npm-publish (adapter: npm) — only if the unit has an npm distribution
276
+ * - 1 claude-marketplace-install (adapter: plugin-marketplace) — only if the unit has a claude-plugin distribution
277
+ * - 1 codex-marketplace-install (adapter: plugin-marketplace) — only if the unit has a codex-plugin distribution
278
+ *
279
+ * Every action must bind to the correct unitId, correct adapter, correct
280
+ * version, correct publicRepo (where applicable), and correct tag derived
281
+ * from tagTemplate. No missing, extra, duplicate, or mismatched actions
282
+ * are allowed.
283
+ *
284
+ * The tag check requires exact equality between expected.tag and the
285
+ * tagTemplate-expanded tag. This eliminates the substring vulnerability
286
+ * (e.g. v0.0.10 must not match expected version 0.0.1).
287
+ *
288
+ * @param {object} plan - A validated release plan object.
289
+ * @returns {{ passed: boolean, details: { failures: string[], expectedCount: number, actualCount: number } }}
290
+ */
291
+ export function validatePlanActionCompleteness(plan) {
292
+ const failures = [];
293
+
294
+ if (!plan || typeof plan !== 'object') {
295
+ return { passed: false, details: { failures: ['plan is null or not an object'], expectedCount: 0, actualCount: 0 } };
296
+ }
297
+
298
+ const units = Array.isArray(plan.units) ? plan.units : [];
299
+ const actions = Array.isArray(plan.externalActions) ? plan.externalActions : [];
300
+
301
+ // --- Must have at least one action ---
302
+ if (actions.length === 0) {
303
+ failures.push('plan has no external actions; at least one is required');
304
+ return { passed: false, details: { failures, expectedCount: 0, actualCount: 0 } };
305
+ }
306
+
307
+ // --- Validate each unit's required actions ---
308
+ let expectedCount = 0;
309
+ const seenUnitIds = new Set();
310
+
311
+ for (const unit of units) {
312
+ const unitId = unit.id;
313
+ const targetVersion = unit.targetVersion;
314
+ const publicRepo = unit.publicRepo;
315
+ const tagTemplate = unit.tagTemplate;
316
+ const distributions = unit.distributions ?? [];
317
+ const production = plan.production?.mode === 'github-npm-v1';
318
+ const frozen = unit.frozenSnapshot;
319
+ const productionConfig = unit.productionConfig ?? {};
320
+ const expectedTag = tagTemplate
321
+ ? tagTemplate.replace('{version}', targetVersion ?? '')
322
+ : null;
323
+
324
+ // Check unit has required fields
325
+ if (!unitId) {
326
+ failures.push(`unit is missing id`);
327
+ continue;
328
+ }
329
+
330
+ // Check for duplicate unit.id
331
+ if (seenUnitIds.has(unitId)) {
332
+ failures.push(`duplicate unit id "${unitId}"`);
333
+ continue;
334
+ }
335
+ seenUnitIds.add(unitId);
336
+ if (!targetVersion) {
337
+ failures.push(`unit "${unitId}" is missing targetVersion`);
338
+ }
339
+ if (!publicRepo) {
340
+ failures.push(`unit "${unitId}" is missing publicRepo`);
341
+ }
342
+ if (!tagTemplate) {
343
+ failures.push(`unit "${unitId}" is missing tagTemplate`);
344
+ }
345
+ if (production && !frozen) {
346
+ failures.push(`unit "${unitId}" is missing frozenSnapshot for production publish`);
347
+ }
348
+ if (production && frozen) {
349
+ const expectedBranch = (productionConfig.branchTemplate ?? 'release/{tag}')
350
+ .replaceAll('{tag}', expectedTag ?? '')
351
+ .replaceAll('{version}', targetVersion ?? '')
352
+ .replaceAll('{unit}', unitId);
353
+ if (frozen.branch !== expectedBranch) {
354
+ failures.push(`unit "${unitId}" frozen branch does not match productionConfig.branchTemplate`);
355
+ }
356
+ }
357
+
358
+ // Required actions for this unit (always required)
359
+ for (const actionType of REQUIRED_ACTION_TYPES) {
360
+ expectedCount++;
361
+ const expectedAdapter = EXPECTED_ADAPTER[actionType];
362
+ const expectedActionId = `${actionType}-${unitId}`;
363
+ const matchingActions = actions.filter(
364
+ (a) => a.unitId === unitId && a.type === actionType,
365
+ );
366
+
367
+ if (matchingActions.length === 0) {
368
+ failures.push(
369
+ `unit "${unitId}": required action type "${actionType}" is missing (expected adapter "${expectedAdapter}")`,
370
+ );
371
+ } else if (matchingActions.length > 1) {
372
+ failures.push(
373
+ `unit "${unitId}": duplicate action type "${actionType}" (${matchingActions.length} found, expected 1)`,
374
+ );
375
+ } else {
376
+ // Validate the single matching action
377
+ const action = matchingActions[0];
378
+
379
+ // --- Strict common field checks ---
380
+ if (action.id !== expectedActionId) {
381
+ failures.push(
382
+ `unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
383
+ );
384
+ }
385
+ if (action.unitId !== unitId) {
386
+ failures.push(
387
+ `unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
388
+ );
389
+ }
390
+ if (action.adapter !== expectedAdapter) {
391
+ failures.push(
392
+ `unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "${expectedAdapter}"`,
393
+ );
394
+ }
395
+ if (action.status !== 'PENDING') {
396
+ failures.push(
397
+ `unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
398
+ );
399
+ }
400
+
401
+ // --- Type-specific strict parameter checks ---
402
+ if (actionType === 'push-snapshot') {
403
+ _checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
404
+ _checkRequired(action, 'parameters.source', action.parameters?.source, unit.source, unitId, failures);
405
+ _checkRequired(action, 'parameters.cwd', action.parameters?.cwd, unit.source, unitId, failures);
406
+ _checkRequired(action, 'parameters.publicRepo', action.parameters?.publicRepo, publicRepo, unitId, failures);
407
+ if (production) {
408
+ _checkRequired(action, 'parameters.snapshotPath', action.parameters?.snapshotPath, frozen?.path, unitId, failures);
409
+ _checkRequired(action, 'parameters.manifestDigest', action.parameters?.manifestDigest, frozen?.manifestDigest, unitId, failures);
410
+ _checkRequired(action, 'parameters.gitObjectDir', action.parameters?.gitObjectDir, frozen?.gitObjectDir, unitId, failures);
411
+ _checkRequired(action, 'parameters.branch', action.parameters?.branch, frozen?.branch, unitId, failures);
412
+ _checkRequired(action, 'parameters.commit', action.parameters?.commit, frozen?.commit, unitId, failures);
413
+ _checkRequired(action, 'parameters.tree', action.parameters?.tree, frozen?.tree, unitId, failures);
414
+ _checkRequired(action, 'parameters.githubHost', action.parameters?.githubHost, productionConfig.githubHost ?? 'github.com', unitId, failures);
415
+ _checkRequired(action, 'expected.commit', action.expected?.commit, frozen?.commit, unitId, failures);
416
+ _checkRequired(action, 'expected.tree', action.expected?.tree, frozen?.tree, unitId, failures);
417
+ _checkRequired(action, 'expected.manifestDigest', action.expected?.manifestDigest, frozen?.manifestDigest, unitId, failures);
418
+ } else {
419
+ _checkRequired(action, 'expected.tag', action.expected?.tag, expectedTag, unitId, failures);
420
+ }
421
+ }
422
+
423
+ if (actionType === 'create-tag') {
424
+ _checkRequired(action, 'parameters.tagTemplate', action.parameters?.tagTemplate, tagTemplate, unitId, failures);
425
+ _checkRequired(action, 'parameters.publicRepo', action.parameters?.publicRepo, publicRepo, unitId, failures);
426
+ _checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
427
+ if (production) {
428
+ _checkRequired(action, 'parameters.tag', action.parameters?.tag, expectedTag, unitId, failures);
429
+ _checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
430
+ _checkRequired(action, 'parameters.gitObjectDir', action.parameters?.gitObjectDir, frozen?.gitObjectDir, unitId, failures);
431
+ _checkRequired(action, 'parameters.commit', action.parameters?.commit, frozen?.commit, unitId, failures);
432
+ _checkRequired(action, 'parameters.githubHost', action.parameters?.githubHost, productionConfig.githubHost ?? 'github.com', unitId, failures);
433
+ _checkRequired(action, 'expected.commit', action.expected?.commit, frozen?.commit, unitId, failures);
434
+ }
435
+ }
436
+
437
+ if (actionType === 'github-release') {
438
+ _checkRequired(action, 'parameters.publicRepo', action.parameters?.publicRepo, publicRepo, unitId, failures);
439
+ _checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
440
+ if (production) {
441
+ _checkRequired(action, 'parameters.tag', action.parameters?.tag, expectedTag, unitId, failures);
442
+ _checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
443
+ _checkRequired(action, 'parameters.commit', action.parameters?.commit, frozen?.commit, unitId, failures);
444
+ _checkRequired(action, 'parameters.githubHost', action.parameters?.githubHost, productionConfig.githubHost ?? 'github.com', unitId, failures);
445
+ const expectedName = (productionConfig.releaseTitleTemplate ?? 'Release {tag}')
446
+ .replaceAll('{tag}', expectedTag ?? '')
447
+ .replaceAll('{version}', targetVersion ?? '')
448
+ .replaceAll('{unit}', unitId);
449
+ _checkRequired(action, 'parameters.name', action.parameters?.name, expectedName, unitId, failures);
450
+ _checkRequired(action, 'parameters.notes', action.parameters?.notes, productionConfig.releaseNotes ?? `Release ${expectedTag}`, unitId, failures);
451
+ _checkRequired(action, 'expected.tag', action.expected?.tag, expectedTag, unitId, failures);
452
+ _checkRequired(action, 'expected.commit', action.expected?.commit, frozen?.commit, unitId, failures);
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ // npm-publish: only required if unit has npm distribution
459
+ const npmDist = distributions.find((d) => d.type === 'npm');
460
+ if (npmDist) {
461
+ // Validate npm distribution has a package name
462
+ if (!npmDist.package || typeof npmDist.package !== 'string') {
463
+ failures.push(
464
+ `unit "${unitId}": npm distribution is missing "package" name`,
465
+ );
466
+ }
467
+ expectedCount++;
468
+ const expectedPkg = npmDist.package;
469
+ const npmActions = actions.filter(
470
+ (a) => a.unitId === unitId && a.type === 'npm-publish',
471
+ );
472
+
473
+ if (npmActions.length === 0) {
474
+ failures.push(
475
+ `unit "${unitId}": npm distribution declared but "npm-publish" action is missing`,
476
+ );
477
+ } else if (npmActions.length > 1) {
478
+ failures.push(
479
+ `unit "${unitId}": duplicate npm-publish actions (${npmActions.length} found, expected 1)`,
480
+ );
481
+ } else {
482
+ const action = npmActions[0];
483
+ const expectedActionId = `npm-publish-${unitId}`;
484
+
485
+ // --- Strict common field checks ---
486
+ if (action.id !== expectedActionId) {
487
+ failures.push(
488
+ `unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
489
+ );
490
+ }
491
+ if (action.unitId !== unitId) {
492
+ failures.push(
493
+ `unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
494
+ );
495
+ }
496
+ if (action.adapter !== 'npm') {
497
+ failures.push(
498
+ `unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "npm"`,
499
+ );
500
+ }
501
+ if (action.status !== 'PENDING') {
502
+ failures.push(
503
+ `unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
504
+ );
505
+ }
506
+
507
+ // --- Strict parameter checks ---
508
+ _checkRequired(action, 'parameters.package', action.parameters?.package, expectedPkg, unitId, failures);
509
+ _checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
510
+ _checkRequired(action, 'parameters.cwd', action.parameters?.cwd, unit.source, unitId, failures);
511
+ _checkRequired(action, 'expected.package', action.expected?.package, expectedPkg, unitId, failures);
512
+ _checkRequired(action, 'expected.version', action.expected?.version, targetVersion, unitId, failures);
513
+
514
+ // Registry and publisher identity checks (always required for npm)
515
+ const expectedRegistry = npmDist.registry;
516
+ const expectedPublisher = npmDist.publisher;
517
+ if (!expectedRegistry || typeof expectedRegistry !== 'string') {
518
+ failures.push(`unit "${unitId}": npm distribution is missing "registry"`);
519
+ } else {
520
+ _checkRequired(action, 'parameters.registry', action.parameters?.registry, expectedRegistry, unitId, failures);
521
+ _checkRequired(action, 'expected.registry', action.expected?.registry, expectedRegistry, unitId, failures);
522
+ }
523
+ if (!expectedPublisher || typeof expectedPublisher !== 'string') {
524
+ failures.push(`unit "${unitId}": npm distribution is missing "publisher"`);
525
+ } else {
526
+ _checkRequired(action, 'parameters.publisher', action.parameters?.publisher, expectedPublisher, unitId, failures);
527
+ _checkRequired(action, 'expected.publisher', action.expected?.publisher, expectedPublisher, unitId, failures);
528
+ }
529
+
530
+ if (production) {
531
+ _checkRequired(action, 'parameters.tarballPath', action.parameters?.tarballPath, frozen?.npm?.tarballPath, unitId, failures);
532
+ _checkRequired(action, 'parameters.tarballSha256', action.parameters?.tarballSha256, frozen?.npm?.tarballSha256, unitId, failures);
533
+ _checkRequired(action, 'parameters.integrity', action.parameters?.integrity, frozen?.npm?.integrity, unitId, failures);
534
+ _checkRequired(action, 'parameters.access', action.parameters?.access, npmDist.access, unitId, failures);
535
+ _checkRequired(action, 'parameters.provenance', action.parameters?.provenance, npmDist.provenance === true, unitId, failures);
536
+ if ((action.parameters?.tag ?? null) !== (npmDist.tag ?? null)) {
537
+ failures.push(`unit "${unitId}", action "${action.id}": parameters.tag does not match npm distribution tag`);
538
+ }
539
+ _checkRequired(action, 'expected.integrity', action.expected?.integrity, frozen?.npm?.integrity, unitId, failures);
540
+ }
541
+ }
542
+ }
543
+
544
+ // claude-marketplace-install: only required if unit has claude-plugin distribution
545
+ const claudeDist = distributions.find((d) => d.type === 'claude-plugin');
546
+ if (claudeDist) {
547
+ const plugin = claudeDist.plugin;
548
+ const marketplace = claudeDist.marketplace;
549
+ const entrySkill = claudeDist.entrySkill;
550
+ if (!plugin || !marketplace || !entrySkill) {
551
+ failures.push(`unit "${unitId}": claude-plugin distribution requires plugin, marketplace, and entrySkill`);
552
+ }
553
+ expectedCount++;
554
+ const expectedActionId = `claude-marketplace-install-${unitId}`;
555
+ const claudeActions = actions.filter(
556
+ (a) => a.unitId === unitId && a.type === 'claude-marketplace-install',
557
+ );
558
+
559
+ if (claudeActions.length === 0) {
560
+ failures.push(
561
+ `unit "${unitId}": claude-plugin distribution declared but "claude-marketplace-install" action is missing`,
562
+ );
563
+ } else if (claudeActions.length > 1) {
564
+ failures.push(
565
+ `unit "${unitId}": duplicate claude-marketplace-install actions (${claudeActions.length} found, expected 1)`,
566
+ );
567
+ } else {
568
+ const action = claudeActions[0];
569
+
570
+ if (action.id !== expectedActionId) {
571
+ failures.push(
572
+ `unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
573
+ );
574
+ }
575
+ if (action.unitId !== unitId) {
576
+ failures.push(
577
+ `unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
578
+ );
579
+ }
580
+ if (action.adapter !== 'plugin-marketplace') {
581
+ failures.push(
582
+ `unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "plugin-marketplace"`,
583
+ );
584
+ }
585
+ if (action.status !== 'PENDING') {
586
+ failures.push(
587
+ `unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
588
+ );
589
+ }
590
+
591
+ // Parameter checks
592
+ _checkRequired(action, 'parameters.consumer', action.parameters?.consumer, 'claude', unitId, failures);
593
+ _checkRequired(action, 'parameters.plugin', action.parameters?.plugin, plugin, unitId, failures);
594
+ _checkRequired(action, 'parameters.marketplace', action.parameters?.marketplace, marketplace, unitId, failures);
595
+ _checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
596
+ _checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
597
+ _checkRequired(action, 'parameters.entrySkill', action.parameters?.entrySkill, entrySkill, unitId, failures);
598
+ if (production) {
599
+ _checkRequired(action, 'parameters.snapshotPath', action.parameters?.snapshotPath, frozen?.path, unitId, failures);
600
+ _checkRequired(action, 'parameters.ref', action.parameters?.ref, expectedTag, unitId, failures);
601
+ _checkRequired(action, 'parameters.manifestDigest', action.parameters?.manifestDigest, frozen?.manifestDigest, unitId, failures);
602
+ }
603
+
604
+ // Expected checks
605
+ _checkRequired(action, 'expected.installed', action.expected?.installed, true, unitId, failures);
606
+ _checkRequired(action, 'expected.plugin', action.expected?.plugin, plugin, unitId, failures);
607
+ _checkRequired(action, 'expected.marketplace', action.expected?.marketplace, marketplace, unitId, failures);
608
+ _checkRequired(action, 'expected.version', action.expected?.version, targetVersion, unitId, failures);
609
+ _checkRequired(action, 'expected.entrySkill', action.expected?.entrySkill, entrySkill, unitId, failures);
610
+ if (production) {
611
+ _checkRequired(action, 'expected.consumer', action.expected?.consumer, 'claude', unitId, failures);
612
+ _checkRequired(action, 'expected.repo', action.expected?.repo, publicRepo, unitId, failures);
613
+ _checkRequired(action, 'expected.ref', action.expected?.ref, expectedTag, unitId, failures);
614
+ _checkRequired(action, 'expected.entrySkillFound', action.expected?.entrySkillFound, true, unitId, failures);
615
+ _checkRequired(action, 'expected.manifestDigest', action.expected?.manifestDigest, frozen?.manifestDigest, unitId, failures);
616
+ }
617
+ }
618
+ }
619
+
620
+ // codex-marketplace-install: only required if unit has codex-plugin distribution
621
+ const codexDist = distributions.find((d) => d.type === 'codex-plugin');
622
+ if (codexDist) {
623
+ const plugin = codexDist.plugin;
624
+ const marketplace = codexDist.marketplace;
625
+ const entrySkill = codexDist.entrySkill;
626
+ if (!plugin || !marketplace || !entrySkill) {
627
+ failures.push(`unit "${unitId}": codex-plugin distribution requires plugin, marketplace, and entrySkill`);
628
+ }
629
+ expectedCount++;
630
+ const expectedActionId = `codex-marketplace-install-${unitId}`;
631
+ const codexActions = actions.filter(
632
+ (a) => a.unitId === unitId && a.type === 'codex-marketplace-install',
633
+ );
634
+
635
+ if (codexActions.length === 0) {
636
+ failures.push(
637
+ `unit "${unitId}": codex-plugin distribution declared but "codex-marketplace-install" action is missing`,
638
+ );
639
+ } else if (codexActions.length > 1) {
640
+ failures.push(
641
+ `unit "${unitId}": duplicate codex-marketplace-install actions (${codexActions.length} found, expected 1)`,
642
+ );
643
+ } else {
644
+ const action = codexActions[0];
645
+
646
+ if (action.id !== expectedActionId) {
647
+ failures.push(
648
+ `unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
649
+ );
650
+ }
651
+ if (action.unitId !== unitId) {
652
+ failures.push(
653
+ `unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
654
+ );
655
+ }
656
+ if (action.adapter !== 'plugin-marketplace') {
657
+ failures.push(
658
+ `unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "plugin-marketplace"`,
659
+ );
660
+ }
661
+ if (action.status !== 'PENDING') {
662
+ failures.push(
663
+ `unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
664
+ );
665
+ }
666
+
667
+ // Parameter checks
668
+ _checkRequired(action, 'parameters.consumer', action.parameters?.consumer, 'codex', unitId, failures);
669
+ _checkRequired(action, 'parameters.plugin', action.parameters?.plugin, plugin, unitId, failures);
670
+ _checkRequired(action, 'parameters.marketplace', action.parameters?.marketplace, marketplace, unitId, failures);
671
+ _checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
672
+ _checkRequired(action, 'parameters.version', action.parameters?.version, targetVersion, unitId, failures);
673
+ _checkRequired(action, 'parameters.entrySkill', action.parameters?.entrySkill, entrySkill, unitId, failures);
674
+ if (production) {
675
+ _checkRequired(action, 'parameters.snapshotPath', action.parameters?.snapshotPath, frozen?.path, unitId, failures);
676
+ _checkRequired(action, 'parameters.ref', action.parameters?.ref, expectedTag, unitId, failures);
677
+ _checkRequired(action, 'parameters.manifestDigest', action.parameters?.manifestDigest, frozen?.manifestDigest, unitId, failures);
678
+ }
679
+
680
+ // Expected checks
681
+ _checkRequired(action, 'expected.installed', action.expected?.installed, true, unitId, failures);
682
+ _checkRequired(action, 'expected.plugin', action.expected?.plugin, plugin, unitId, failures);
683
+ _checkRequired(action, 'expected.marketplace', action.expected?.marketplace, marketplace, unitId, failures);
684
+ _checkRequired(action, 'expected.version', action.expected?.version, targetVersion, unitId, failures);
685
+ _checkRequired(action, 'expected.entrySkill', action.expected?.entrySkill, entrySkill, unitId, failures);
686
+ if (production) {
687
+ _checkRequired(action, 'expected.consumer', action.expected?.consumer, 'codex', unitId, failures);
688
+ _checkRequired(action, 'expected.repo', action.expected?.repo, publicRepo, unitId, failures);
689
+ _checkRequired(action, 'expected.ref', action.expected?.ref, expectedTag, unitId, failures);
690
+ _checkRequired(action, 'expected.entrySkillFound', action.expected?.entrySkillFound, true, unitId, failures);
691
+ _checkRequired(action, 'expected.manifestDigest', action.expected?.manifestDigest, frozen?.manifestDigest, unitId, failures);
692
+ }
693
+ }
694
+ }
695
+ }
696
+
697
+ // --- Strict count equality: actual must exactly match expected ---
698
+ const actualCount = actions.length;
699
+ if (actualCount !== expectedCount) {
700
+ const diff = actualCount > expectedCount
701
+ ? `${actualCount - expectedCount} extra action(s) detected`
702
+ : `${expectedCount - actualCount} action(s) missing from plan`;
703
+ failures.push(
704
+ `plan has ${actualCount} action(s) but exactly ${expectedCount} expected from unit definitions; ${diff}`,
705
+ );
706
+ }
707
+
708
+ return {
709
+ passed: failures.length === 0,
710
+ details: { failures, expectedCount, actualCount },
711
+ };
712
+ }
713
+
714
+ /**
715
+ * Check that a required field exists and equals the expected value.
716
+ * Missing or mismatched values both produce failures.
717
+ *
718
+ * @param {object} action - The action being checked (for error messages).
719
+ * @param {string} fieldPath - Human-readable field path (e.g. 'parameters.version').
720
+ * @param {*} actual - The actual value (may be undefined if missing).
721
+ * @param {*} expected - The expected value.
722
+ * @param {string} unitId - The unit ID (for error messages).
723
+ * @param {string[]} failures - Accumulator for failure messages.
724
+ */
725
+ function _checkRequired(action, fieldPath, actual, expected, unitId, failures) {
726
+ if (actual === undefined || actual === null) {
727
+ failures.push(
728
+ `unit "${unitId}", action "${action.id}": ${fieldPath} is missing, expected "${expected}"`,
729
+ );
730
+ } else if (actual !== expected) {
731
+ failures.push(
732
+ `unit "${unitId}", action "${action.id}": ${fieldPath} is "${actual}", expected "${expected}"`,
733
+ );
734
+ }
735
+ }