release-skill 0.2.9 → 0.4.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 (77) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +41 -0
  7. package/INSTALL.md +34 -110
  8. package/INSTALL.zh-CN.md +17 -83
  9. package/README.md +49 -41
  10. package/README.zh-CN.md +43 -34
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +4131 -3180
  14. package/adapters/claude/schemas/release-plan.schema.json +7 -0
  15. package/adapters/claude/schemas/release-run.schema.json +43 -0
  16. package/adapters/claude/skills/release-help/SKILL.md +10 -2
  17. package/adapters/claude/skills/release-prepare/SKILL.md +6 -15
  18. package/adapters/claude/skills/release-publish/SKILL.md +5 -6
  19. package/adapters/claude/skills/release-reconcile/SKILL.md +2 -2
  20. package/adapters/claude/skills/release-verify/SKILL.md +4 -6
  21. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  22. package/adapters/codex/bin/release-skill.bundle.mjs +4131 -3180
  23. package/adapters/codex/schemas/release-plan.schema.json +7 -0
  24. package/adapters/codex/schemas/release-run.schema.json +43 -0
  25. package/adapters/codex/skills/release-help/SKILL.md +10 -2
  26. package/adapters/codex/skills/release-prepare/SKILL.md +6 -15
  27. package/adapters/codex/skills/release-publish/SKILL.md +5 -6
  28. package/adapters/codex/skills/release-reconcile/SKILL.md +2 -2
  29. package/adapters/codex/skills/release-verify/SKILL.md +4 -6
  30. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  31. package/adapters/kimi/bin/release-skill.bundle.mjs +4131 -3180
  32. package/adapters/kimi/schemas/release-plan.schema.json +7 -0
  33. package/adapters/kimi/schemas/release-run.schema.json +43 -0
  34. package/adapters/kimi/skills/release-help/SKILL.md +10 -2
  35. package/adapters/kimi/skills/release-prepare/SKILL.md +6 -15
  36. package/adapters/kimi/skills/release-publish/SKILL.md +5 -6
  37. package/adapters/kimi/skills/release-reconcile/SKILL.md +2 -2
  38. package/adapters/kimi/skills/release-verify/SKILL.md +4 -6
  39. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  40. package/adapters/workbuddy/bin/release-skill.bundle.mjs +4131 -3180
  41. package/adapters/workbuddy/schemas/release-plan.schema.json +7 -0
  42. package/adapters/workbuddy/schemas/release-run.schema.json +43 -0
  43. package/adapters/workbuddy/skills/release-help/SKILL.md +10 -2
  44. package/adapters/workbuddy/skills/release-prepare/SKILL.md +6 -15
  45. package/adapters/workbuddy/skills/release-publish/SKILL.md +5 -6
  46. package/adapters/workbuddy/skills/release-reconcile/SKILL.md +2 -2
  47. package/adapters/workbuddy/skills/release-verify/SKILL.md +4 -6
  48. package/bin/release-skill-cli.mjs +212 -43
  49. package/bin/release-skill.bundle.mjs +4131 -3180
  50. package/package.json +1 -1
  51. package/references/01-state-machine.md +1 -1
  52. package/references/06-adapter-contract.md +6 -5
  53. package/schemas/release-plan.schema.json +7 -0
  54. package/schemas/release-run.schema.json +43 -0
  55. package/skills/release-help/SKILL.md +10 -2
  56. package/skills/release-prepare/SKILL.md +6 -15
  57. package/skills/release-publish/SKILL.md +5 -6
  58. package/skills/release-reconcile/SKILL.md +2 -2
  59. package/skills/release-verify/SKILL.md +4 -6
  60. package/skills-src/release-help/SKILL.md +10 -2
  61. package/skills-src/release-prepare/SKILL.md +6 -15
  62. package/skills-src/release-publish/SKILL.md +5 -6
  63. package/skills-src/release-reconcile/SKILL.md +2 -2
  64. package/skills-src/release-verify/SKILL.md +4 -6
  65. package/src/adapters/plugin-marketplace.mjs +1 -1
  66. package/src/adapters/push-snapshot.mjs +3 -1
  67. package/src/commands/approve.mjs +8 -6
  68. package/src/commands/attest.mjs +195 -0
  69. package/src/commands/hooks.mjs +42 -0
  70. package/src/commands/prepare.mjs +13 -50
  71. package/src/commands/publish.mjs +0 -8
  72. package/src/commands/reconcile.mjs +3 -37
  73. package/src/commands/ship.mjs +334 -0
  74. package/src/commands/verify.mjs +195 -15
  75. package/src/core/git-transport.mjs +93 -0
  76. package/src/core/release-metadata.mjs +105 -0
  77. package/src/core/skill-resource-closure.mjs +7 -1
@@ -0,0 +1,195 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve, join } from 'node:path';
3
+
4
+ import { SAFE_ID_RE, writeEvidenceAtomic } from '../adapters/contract.mjs';
5
+ import { ReleaseError, GATE_FAILED, MISSING_PARAMETERS } from '../core/errors.mjs';
6
+
7
+ const PLATFORM = Object.freeze({
8
+ kimi: {
9
+ directory: 'kimi-attestations',
10
+ requirementFile: 'release-skill-kimi-manual-install.json',
11
+ },
12
+ codebuddy: {
13
+ directory: 'codebuddy-attestations',
14
+ requirementFile: 'release-skill-codebuddy-manual-install.json',
15
+ },
16
+ });
17
+
18
+ async function validateInstalledConsumerClosure({
19
+ root,
20
+ platform,
21
+ requirement,
22
+ installPath,
23
+ }) {
24
+ const planPath = resolve(root, '.release-skill', 'plans', `${requirement.planDigest}.json`);
25
+ const [
26
+ { validatePlan, computePlanDigest, assertImmutablePlanAuthority },
27
+ { createPluginMarketplaceAdapter, verifyInstalledMarketplacePayload },
28
+ ] = await Promise.all([
29
+ import('../core/plan.mjs'),
30
+ import('../adapters/plugin-marketplace.mjs'),
31
+ ]);
32
+ let plan;
33
+ try {
34
+ plan = JSON.parse(await readFile(planPath, 'utf8'));
35
+ } catch (error) {
36
+ throw new ReleaseError(
37
+ GATE_FAILED,
38
+ `cannot read the frozen plan required to verify the installed ${platform} payload: ${error.message}`,
39
+ );
40
+ }
41
+ validatePlan(plan);
42
+ assertImmutablePlanAuthority(planPath, plan);
43
+ if (computePlanDigest(plan) !== requirement.planDigest) {
44
+ throw new ReleaseError(GATE_FAILED, 'manual requirement planDigest does not match the frozen plan');
45
+ }
46
+ const actionType = `${platform}-marketplace-install`;
47
+ const action = (plan.externalActions ?? []).find((candidate) => (
48
+ candidate.type === actionType
49
+ && candidate.parameters?.plugin === requirement.plugin
50
+ && candidate.parameters?.version === requirement.version
51
+ ));
52
+ if (!action) {
53
+ throw new ReleaseError(GATE_FAILED, `frozen plan has no matching ${platform} install action`);
54
+ }
55
+ const adapter = createPluginMarketplaceAdapter();
56
+ const context = {
57
+ externalWritesAuthorized: false,
58
+ isolatedConsumerWritesAuthorized: false,
59
+ plan,
60
+ baseline: plan.baseline,
61
+ root,
62
+ };
63
+ const actionInput = { actionType, ...action.parameters };
64
+ const preflight = await adapter.preflight(actionInput, context);
65
+ if (preflight.status !== 'PREFLIGHT_PASSED') {
66
+ throw new ReleaseError(
67
+ GATE_FAILED,
68
+ `${platform} installed-payload preflight failed: ${preflight.error}`,
69
+ );
70
+ }
71
+ const binding = await verifyInstalledMarketplacePayload(
72
+ actionInput,
73
+ context,
74
+ installPath,
75
+ platform,
76
+ );
77
+ return {
78
+ payloadDigest: action.parameters.manifestDigest,
79
+ manifestDigest: binding.manifestDigest,
80
+ extraInstalledPaths: binding.extraInstalledPaths ?? [],
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Record the human fact needed by an interactive-only consumer. Identity
86
+ * fields come exclusively from the generated requirement; the operator only
87
+ * supplies the result, actor and optional observed install facts.
88
+ */
89
+ export async function recordManualAttestation(options = {}, injected = {}) {
90
+ const {
91
+ root = process.cwd(),
92
+ platform,
93
+ plugin,
94
+ actor,
95
+ result,
96
+ installPath,
97
+ installChannel,
98
+ note,
99
+ clock = () => new Date().toISOString(),
100
+ } = options;
101
+ const descriptor = PLATFORM[platform];
102
+ if (!descriptor || !SAFE_ID_RE.test(plugin ?? '')) {
103
+ throw new ReleaseError(
104
+ MISSING_PARAMETERS,
105
+ 'attest requires --platform <kimi|codebuddy> and a safe --plugin <id>',
106
+ );
107
+ }
108
+ if (typeof actor !== 'string' || actor.trim() === '') {
109
+ throw new ReleaseError(MISSING_PARAMETERS, 'attest requires --actor <person>');
110
+ }
111
+ if (result !== 'passed' && result !== 'failed') {
112
+ throw new ReleaseError(MISSING_PARAMETERS, 'attest requires --result <passed|failed>');
113
+ }
114
+
115
+ const authorityDir = resolve(root, '.release-skill', descriptor.directory, plugin);
116
+ const requirementPath = join(authorityDir, descriptor.requirementFile);
117
+ let requirement;
118
+ try {
119
+ requirement = JSON.parse(await readFile(requirementPath, 'utf8'));
120
+ } catch (error) {
121
+ throw new ReleaseError(
122
+ GATE_FAILED,
123
+ `cannot read generated ${platform} requirement: ${error.message}`,
124
+ { platform, plugin, requirementPath },
125
+ );
126
+ }
127
+ if (
128
+ requirement.platform !== platform
129
+ || requirement.plugin !== plugin
130
+ || typeof requirement.version !== 'string'
131
+ || !/^[a-f0-9]{64}$/.test(requirement.planDigest ?? '')
132
+ || typeof requirement.attestationFile !== 'string'
133
+ ) {
134
+ throw new ReleaseError(
135
+ GATE_FAILED,
136
+ `generated ${platform} requirement has invalid identity fields`,
137
+ { platform, plugin, requirementPath },
138
+ );
139
+ }
140
+ if (result === 'passed' && (typeof installPath !== 'string' || installPath === '')) {
141
+ throw new ReleaseError(
142
+ MISSING_PARAMETERS,
143
+ `${platform} passed attestation requires --install-path for installed payload verification`,
144
+ );
145
+ }
146
+ const needsInstallChannel = Object.hasOwn(requirement.attestationTemplate ?? {}, 'installChannel');
147
+ if (
148
+ result === 'passed'
149
+ && needsInstallChannel
150
+ && installChannel !== 'desktop'
151
+ && installChannel !== 'cli'
152
+ ) {
153
+ throw new ReleaseError(
154
+ MISSING_PARAMETERS,
155
+ `${platform} passed attestation requires --install-channel <desktop|cli>`,
156
+ );
157
+ }
158
+
159
+ const installBinding = result === 'passed'
160
+ ? await (injected.validateInstalledConsumerClosure ?? validateInstalledConsumerClosure)({
161
+ root,
162
+ platform,
163
+ requirement,
164
+ installPath,
165
+ })
166
+ : null;
167
+ const receipt = {
168
+ platform,
169
+ version: requirement.version,
170
+ planDigest: requirement.planDigest,
171
+ result,
172
+ actor: actor.trim(),
173
+ confirmedAt: clock(),
174
+ ...(installPath ? { installPath } : {}),
175
+ ...(installChannel ? { installChannel } : {}),
176
+ ...(note ? { note } : {}),
177
+ ...(installBinding ? {
178
+ payloadDigest: installBinding.payloadDigest,
179
+ installedClosureVerified: true,
180
+ extraInstalledPaths: installBinding.extraInstalledPaths,
181
+ } : {}),
182
+ };
183
+ const attestationPath = join(authorityDir, requirement.attestationFile);
184
+ await writeEvidenceAtomic(attestationPath, receipt);
185
+ return {
186
+ command: 'attest',
187
+ status: 'RECORDED',
188
+ platform,
189
+ plugin,
190
+ version: requirement.version,
191
+ planDigest: requirement.planDigest,
192
+ requirementPath,
193
+ attestationPath,
194
+ };
195
+ }
@@ -0,0 +1,42 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { loadProjectConfig } from '../core/config.mjs';
5
+ import { createEvidenceWriter } from '../core/evidence.mjs';
6
+ import { runDeclaredHooks } from './prepare.mjs';
7
+ // ReleaseError and GATE_FAILED removed: no authorization gate remains.
8
+
9
+ /**
10
+ * Run the declared development gates and populate the exact same content-bound
11
+ * hook receipts consumed by prepare. A later ship/prepare reuses only
12
+ * cacheable hooks whose declared input closure is byte-identical.
13
+ */
14
+ export async function validateDeclaredHooks(options = {}) {
15
+ const {
16
+ root = process.cwd(),
17
+ hooksAuthorized: _hooksAuthorized,
18
+ hookCache = true,
19
+ runDir = resolve(root, '.release-skill', 'runs', `hooks-${Date.now()}`),
20
+ } = options;
21
+ // The command invocation itself authorizes execution of configured hooks.
22
+ // Old --acknowledge-hook-side-effects is accepted as a no-effect compatibility input.
23
+ const { config, configDigest } = await loadProjectConfig({ root });
24
+ await mkdir(runDir, { recursive: true });
25
+ const evidence = createEvidenceWriter({
26
+ runDir,
27
+ command: 'hooks-validate',
28
+ clock: () => new Date().toISOString(),
29
+ });
30
+ const startedAt = new Date().toISOString();
31
+ await runDeclaredHooks(config, root, evidence, undefined, { hookCache });
32
+ return {
33
+ command: 'hooks validate',
34
+ status: 'PASSED',
35
+ configDigest,
36
+ hooks: Object.keys(config.hooks ?? {}).sort(),
37
+ cacheEnabled: hookCache,
38
+ evidenceDir: runDir,
39
+ startedAt,
40
+ finishedAt: new Date().toISOString(),
41
+ };
42
+ }
@@ -1809,41 +1809,16 @@ export async function prepareRelease(options) {
1809
1809
  cwd: hook.cwd ?? '.',
1810
1810
  }));
1811
1811
 
1812
+ // Hook and gate authorization: the command invocation itself authorizes
1813
+ // execution of configured commands. Old acknowledgement parameters
1814
+ // (--acknowledge-hook-side-effects, --acknowledge-gate-side-effects) are
1815
+ // accepted as no-effect compatibility inputs but are not required.
1812
1816
  if (declaredHooks.length > 0) {
1813
- await evidence.append({
1814
- phase: 'hook-authorization',
1815
- status: 'started',
1816
- hookCount: declaredHooks.length,
1817
- hooks: declaredHooks.map((h) => `${h.name}: ${h.executable} ${h.args.join(' ')}`),
1818
- });
1819
-
1820
- if (hooksAuthorized !== true) {
1821
- const hookList = declaredHooks
1822
- .map((h) => ` - ${h.name}: executable="${h.executable}", args=[${h.args.join(', ')}], cwd="${h.cwd}"`)
1823
- .join('\n');
1824
-
1825
- await evidence.append({
1826
- phase: 'hook-authorization',
1827
- status: 'denied',
1828
- reason: 'hooks not explicitly authorized',
1829
- });
1830
-
1831
- throw new ReleaseError(
1832
- GATE_FAILED,
1833
- `project declares ${declaredHooks.length} hook(s) that will be executed as arbitrary local processes.\n` +
1834
- `These hooks are NOT sandboxed — they may write to the filesystem outside the project, ` +
1835
- `access local credentials, or make network calls.\n` +
1836
- `The following hooks will run:\n${hookList}\n\n` +
1837
- `To proceed, pass --acknowledge-hook-side-effects (CLI) or hooksAuthorized=true (API). ` +
1838
- `Authorization means you accept hook side-effect risks; it does NOT make hooks safe.`,
1839
- { hookNames: declaredHooks.map((h) => h.name), hookCount: declaredHooks.length },
1840
- );
1841
- }
1842
-
1843
1817
  await evidence.append({
1844
1818
  phase: 'hook-authorization',
1845
1819
  status: 'authorized',
1846
1820
  hookCount: declaredHooks.length,
1821
+ hooks: declaredHooks.map((h) => `${h.name}: ${h.executable} ${h.args.join(' ')}`),
1847
1822
  });
1848
1823
  }
1849
1824
 
@@ -1851,7 +1826,7 @@ export async function prepareRelease(options) {
1851
1826
  if (declaredVerificationGates.length > 0) {
1852
1827
  await evidence.append({
1853
1828
  phase: 'verification-gate-authorization',
1854
- status: 'started',
1829
+ status: 'authorized',
1855
1830
  gateCount: declaredVerificationGates.length,
1856
1831
  gates: declaredVerificationGates.map((gate) => ({
1857
1832
  id: gate.id,
@@ -1863,25 +1838,6 @@ export async function prepareRelease(options) {
1863
1838
  cwd: gate.cwd,
1864
1839
  })),
1865
1840
  });
1866
- if (verificationGatesAuthorized !== true) {
1867
- await evidence.append({
1868
- phase: 'verification-gate-authorization',
1869
- status: 'denied',
1870
- gateCount: declaredVerificationGates.length,
1871
- });
1872
- throw new ReleaseError(
1873
- GATE_FAILED,
1874
- `project declares ${declaredVerificationGates.length} verification gate(s). ` +
1875
- 'They run local project commands without a network sandbox. ' +
1876
- 'To proceed, pass --acknowledge-gate-side-effects (CLI) or verificationGatesAuthorized=true (API).',
1877
- { gateIds: declaredVerificationGates.map((gate) => gate.id) },
1878
- );
1879
- }
1880
- await evidence.append({
1881
- phase: 'verification-gate-authorization',
1882
- status: 'authorized',
1883
- gateCount: declaredVerificationGates.length,
1884
- });
1885
1841
  }
1886
1842
 
1887
1843
  // --- Step 3: Run declared hooks ---
@@ -2848,6 +2804,12 @@ export async function prepareRelease(options) {
2848
2804
  // Compute overall snapshot digest
2849
2805
  const overallSnapshotDigest = sha256Hex(snapshotDigests.join(':'));
2850
2806
 
2807
+ // Detect human consumer platforms (Kimi/CodeBuddy) in the plan.
2808
+ // When present, new plans mark them as non-blocking manual follow-up tasks.
2809
+ const hasHumanConsumerActions = externalActions.some(
2810
+ (a) => a.type === 'kimi-marketplace-install' || a.type === 'codebuddy-marketplace-install',
2811
+ );
2812
+
2851
2813
  const plan = {
2852
2814
  planVersion: 2,
2853
2815
  status: 'PREPARED',
@@ -2870,6 +2832,7 @@ export async function prepareRelease(options) {
2870
2832
  },
2871
2833
  verificationGates: config.verificationGates ?? [],
2872
2834
  snapshotDigest: overallSnapshotDigest,
2835
+ ...(hasHumanConsumerActions ? { humanConsumersStrategy: 'manualFollowUps' } : {}),
2873
2836
  ...(production ? {
2874
2837
  production: {
2875
2838
  mode: 'github-npm-v1',
@@ -465,7 +465,6 @@ export async function publishRelease(options) {
465
465
  clock: clockOpt,
466
466
  captureBaselineFn,
467
467
  productionMode = false,
468
- productionConfirmation,
469
468
  observePreviousPublicBaselineFn,
470
469
  observeRetrySleep,
471
470
  } = options ?? {};
@@ -544,13 +543,6 @@ export async function publishRelease(options) {
544
543
  if (!plan.production.assetRoot || plan.production.assetRoot === '.') {
545
544
  throw new ReleaseError(GATE_FAILED, 'production plan requires a dedicated assetRoot');
546
545
  }
547
- if (!productionConfirmation || productionConfirmation !== actualDigest) {
548
- throw new ReleaseError(
549
- GATE_FAILED,
550
- 'production confirmation must exactly match the current plan digest',
551
- { planDigest: actualDigest },
552
- );
553
- }
554
546
  }
555
547
 
556
548
  // =======================================================================
@@ -91,7 +91,6 @@ function defaultClock() {
91
91
  * @param {string} options.planPath - Absolute path to the frozen release plan.
92
92
  * @param {string} options.sourceRunPath - Absolute path to the source run (publish or prior reconcile).
93
93
  * @param {string} [options.approvalPath] - Path to the approval record (required if any action needs retry).
94
- * @param {string} [options.productionConfirmation] - Exact plan digest required before retrying production writes.
95
94
  * @param {Object} options.adapterRegistry - Adapter registry for action execution.
96
95
  * @param {string} [options.runDir] - Evidence directory. Defaults to `<planDir>/runs/reconcile-<ts>`.
97
96
  * @param {string} [options.root] - Project root for baseline capture.
@@ -114,7 +113,6 @@ export async function reconcileRelease(options) {
114
113
  root = process.cwd(),
115
114
  clock: clockOpt,
116
115
  captureBaselineFn,
117
- productionConfirmation,
118
116
  observePreviousPublicBaselineFn,
119
117
  observeRetrySleep,
120
118
  } = options ?? {};
@@ -332,29 +330,6 @@ export async function reconcileRelease(options) {
332
330
  );
333
331
  }
334
332
 
335
- // A production retry authority must be confirmed before any remote
336
- // observation can influence whether a permanent write will be retried.
337
- // Isolated marketplace consumer checks remain outside this requirement.
338
- const planActionsById = new Map(
339
- (plan.externalActions ?? []).map((action) => [action.id, action]),
340
- );
341
- const hasNonMarketplaceRetryCandidate = sourceRun.checkpoints.some((checkpoint) => {
342
- if (checkpoint.status === 'succeeded') return false;
343
- const action = planActionsById.get(checkpoint.actionId);
344
- return action && !isMarketplaceAction(action.type);
345
- });
346
- if (
347
- plan.production?.mode === 'github-npm-v1' &&
348
- hasNonMarketplaceRetryCandidate &&
349
- productionConfirmation !== actualDigest
350
- ) {
351
- throw new ReleaseError(
352
- GATE_FAILED,
353
- 'production reconcile confirmation must exactly match the current plan digest before retry',
354
- { planDigest: actualDigest },
355
- );
356
- }
357
-
358
333
  await evidence.append({
359
334
  phase: 'safety-gate',
360
335
  gate: 'source-run-load',
@@ -918,7 +893,7 @@ export async function reconcileRelease(options) {
918
893
  // --- Phase 2: Validate approval and global preflight before retrying ---
919
894
  //
920
895
  // Marketplace recovery is deferred to phase 4 and does NOT require
921
- // approval or productionConfirmation; marketplace actions are isolated
896
+ // approval; marketplace actions are isolated
922
897
  // consumer checks, not permanent remote writes. Only non-marketplace
923
898
  // retries require approval.
924
899
  // =======================================================================
@@ -926,17 +901,8 @@ export async function reconcileRelease(options) {
926
901
  const nonMarketplaceRetries = actionsToRetry.filter((a) => !isMarketplaceAction(a.type));
927
902
 
928
903
  if (nonMarketplaceRetries.length > 0) {
929
- // Non-marketplace retries require approval and productionConfirmation
930
- if (
931
- plan.production?.mode === 'github-npm-v1' &&
932
- productionConfirmation !== actualDigest
933
- ) {
934
- throw new ReleaseError(
935
- GATE_FAILED,
936
- 'production reconcile confirmation must exactly match the current plan digest before retry',
937
- { planDigest: actualDigest, actionsToRetry: nonMarketplaceRetries.map((action) => action.id) },
938
- );
939
- }
904
+ // Non-marketplace retries require a current immutable approval. The
905
+ // approval already binds the exact plan digest and action set.
940
906
  if (!approval) {
941
907
  throw new ReleaseError(
942
908
  GATE_FAILED,