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,1255 @@
1
+ /**
2
+ * Reconcile command: idempotent recovery from partial publish.
3
+ *
4
+ * Reads the source run (a publish or prior reconcile run) and the frozen
5
+ * release plan. For each checkpoint in the source run:
6
+ * - SUCCEEDED: re-observe to verify remote state is still consistent.
7
+ * - FAILED/PENDING: observe remote state; if consistent skip, if missing
8
+ * add to retry list, if conflicting => REMOTE_CONFLICT.
9
+ *
10
+ * Retry actions are validated against the approval record, then preflighted
11
+ * globally before any execute. Each retry execute is followed by observe.
12
+ *
13
+ * Invariants:
14
+ * - SUCCEEDED actions are never re-executed (only re-observed)
15
+ * - Remote state conflict => REMOTE_CONFLICT (never blindly overwrite)
16
+ * - All retry preflight must pass before first retry execute
17
+ * - Every retry execute is followed by observe
18
+ * - PARTIAL => PUBLISHED when all external actions are consistent
19
+ * - A separate verify run performs fresh npm/plugin consumer installs and is
20
+ * the only command that may promote PUBLISHED to VERIFIED
21
+ * - New run includes sourceRunId; source run and plan are never modified
22
+ *
23
+ * @module commands/reconcile
24
+ */
25
+
26
+ import { readFile, mkdir } from 'node:fs/promises';
27
+ import { join } from 'node:path';
28
+
29
+ import { assertImmutablePlanAuthority, computePlanDigest, validatePlan, validatePlanActionCompleteness } from '../core/plan.mjs';
30
+ import {
31
+ assertImmutableApprovalAuthority,
32
+ computeApprovalDigest,
33
+ validateApproval,
34
+ validateApprovalRecordSchema,
35
+ } from '../core/approval.mjs';
36
+ import { captureBaseline, WORKSPACE_DIGEST_ALGORITHM } from '../core/baseline.mjs';
37
+ import {
38
+ assertPreviousPublicBaselineTarget,
39
+ reObservePreviousPublicBaseline,
40
+ } from '../core/previous-public-baseline.mjs';
41
+ import { createEvidenceWriter } from '../core/evidence.mjs';
42
+ import {
43
+ loadRun,
44
+ validateRunPlanDigest,
45
+ validateRunCheckpointMapping,
46
+ writeRunAtomic,
47
+ appendRunState,
48
+ computeRunDigest,
49
+ createProductionRunDir,
50
+ validateRunLineage,
51
+ resolveDefaultRunDir,
52
+ } from '../core/run.mjs';
53
+ import {
54
+ ReleaseError,
55
+ GATE_FAILED,
56
+ BASELINE_CHANGED,
57
+ REMOTE_CONFLICT,
58
+ POST_PUBLISH_VERIFY_FAILED,
59
+ } from '../core/errors.mjs';
60
+ import { assertTransition, PARTIAL, PUBLISHED, BLOCKED } from '../core/state-machine.mjs';
61
+ import { matchObservation } from '../adapters/contract.mjs';
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Constants
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Checkpoint order for the reconcile saga.
69
+ * Must match publish.mjs.
70
+ */
71
+ const CHECKPOINT_ORDER = [
72
+ 'push-commit',
73
+ 'push-snapshot',
74
+ 'create-tag',
75
+ 'npm-publish',
76
+ 'github-release',
77
+ 'claude-marketplace-install',
78
+ 'codex-marketplace-install',
79
+ ];
80
+
81
+ /**
82
+ * Map plan action type to adapter ActionType.
83
+ * Must match publish.mjs.
84
+ */
85
+ const ADAPTER_ACTION_TYPE_MAP = {
86
+ 'push-commit': 'git-push',
87
+ 'push-snapshot': 'push-snapshot',
88
+ 'create-tag': 'git-tag',
89
+ 'npm-publish': 'npm-publish',
90
+ 'github-release': 'github-release',
91
+ 'claude-marketplace-install': 'claude-marketplace-install',
92
+ 'codex-marketplace-install': 'codex-marketplace-install',
93
+ };
94
+
95
+ /**
96
+ * Marketplace action types that are reconstructable isolated consumer checks,
97
+ * not permanent remote writes. These use isolatedConsumerWritesAuthorized
98
+ * and don't require externalWritesAuthorized even during retry.
99
+ */
100
+ const MARKETPLACE_TYPES = new Set([
101
+ 'claude-marketplace-install',
102
+ 'codex-marketplace-install',
103
+ ]);
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Helpers
107
+ // ---------------------------------------------------------------------------
108
+
109
+ function defaultClock() {
110
+ return new Date().toISOString();
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Public API
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Reconcile a release from a source run.
119
+ *
120
+ * @param {Object} options
121
+ * @param {string} options.planPath - Absolute path to the frozen release plan.
122
+ * @param {string} options.sourceRunPath - Absolute path to the source run (publish or prior reconcile).
123
+ * @param {string} [options.approvalPath] - Path to the approval record (required if any action needs retry).
124
+ * @param {string} [options.productionConfirmation] - Exact plan digest required before retrying production writes.
125
+ * @param {Object} options.adapterRegistry - Adapter registry for action execution.
126
+ * @param {string} [options.runDir] - Evidence directory. Defaults to `<planDir>/runs/reconcile-<ts>`.
127
+ * @param {string} [options.root] - Project root for baseline capture.
128
+ * @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
129
+ * @param {(root: string) => Promise<Object>} [options.captureBaselineFn] - Injectable baseline capture.
130
+ *
131
+ * @returns {Promise<{ planPath: string, runPath: string, status: string, checkpoints: Object[] }>}
132
+ *
133
+ * @throws {ReleaseError} GATE_FAILED on safety gate failures.
134
+ * @throws {ReleaseError} BASELINE_CHANGED if the baseline has changed since freeze.
135
+ * @throws {ReleaseError} REMOTE_CONFLICT if remote state is inconsistent with the plan.
136
+ */
137
+ export async function reconcileRelease(options) {
138
+ const {
139
+ planPath,
140
+ sourceRunPath,
141
+ approvalPath,
142
+ adapterRegistry,
143
+ runDir: runDirOpt,
144
+ root = process.cwd(),
145
+ clock: clockOpt,
146
+ captureBaselineFn,
147
+ productionConfirmation,
148
+ observePreviousPublicBaselineFn,
149
+ } = options ?? {};
150
+
151
+ const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
152
+ const captureBaselineActual =
153
+ typeof captureBaselineFn === 'function' ? captureBaselineFn : captureBaseline;
154
+
155
+ // --- Gate: sourceRunPath is required ---
156
+ if (!sourceRunPath) {
157
+ throw new ReleaseError(
158
+ GATE_FAILED,
159
+ 'reconcile requires a source run path (--run)',
160
+ { parameter: 'sourceRunPath' },
161
+ );
162
+ }
163
+
164
+ // Load the plan before selecting a production evidence authority. An unsafe
165
+ // runDir must fail before this command writes through it or retries a remote
166
+ // action.
167
+ let planRaw;
168
+ try {
169
+ planRaw = await readFile(planPath, 'utf8');
170
+ } catch (err) {
171
+ throw new ReleaseError(GATE_FAILED, `cannot read release plan: ${err.message}`, { planPath, cause: err.code });
172
+ }
173
+ let plan;
174
+ try {
175
+ plan = JSON.parse(planRaw);
176
+ } catch (err) {
177
+ throw new ReleaseError(GATE_FAILED, `release plan is not valid JSON: ${err.message}`, { planPath });
178
+ }
179
+ validatePlan(plan);
180
+ assertImmutablePlanAuthority(planPath, plan);
181
+ const isProductionPlan = plan.production?.mode === 'github-npm-v1';
182
+
183
+ // --- Set up directories ---
184
+ const runId = `reconcile-${Date.now()}`;
185
+ let runDir = runDirOpt ?? resolveDefaultRunDir(planPath, 'reconcile', runId);
186
+ if (isProductionPlan) {
187
+ runDir = await createProductionRunDir(runDir, planPath);
188
+ } else {
189
+ await mkdir(runDir, { recursive: true });
190
+ }
191
+
192
+ const evidence = createEvidenceWriter({ runDir, command: 'reconcile', clock: clockFn });
193
+
194
+ try {
195
+ // =======================================================================
196
+ // Safety Gate 1: Load and validate plan schema
197
+ // =======================================================================
198
+ await evidence.append({ phase: 'safety-gate', gate: 'plan-load', status: 'started' });
199
+
200
+ await evidence.append({ phase: 'safety-gate', gate: 'plan-schema', status: 'passed' });
201
+
202
+ // =======================================================================
203
+ // Safety Gate 2: Verify plan digest
204
+ // =======================================================================
205
+ await evidence.append({ phase: 'safety-gate', gate: 'plan-digest', status: 'started' });
206
+
207
+ const actualDigest = computePlanDigest(plan);
208
+ if (plan.digest && plan.digest !== actualDigest) {
209
+ throw new ReleaseError(
210
+ GATE_FAILED,
211
+ `plan digest mismatch: expected ${plan.digest.slice(0, 16)}..., computed ${actualDigest.slice(0, 16)}...`,
212
+ { expected: plan.digest, actual: actualDigest },
213
+ );
214
+ }
215
+
216
+ await evidence.append({ phase: 'safety-gate', gate: 'plan-digest', status: 'passed' });
217
+
218
+ // =======================================================================
219
+ // Safety Gate 2b: Validate plan action completeness
220
+ // =======================================================================
221
+ await evidence.append({ phase: 'safety-gate', gate: 'action-completeness', status: 'started' });
222
+
223
+ const completenessResult = validatePlanActionCompleteness(plan);
224
+ if (!completenessResult.passed) {
225
+ await evidence.append({
226
+ phase: 'safety-gate',
227
+ gate: 'action-completeness',
228
+ status: 'failed',
229
+ failures: completenessResult.details.failures,
230
+ });
231
+ throw new ReleaseError(
232
+ GATE_FAILED,
233
+ `plan action completeness gate failed: ${completenessResult.details.failures.join('; ')}`,
234
+ { failures: completenessResult.details.failures },
235
+ );
236
+ }
237
+
238
+ await evidence.append({ phase: 'safety-gate', gate: 'action-completeness', status: 'passed' });
239
+
240
+ // =======================================================================
241
+ // Safety Gate 3: Load and validate source run
242
+ // =======================================================================
243
+ await evidence.append({ phase: 'safety-gate', gate: 'source-run-load', status: 'started' });
244
+
245
+ const sourceRun = await loadRun(sourceRunPath, {
246
+ requireDigest: Boolean(plan.production),
247
+ ...(plan.production ? { authorityPlanPath: planPath } : {}),
248
+ });
249
+ await validateRunLineage(sourceRun, {
250
+ plan,
251
+ planPath,
252
+ runPath: sourceRunPath,
253
+ production: Boolean(plan.production),
254
+ });
255
+ const sourceAuthorityDigest = sourceRun.runDigest ?? computeRunDigest(sourceRun);
256
+ validateRunCheckpointMapping(sourceRun, plan.externalActions ?? []);
257
+
258
+ if (!['publish', 'reconcile'].includes(sourceRun.command)) {
259
+ throw new ReleaseError(
260
+ GATE_FAILED,
261
+ `reconcile source command must be publish or reconcile, got "${sourceRun.command}"`,
262
+ );
263
+ }
264
+
265
+ let consumedApprovalPath = sourceRun.approvalPath;
266
+ let consumedApprovalDigest = sourceRun.approvalDigest;
267
+ if (plan.production) {
268
+ if (!consumedApprovalPath || !consumedApprovalDigest) {
269
+ throw new ReleaseError(
270
+ GATE_FAILED,
271
+ 'production source run is missing immutable approvalPath/approvalDigest authority',
272
+ { sourceRunId: sourceRun.runId },
273
+ );
274
+ }
275
+ const sourceApprovalRaw = await readFile(consumedApprovalPath, 'utf8').catch((error) => {
276
+ throw new ReleaseError(GATE_FAILED, 'source run approval authority is unavailable', {
277
+ sourceRunId: sourceRun.runId,
278
+ cause: error.code,
279
+ });
280
+ });
281
+ const observedApprovalDigest = assertImmutableApprovalAuthority(
282
+ consumedApprovalPath,
283
+ plan,
284
+ sourceApprovalRaw,
285
+ );
286
+ if (observedApprovalDigest !== consumedApprovalDigest) {
287
+ throw new ReleaseError(
288
+ GATE_FAILED,
289
+ 'source run approvalDigest does not match immutable approval bytes',
290
+ );
291
+ }
292
+ let sourceApproval;
293
+ try {
294
+ sourceApproval = JSON.parse(sourceApprovalRaw);
295
+ } catch (error) {
296
+ throw new ReleaseError(GATE_FAILED, `source run approval is not valid JSON: ${error.message}`);
297
+ }
298
+ validateApprovalRecordSchema(sourceApproval);
299
+ validateApproval(plan, sourceApproval, { clock: clockFn, requireUnexpired: false });
300
+ }
301
+
302
+ if (sourceRun.status !== PARTIAL) {
303
+ throw new ReleaseError(
304
+ GATE_FAILED,
305
+ `reconcile only accepts PARTIAL runs; source status is "${sourceRun.status}". ` +
306
+ 'For BLOCKED with no durable writes, fix the gate and rerun publish; VERIFIED is terminal.',
307
+ { sourceRunId: sourceRun.runId, sourceRunStatus: sourceRun.status },
308
+ );
309
+ }
310
+
311
+ // A production retry authority must be confirmed before any remote
312
+ // observation can influence whether a permanent write will be retried.
313
+ // Isolated marketplace consumer checks remain outside this requirement.
314
+ const planActionsById = new Map(
315
+ (plan.externalActions ?? []).map((action) => [action.id, action]),
316
+ );
317
+ const hasNonMarketplaceRetryCandidate = sourceRun.checkpoints.some((checkpoint) => {
318
+ if (checkpoint.status === 'succeeded') return false;
319
+ const action = planActionsById.get(checkpoint.actionId);
320
+ return action && !MARKETPLACE_TYPES.has(action.type);
321
+ });
322
+ if (
323
+ plan.production?.mode === 'github-npm-v1' &&
324
+ hasNonMarketplaceRetryCandidate &&
325
+ productionConfirmation !== actualDigest
326
+ ) {
327
+ throw new ReleaseError(
328
+ GATE_FAILED,
329
+ 'production reconcile confirmation must exactly match the current plan digest before retry',
330
+ { planDigest: actualDigest },
331
+ );
332
+ }
333
+
334
+ await evidence.append({
335
+ phase: 'safety-gate',
336
+ gate: 'source-run-load',
337
+ status: 'passed',
338
+ sourceRunId: sourceRun.runId,
339
+ });
340
+
341
+ // =======================================================================
342
+ // Safety Gate 4: Baseline comparison
343
+ // =======================================================================
344
+ await evidence.append({ phase: 'safety-gate', gate: 'baseline-check', status: 'started' });
345
+
346
+ if (
347
+ plan.production?.mode === 'github-npm-v1' &&
348
+ plan.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM
349
+ ) {
350
+ throw new ReleaseError(
351
+ GATE_FAILED,
352
+ `production plan workspace digest algorithm is missing or obsolete; re-run prepare (expected ${WORKSPACE_DIGEST_ALGORITHM})`,
353
+ { expected: WORKSPACE_DIGEST_ALGORITHM, actual: plan.baseline?.workspaceDigestAlgorithm ?? null },
354
+ );
355
+ }
356
+
357
+ const currentBaseline = await captureBaselineActual(root);
358
+
359
+ if (currentBaseline.gitTreeHash !== plan.baseline.gitTreeHash) {
360
+ await evidence.append({
361
+ phase: 'safety-gate',
362
+ gate: 'baseline-check',
363
+ status: 'failed',
364
+ planTreeHash: plan.baseline.gitTreeHash,
365
+ currentTreeHash: currentBaseline.gitTreeHash,
366
+ });
367
+
368
+ throw new ReleaseError(
369
+ BASELINE_CHANGED,
370
+ `baseline has changed since plan freeze: plan=${plan.baseline.gitTreeHash}, current=${currentBaseline.gitTreeHash}`,
371
+ { planTreeHash: plan.baseline.gitTreeHash, currentTreeHash: currentBaseline.gitTreeHash },
372
+ );
373
+ }
374
+
375
+ if (
376
+ plan.baseline.workspaceDigest &&
377
+ currentBaseline.workspaceDigest !== plan.baseline.workspaceDigest
378
+ ) {
379
+ throw new ReleaseError(
380
+ BASELINE_CHANGED,
381
+ `workspace digest has changed since plan freeze: plan=${plan.baseline.workspaceDigest}, current=${currentBaseline.workspaceDigest}`,
382
+ { planWorkspaceDigest: plan.baseline.workspaceDigest, currentWorkspaceDigest: currentBaseline.workspaceDigest },
383
+ );
384
+ }
385
+
386
+ await evidence.append({
387
+ phase: 'safety-gate',
388
+ gate: 'baseline-check',
389
+ status: 'passed',
390
+ gitTreeHash: currentBaseline.gitTreeHash,
391
+ });
392
+
393
+ // Re-observe every unit's frozen public baseline before any adapter
394
+ // observation, preflight, or execute call.
395
+ const isProductionPlan = plan.production?.mode === 'github-npm-v1';
396
+ const defaultPpbObserveFn = async (repo, ref, expectedCommit, { githubHost = 'github.com' } = {}) => {
397
+ try {
398
+ const { execFile } = await import('node:child_process');
399
+ const { promisify } = await import('node:util');
400
+ const { stdout } = await promisify(execFile)(
401
+ 'git',
402
+ ['ls-remote', `https://${githubHost}/${repo}.git`, ref],
403
+ { shell: false, encoding: 'utf8', timeout: 30000 },
404
+ );
405
+ const [line] = stdout.trim().split('\n').filter(Boolean);
406
+ if (!line) return { status: 'drifted', actual: null, diff: 'ref not found on remote' };
407
+ const [actual] = line.split('\t');
408
+ return actual === expectedCommit
409
+ ? { status: 'consistent', actual }
410
+ : { status: 'drifted', actual, diff: `expected ${expectedCommit}, got ${actual}` };
411
+ } catch (error) {
412
+ return { status: 'unknown', error: error.message };
413
+ }
414
+ };
415
+ const ppbObserveFn = observePreviousPublicBaselineFn ?? defaultPpbObserveFn;
416
+
417
+ for (const unit of plan.units ?? []) {
418
+ const baseline = unit.previousPublicBaseline;
419
+ if (!baseline) {
420
+ if (isProductionPlan) {
421
+ throw new ReleaseError(
422
+ GATE_FAILED,
423
+ `unit "${unit.id}" missing previousPublicBaseline in production plan`,
424
+ { gate: 'previous-public-baseline', unitId: unit.id },
425
+ );
426
+ }
427
+ continue;
428
+ }
429
+ const githubHost = unit.productionConfig?.githubHost ?? 'github.com';
430
+ assertPreviousPublicBaselineTarget({
431
+ baseline,
432
+ githubHost,
433
+ publicRepo: unit.publicRepo,
434
+ requireHost: isProductionPlan,
435
+ });
436
+ if (baseline.mode === 'bound' && baseline.status !== 'consistent') {
437
+ throw new ReleaseError(
438
+ GATE_FAILED,
439
+ `unit "${unit.id}" previous public baseline is not frozen as consistent`,
440
+ { gate: 'previous-public-baseline', unitId: unit.id, status: baseline.status },
441
+ );
442
+ }
443
+ const observed = await reObservePreviousPublicBaseline({
444
+ baseline,
445
+ observeFn: ppbObserveFn,
446
+ evidence,
447
+ });
448
+ if (!observed.consistent) {
449
+ throw new ReleaseError(
450
+ GATE_FAILED,
451
+ `unit "${unit.id}": ${observed.error}`,
452
+ { gate: 'previous-public-baseline', unitId: unit.id, ...observed.detail },
453
+ );
454
+ }
455
+ }
456
+
457
+ // =======================================================================
458
+ // Load approval if provided (needed for retrying actions)
459
+ // =======================================================================
460
+ let approval = null;
461
+ if (approvalPath) {
462
+ let approvalRaw;
463
+ try {
464
+ approvalRaw = await readFile(approvalPath, 'utf8');
465
+ } catch (err) {
466
+ throw new ReleaseError(
467
+ GATE_FAILED,
468
+ `cannot read approval record: ${err.message}`,
469
+ { approvalPath, cause: err.code },
470
+ );
471
+ }
472
+
473
+ try {
474
+ approval = JSON.parse(approvalRaw);
475
+ } catch (err) {
476
+ throw new ReleaseError(
477
+ GATE_FAILED,
478
+ `approval record is not valid JSON: ${err.message}`,
479
+ { approvalPath },
480
+ );
481
+ }
482
+ validateApprovalRecordSchema(approval);
483
+ consumedApprovalDigest = assertImmutableApprovalAuthority(approvalPath, plan, approvalRaw)
484
+ ?? computeApprovalDigest(approvalRaw);
485
+ consumedApprovalPath = approvalPath;
486
+ // Any supplied approval that may replace the source lineage must be
487
+ // fully bound to this plan, even when observation later proves that no
488
+ // retry is necessary.
489
+ validateApproval(plan, approval, { clock: clockFn });
490
+ }
491
+
492
+ // =======================================================================
493
+ // Safety Gate 5: Adapter availability for all plan action types
494
+ // =======================================================================
495
+ await evidence.append({ phase: 'safety-gate', gate: 'adapter-availability', status: 'started' });
496
+
497
+ for (const action of plan.externalActions ?? []) {
498
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
499
+ if (!adapterActionType) continue;
500
+ try {
501
+ adapterRegistry.getAdapter(adapterActionType);
502
+ } catch {
503
+ throw new ReleaseError(
504
+ GATE_FAILED,
505
+ `no adapter registered for action type "${adapterActionType}" (plan action "${action.id}")`,
506
+ { actionId: action.id, adapterActionType },
507
+ );
508
+ }
509
+ }
510
+
511
+ await evidence.append({ phase: 'safety-gate', gate: 'adapter-availability', status: 'passed' });
512
+
513
+ // =======================================================================
514
+ // Map source run checkpoints to plan actions
515
+ // =======================================================================
516
+ const planActions = (plan.externalActions ?? []).slice().sort((a, b) => {
517
+ const ai = CHECKPOINT_ORDER.indexOf(a.type);
518
+ const bi = CHECKPOINT_ORDER.indexOf(b.type);
519
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
520
+ });
521
+
522
+ // Build a map from actionId -> source run checkpoint
523
+ const sourceCpMap = new Map();
524
+ for (const cp of sourceRun.checkpoints) {
525
+ sourceCpMap.set(cp.actionId, cp);
526
+ }
527
+
528
+ await evidence.append({
529
+ phase: 'reconcile',
530
+ status: 'started',
531
+ actionCount: planActions.length,
532
+ sourceRunId: sourceRun.runId,
533
+ sourceRunDigest: sourceRun.runDigest,
534
+ });
535
+
536
+ const context = {
537
+ externalWritesAuthorized: false,
538
+ plan,
539
+ baseline: plan.baseline,
540
+ root,
541
+ runDir,
542
+ };
543
+
544
+ // --- Phase 1: Process each plan action using source run checkpoint ---
545
+ //
546
+ // Marketplace actions are reconstructable isolated consumer checks, not
547
+ // permanent remote writes. They are handled with a fresh
548
+ // preflight -> execute -> verify cycle in an isolated directory, using
549
+ // isolatedConsumerWritesAuthorized instead of externalWritesAuthorized.
550
+ //
551
+ // Non-marketplace actions use observe-based consistency checks.
552
+ // =======================================================================
553
+ const actionsToRetry = [];
554
+ const actionResults = new Map(); // actionId -> final status
555
+ let retryFailed = false;
556
+
557
+ for (const action of planActions) {
558
+ const sourceCp = sourceCpMap.get(action.id);
559
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
560
+
561
+ if (!adapterActionType) {
562
+ // Meta-checkpoint, skip
563
+ actionResults.set(action.id, 'skipped');
564
+ continue;
565
+ }
566
+
567
+ const adapter = adapterRegistry.getAdapter(adapterActionType);
568
+
569
+ // -------------------------------------------------------------------
570
+ // Marketplace actions: isolated consumer verification
571
+ // -------------------------------------------------------------------
572
+ if (MARKETPLACE_TYPES.has(action.type)) {
573
+ const marketplaceContext = {
574
+ externalWritesAuthorized: false,
575
+ isolatedConsumerWritesAuthorized: true,
576
+ plan,
577
+ baseline: plan.baseline,
578
+ root,
579
+ runDir,
580
+ };
581
+
582
+ const actionInput = {
583
+ actionType: adapterActionType,
584
+ ...action.parameters,
585
+ };
586
+
587
+ // Preflight (always runs — validates frozen snapshot)
588
+ const preflightResult = await adapter.preflight(actionInput, marketplaceContext);
589
+ if (preflightResult.status === 'PREFLIGHT_FAILED') {
590
+ actionResults.set(action.id, 'failed');
591
+ await evidence.append({
592
+ phase: 'reconcile-marketplace',
593
+ actionId: action.id,
594
+ actionType: action.type,
595
+ decision: 'preflight-failed',
596
+ error: preflightResult.error,
597
+ });
598
+ throw new ReleaseError(
599
+ POST_PUBLISH_VERIFY_FAILED,
600
+ `marketplace preflight failed for action "${action.id}": ${preflightResult.error}`,
601
+ { actionId: action.id },
602
+ );
603
+ }
604
+
605
+ // Execute (install to isolated consumer directory)
606
+ const executeResult = await adapter.execute(actionInput, marketplaceContext);
607
+ if (executeResult.status !== 'EXECUTED') {
608
+ actionResults.set(action.id, 'failed');
609
+ await evidence.append({
610
+ phase: 'reconcile-marketplace',
611
+ actionId: action.id,
612
+ actionType: action.type,
613
+ decision: 'execute-failed',
614
+ error: executeResult.error,
615
+ });
616
+ // Execute failed — if source was succeeded this is a conflict;
617
+ // if source was failed/pending, add to retry list
618
+ if (sourceCp.status === 'succeeded') {
619
+ throw new ReleaseError(
620
+ REMOTE_CONFLICT,
621
+ `marketplace execute failed for SUCCEEDED action "${action.id}": ${executeResult.error}`,
622
+ { actionId: action.id },
623
+ );
624
+ }
625
+ actionsToRetry.push(action);
626
+ continue;
627
+ }
628
+
629
+ // Verify (observe + match against plan expected state)
630
+ const verifyResult = await adapter.verify(
631
+ { ...actionInput, expected: action.expected },
632
+ marketplaceContext,
633
+ );
634
+
635
+ if (verifyResult.status === 'VERIFIED') {
636
+ // Consistent: source succeeded => skipped, source failed => succeeded (recovered)
637
+ const resultStatus = sourceCp.status === 'succeeded' ? 'skipped' : 'succeeded';
638
+ actionResults.set(action.id, resultStatus);
639
+ await evidence.append({
640
+ phase: 'reconcile-marketplace',
641
+ actionId: action.id,
642
+ actionType: action.type,
643
+ decision: sourceCp.status === 'succeeded' ? 'skip-source-succeeded-consistent' : 'recovered',
644
+ sourceStatus: sourceCp.status,
645
+ });
646
+ } else {
647
+ // Verify mismatch
648
+ await evidence.append({
649
+ phase: 'reconcile-marketplace',
650
+ actionId: action.id,
651
+ actionType: action.type,
652
+ decision: 'verify-mismatch',
653
+ error: verifyResult.error,
654
+ });
655
+ if (sourceCp.status === 'succeeded') {
656
+ throw new ReleaseError(
657
+ REMOTE_CONFLICT,
658
+ `marketplace verify mismatch for SUCCEEDED action "${action.id}": ${verifyResult.error}`,
659
+ { actionId: action.id },
660
+ );
661
+ }
662
+ // Source was failed/pending and verify mismatched after retry => BLOCKED
663
+ actionResults.set(action.id, 'failed');
664
+ retryFailed = true;
665
+ // Don't add to retry — this is a verify mismatch, not a missing state
666
+ }
667
+ continue;
668
+ }
669
+
670
+ // -------------------------------------------------------------------
671
+ // Non-marketplace actions: observe-based consistency checks
672
+ // -------------------------------------------------------------------
673
+
674
+ if (sourceCp.status === 'succeeded') {
675
+ // SUCCEEDED: re-observe to verify remote state is still consistent.
676
+ // Item 20 invariant: if the source checkpoint already succeeded, any
677
+ // observe failure (empty observation, auth error, network error, or
678
+ // uncertain state) must fail closed and require human intervention.
679
+ // We never add a SUCCEEDED action to actionsToRetry or re-execute it.
680
+ const observeResult = await adapter.observe(
681
+ { actionType: adapterActionType, ...action.parameters },
682
+ context,
683
+ );
684
+
685
+ if (action.expected) {
686
+ if (
687
+ !observeResult.observation ||
688
+ (observeResult.error && Object.keys(observeResult.observation).length === 0)
689
+ ) {
690
+ // SUCCEEDED action but observe failed: fail closed. The remote
691
+ // state is uncertain — we cannot confirm consistency, so we
692
+ // cannot proceed. Requires human intervention.
693
+ await evidence.append({
694
+ phase: 'reconcile-observe',
695
+ actionId: action.id,
696
+ actionType: action.type,
697
+ decision: 'succeeded-observe-failed-fail-closed',
698
+ error: observeResult.error,
699
+ });
700
+
701
+ throw new ReleaseError(
702
+ REMOTE_CONFLICT,
703
+ `SUCCEEDED action "${action.id}" observe returned empty/error: ${observeResult.error ?? 'empty observation'}. Remote state uncertain; manual verification required.`,
704
+ { actionId: action.id, observeError: observeResult.error },
705
+ );
706
+ }
707
+
708
+ const { matches, mismatches } = matchObservation(
709
+ action.expected,
710
+ observeResult.observation,
711
+ );
712
+
713
+ if (!matches) {
714
+ await evidence.append({
715
+ phase: 'reconcile-observe',
716
+ actionId: action.id,
717
+ actionType: action.type,
718
+ decision: 'remote-conflict',
719
+ mismatches,
720
+ });
721
+
722
+ throw new ReleaseError(
723
+ REMOTE_CONFLICT,
724
+ `Remote state conflict for SUCCEEDED action "${action.id}": ${mismatches.join('; ')}`,
725
+ { actionId: action.id, mismatches },
726
+ );
727
+ }
728
+ }
729
+
730
+ actionResults.set(action.id, 'succeeded');
731
+ await evidence.append({
732
+ phase: 'reconcile-observe',
733
+ actionId: action.id,
734
+ actionType: action.type,
735
+ decision: 'skip-succeeded-verified',
736
+ });
737
+ continue;
738
+ }
739
+
740
+ // FAILED or PENDING: observe remote state
741
+ const observeResult = await adapter.observe(
742
+ { actionType: adapterActionType, ...action.parameters },
743
+ context,
744
+ );
745
+
746
+ if (
747
+ !observeResult.observation ||
748
+ (observeResult.error && Object.keys(observeResult.observation).length === 0)
749
+ ) {
750
+ await evidence.append({
751
+ phase: 'reconcile-observe',
752
+ actionId: action.id,
753
+ actionType: action.type,
754
+ decision: 'uncertain-observation-fail-closed',
755
+ error: observeResult.error ?? null,
756
+ });
757
+ throw new ReleaseError(
758
+ REMOTE_CONFLICT,
759
+ `action "${action.id}" cannot be retried because remote state is unobservable`,
760
+ { actionId: action.id, observeError: observeResult.error },
761
+ );
762
+ }
763
+
764
+ const explicitlyMissing = observeResult.observation.exists === false
765
+ || observeResult.observation.remoteCommit === ''
766
+ || observeResult.observation.commit === ''
767
+ || observeResult.observation.published === false;
768
+ if (explicitlyMissing) {
769
+ actionsToRetry.push(action);
770
+ await evidence.append({
771
+ phase: 'reconcile-observe',
772
+ actionId: action.id,
773
+ actionType: action.type,
774
+ decision: 'retry-explicitly-missing',
775
+ });
776
+ continue;
777
+ }
778
+
779
+ // Check if remote already has the expected state
780
+ if (observeResult.observation && action.expected) {
781
+ const { matches, mismatches } = matchObservation(
782
+ action.expected,
783
+ observeResult.observation,
784
+ );
785
+
786
+ if (matches) {
787
+ actionResults.set(action.id, 'skipped');
788
+ await evidence.append({
789
+ phase: 'reconcile-observe',
790
+ actionId: action.id,
791
+ actionType: action.type,
792
+ decision: 'skip-remote-consistent',
793
+ });
794
+ continue;
795
+ }
796
+
797
+ // Remote state exists but doesn't match: REMOTE_CONFLICT
798
+ await evidence.append({
799
+ phase: 'reconcile-observe',
800
+ actionId: action.id,
801
+ actionType: action.type,
802
+ decision: 'remote-conflict',
803
+ mismatches,
804
+ });
805
+
806
+ throw new ReleaseError(
807
+ REMOTE_CONFLICT,
808
+ `Remote state conflict for action "${action.id}": ${mismatches.join('; ')}`,
809
+ { actionId: action.id, mismatches },
810
+ );
811
+ }
812
+
813
+ // No expected observation or no remote state: needs retry
814
+ actionsToRetry.push(action);
815
+ await evidence.append({
816
+ phase: 'reconcile-observe',
817
+ actionId: action.id,
818
+ actionType: action.type,
819
+ decision: 'retry',
820
+ });
821
+ }
822
+
823
+ // --- Phase 2: Validate approval and global preflight before retrying ---
824
+ //
825
+ // Marketplace actions in the retry list do NOT require approval or
826
+ // productionConfirmation; they are isolated consumer checks, not
827
+ // permanent remote writes. Only non-marketplace retries require approval.
828
+ // =======================================================================
829
+ // Split retry actions into marketplace and non-marketplace
830
+ const marketplaceRetries = actionsToRetry.filter((a) => MARKETPLACE_TYPES.has(a.type));
831
+ const nonMarketplaceRetries = actionsToRetry.filter((a) => !MARKETPLACE_TYPES.has(a.type));
832
+
833
+ if (nonMarketplaceRetries.length > 0) {
834
+ // Non-marketplace retries require approval and productionConfirmation
835
+ if (
836
+ plan.production?.mode === 'github-npm-v1' &&
837
+ productionConfirmation !== actualDigest
838
+ ) {
839
+ throw new ReleaseError(
840
+ GATE_FAILED,
841
+ 'production reconcile confirmation must exactly match the current plan digest before retry',
842
+ { planDigest: actualDigest, actionsToRetry: nonMarketplaceRetries.map((action) => action.id) },
843
+ );
844
+ }
845
+ if (!approval) {
846
+ throw new ReleaseError(
847
+ GATE_FAILED,
848
+ 'approval record is required when actions need retry but none was provided',
849
+ { actionsToRetry: nonMarketplaceRetries.map((a) => a.id) },
850
+ );
851
+ }
852
+
853
+ context.externalWritesAuthorized = true;
854
+
855
+ await evidence.append({
856
+ phase: 'reconcile-approval',
857
+ status: 'validated',
858
+ retryActionCount: nonMarketplaceRetries.length,
859
+ });
860
+ }
861
+
862
+ if (actionsToRetry.length > 0) {
863
+ // Global preflight: validate ALL retry actions before any execute
864
+ await evidence.append({ phase: 'reconcile-preflight', status: 'started' });
865
+
866
+ for (const action of actionsToRetry) {
867
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
868
+ const adapter = adapterRegistry.getAdapter(adapterActionType);
869
+
870
+ // Use appropriate context for each action type
871
+ const preflightCtx = MARKETPLACE_TYPES.has(action.type)
872
+ ? { ...context, externalWritesAuthorized: false, isolatedConsumerWritesAuthorized: true }
873
+ : context;
874
+
875
+ const preflightResult = await adapter.preflight(
876
+ { actionType: adapterActionType, ...action.parameters },
877
+ preflightCtx,
878
+ );
879
+
880
+ if (preflightResult.status === 'PREFLIGHT_FAILED') {
881
+ // Mark all retry actions as failed, stop
882
+ for (const retryAction of actionsToRetry) {
883
+ actionResults.set(retryAction.id, 'failed');
884
+ }
885
+ retryFailed = true;
886
+
887
+ await evidence.append({
888
+ phase: 'reconcile-preflight',
889
+ status: 'failed',
890
+ actionId: action.id,
891
+ error: preflightResult.error,
892
+ });
893
+ break;
894
+ }
895
+ }
896
+
897
+ if (!retryFailed) {
898
+ await evidence.append({ phase: 'reconcile-preflight', status: 'passed' });
899
+ }
900
+ }
901
+
902
+ // Build an append-only reconcile journal before the first retry execute.
903
+ // It carries the complete source lineage and becomes PARTIAL as soon as an
904
+ // action is marked uncertain, so a process kill is directly recoverable.
905
+ const reconcileStartedAt = clockFn();
906
+ let retryStateSequence = -1;
907
+ let latestRetryState = null;
908
+ const buildReconcileState = (status = PARTIAL, finishedAt) => ({
909
+ runId,
910
+ command: 'reconcile',
911
+ planDigest: plan.digest,
912
+ planPath,
913
+ ...(consumedApprovalPath ? {
914
+ approvalPath: consumedApprovalPath,
915
+ approvalDigest: consumedApprovalDigest,
916
+ } : {}),
917
+ sourceRunId: sourceRun.runId,
918
+ sourceRunDigest: sourceAuthorityDigest,
919
+ sourceRunPath,
920
+ status,
921
+ checkpoints: planActions.map((action) => {
922
+ const value = actionResults.get(action.id);
923
+ return {
924
+ actionId: action.id,
925
+ actionType: action.type,
926
+ status: value === 'succeeded' ? 'succeeded'
927
+ : value === 'skipped' ? 'skipped'
928
+ : value === 'failed' ? 'failed'
929
+ : value === 'uncertain' ? 'uncertain'
930
+ : 'pending',
931
+ };
932
+ }),
933
+ startedAt: reconcileStartedAt,
934
+ ...(finishedAt ? { finishedAt } : {}),
935
+ });
936
+ const persistRetryState = async (status = PARTIAL, finishedAt) => {
937
+ retryStateSequence += 1;
938
+ latestRetryState = await appendRunState(
939
+ runDir,
940
+ retryStateSequence,
941
+ buildReconcileState(status, finishedAt),
942
+ );
943
+ return latestRetryState;
944
+ };
945
+ if (!retryFailed && actionsToRetry.length > 0) {
946
+ await persistRetryState('PUBLISHING');
947
+ }
948
+
949
+ // --- Phase 3: Execute retries ---
950
+ if (!retryFailed && actionsToRetry.length > 0) {
951
+ for (const action of actionsToRetry) {
952
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
953
+ const adapter = adapterRegistry.getAdapter(adapterActionType);
954
+
955
+ // Use appropriate context: marketplace uses isolated consumer writes,
956
+ // non-marketplace uses external writes
957
+ const retryCtx = MARKETPLACE_TYPES.has(action.type)
958
+ ? { ...context, externalWritesAuthorized: false, isolatedConsumerWritesAuthorized: true }
959
+ : context;
960
+
961
+ await evidence.append({
962
+ phase: 'reconcile-retry',
963
+ actionId: action.id,
964
+ actionType: action.type,
965
+ status: 'started',
966
+ });
967
+
968
+ // Persist UNCERTAIN before authorizing execute. This snapshot is a
969
+ // complete PARTIAL authority and can be fed back to reconcile after a
970
+ // process kill.
971
+ actionResults.set(action.id, 'uncertain');
972
+ await persistRetryState(PARTIAL);
973
+
974
+ let executeResult;
975
+ try {
976
+ executeResult = await adapter.execute(
977
+ { actionType: adapterActionType, ...action.parameters },
978
+ retryCtx,
979
+ );
980
+ } catch (error) {
981
+ executeResult = { status: 'EXECUTE_FAILED', error: error.message };
982
+ }
983
+
984
+ if (executeResult.status === 'EXECUTED') {
985
+ // Must verify after execute to check remote/install state
986
+ if (MARKETPLACE_TYPES.has(action.type)) {
987
+ // Marketplace: verify via adapter.verify (observe + matchObservation)
988
+ const verifyResult = await adapter.verify(
989
+ { actionType: adapterActionType, ...action.parameters, expected: action.expected },
990
+ retryCtx,
991
+ );
992
+
993
+ if (verifyResult.status !== 'VERIFIED') {
994
+ actionResults.set(action.id, 'failed');
995
+ retryFailed = true;
996
+ await evidence.append({
997
+ phase: 'reconcile-retry',
998
+ actionId: action.id,
999
+ actionType: action.type,
1000
+ status: 'verify-mismatch',
1001
+ error: verifyResult.error,
1002
+ });
1003
+ await persistRetryState(PARTIAL);
1004
+ break;
1005
+ }
1006
+ } else {
1007
+ // Non-marketplace: observe after execute to verify remote state
1008
+ let observation;
1009
+ let observeError;
1010
+ try {
1011
+ const observeResult = await adapter.observe(
1012
+ { actionType: adapterActionType, ...action.parameters, expected: action.expected },
1013
+ retryCtx,
1014
+ );
1015
+ observation = observeResult.observation;
1016
+ observeError = observeResult.error;
1017
+ } catch (error) {
1018
+ observeError = error.message;
1019
+ }
1020
+ if (!observation || (observeError && Object.keys(observation).length === 0)) {
1021
+ actionResults.set(action.id, 'uncertain');
1022
+ retryFailed = true;
1023
+ await evidence.append({
1024
+ phase: 'reconcile-retry',
1025
+ actionId: action.id,
1026
+ actionType: action.type,
1027
+ status: 'observe-failed',
1028
+ error: observeError ?? 'empty observation after retry execute',
1029
+ });
1030
+ await persistRetryState(PARTIAL);
1031
+ break;
1032
+ }
1033
+
1034
+ // Check observation mismatch
1035
+ if (action.expected) {
1036
+ const { matches } = matchObservation(action.expected, observation);
1037
+ if (!matches) {
1038
+ actionResults.set(action.id, 'failed');
1039
+ retryFailed = true;
1040
+ await evidence.append({
1041
+ phase: 'reconcile-retry',
1042
+ actionId: action.id,
1043
+ actionType: action.type,
1044
+ status: 'observe-mismatch',
1045
+ error: 'observation does not match expected after retry execute',
1046
+ });
1047
+ await persistRetryState(PARTIAL);
1048
+ break;
1049
+ }
1050
+ } else if (observation && observation.mismatched) {
1051
+ actionResults.set(action.id, 'failed');
1052
+ retryFailed = true;
1053
+ await evidence.append({
1054
+ phase: 'reconcile-retry',
1055
+ actionId: action.id,
1056
+ actionType: action.type,
1057
+ status: 'observe-mismatch',
1058
+ error: 'observation indicates mismatch after retry execute',
1059
+ });
1060
+ await persistRetryState(PARTIAL);
1061
+ break;
1062
+ }
1063
+ }
1064
+
1065
+ actionResults.set(action.id, 'succeeded');
1066
+ await evidence.append({
1067
+ phase: 'reconcile-retry',
1068
+ actionId: action.id,
1069
+ actionType: action.type,
1070
+ status: 'completed',
1071
+ });
1072
+ await persistRetryState(PARTIAL);
1073
+ } else {
1074
+ // A transport failure may occur after the remote accepted the
1075
+ // write. Non-marketplace actions must therefore be observed before
1076
+ // classifying the checkpoint; unknown remains uncertain and an
1077
+ // already-consistent remote is treated as succeeded.
1078
+ if (!MARKETPLACE_TYPES.has(action.type)) {
1079
+ let observeResult;
1080
+ try {
1081
+ observeResult = await adapter.observe(
1082
+ { actionType: adapterActionType, ...action.parameters, expected: action.expected },
1083
+ retryCtx,
1084
+ );
1085
+ } catch (error) {
1086
+ observeResult = { observation: null, error: error.message };
1087
+ }
1088
+ const observation = observeResult?.observation;
1089
+ const missing = observation?.exists === false
1090
+ || observation?.remoteCommit === ''
1091
+ || observation?.commit === ''
1092
+ || observation?.published === false;
1093
+ const matches = action.expected && observation
1094
+ ? matchObservation(action.expected, observation).matches
1095
+ : false;
1096
+ if (matches) {
1097
+ actionResults.set(action.id, 'succeeded');
1098
+ await evidence.append({
1099
+ phase: 'reconcile-retry',
1100
+ actionId: action.id,
1101
+ actionType: action.type,
1102
+ status: 'completed-after-execute-failure-observe',
1103
+ });
1104
+ await persistRetryState(PARTIAL);
1105
+ continue;
1106
+ }
1107
+ actionResults.set(action.id, missing ? 'failed' : 'uncertain');
1108
+ } else {
1109
+ actionResults.set(action.id, 'failed');
1110
+ }
1111
+ retryFailed = true;
1112
+
1113
+ await evidence.append({
1114
+ phase: 'reconcile-retry',
1115
+ actionId: action.id,
1116
+ actionType: action.type,
1117
+ status: actionResults.get(action.id),
1118
+ error: executeResult.error,
1119
+ });
1120
+ await persistRetryState(PARTIAL);
1121
+ break;
1122
+ }
1123
+ }
1124
+ }
1125
+
1126
+ // Mark remaining retry actions as pending if we stopped early
1127
+ if (retryFailed) {
1128
+ let foundFailed = false;
1129
+ for (const action of actionsToRetry) {
1130
+ if (['failed', 'uncertain'].includes(actionResults.get(action.id))) {
1131
+ foundFailed = true;
1132
+ continue;
1133
+ }
1134
+ if (foundFailed) {
1135
+ actionResults.set(action.id, 'pending');
1136
+ }
1137
+ }
1138
+ }
1139
+
1140
+ // =======================================================================
1141
+ // Determine final status
1142
+ // =======================================================================
1143
+ const allSucceeded = planActions.every(
1144
+ (a) => {
1145
+ const result = actionResults.get(a.id);
1146
+ return result === 'succeeded' || result === 'skipped';
1147
+ },
1148
+ );
1149
+
1150
+ const effectiveFromStatus = PARTIAL;
1151
+
1152
+ // Map reconcile outcome to state machine target
1153
+ let overallStatus;
1154
+ if (allSucceeded && !retryFailed) {
1155
+ overallStatus = PUBLISHED;
1156
+ } else if (retryFailed) {
1157
+ // Once any retry execute was attempted, recovery must remain PARTIAL
1158
+ // even when no success was observed. BLOCKED is only pre-execute.
1159
+ overallStatus = PARTIAL;
1160
+ } else {
1161
+ // All remote checkpoints are consistent, but installation smoke still
1162
+ // belongs to the separate verify command.
1163
+ overallStatus = PUBLISHED;
1164
+ }
1165
+
1166
+ // Only validate state transition if status actually changes
1167
+ if (effectiveFromStatus !== overallStatus) {
1168
+ assertTransition(effectiveFromStatus, overallStatus);
1169
+ }
1170
+
1171
+ await evidence.append({
1172
+ phase: 'reconcile',
1173
+ status: 'completed',
1174
+ overallStatus,
1175
+ actionStatuses: planActions.map((a) => actionResults.get(a.id)),
1176
+ });
1177
+
1178
+ // Build checkpoints for return value and run file
1179
+ const resultCheckpoints = planActions.map((a) => {
1180
+ const status = actionResults.get(a.id) ?? 'pending';
1181
+ return {
1182
+ actionId: a.id,
1183
+ status: status === 'succeeded' ? 'succeeded'
1184
+ : status === 'failed' ? 'failed'
1185
+ : status === 'skipped' ? 'skipped'
1186
+ : status === 'uncertain' ? 'uncertain'
1187
+ : 'pending',
1188
+ };
1189
+ });
1190
+
1191
+ // Write new reconcile run with sourceRunId
1192
+ const runPath = join(runDir, 'release-run.json');
1193
+ const sourceRunDigest = sourceAuthorityDigest;
1194
+ const runState = {
1195
+ runId,
1196
+ command: 'reconcile',
1197
+ planDigest: plan.digest,
1198
+ planPath,
1199
+ ...(consumedApprovalPath ? {
1200
+ approvalPath: consumedApprovalPath,
1201
+ approvalDigest: consumedApprovalDigest,
1202
+ } : {}),
1203
+ sourceRunId: sourceRun.runId,
1204
+ sourceRunDigest,
1205
+ sourceRunPath,
1206
+ status: overallStatus,
1207
+ checkpoints: planActions.map((a) => {
1208
+ const status = actionResults.get(a.id) ?? 'pending';
1209
+ return {
1210
+ actionId: a.id,
1211
+ actionType: a.type,
1212
+ status: status === 'succeeded' ? 'succeeded'
1213
+ : status === 'failed' ? 'failed'
1214
+ : status === 'skipped' ? 'skipped'
1215
+ : status === 'uncertain' ? 'uncertain'
1216
+ : 'pending',
1217
+ };
1218
+ }),
1219
+ startedAt: reconcileStartedAt,
1220
+ finishedAt: clockFn(),
1221
+ };
1222
+ if (retryStateSequence >= 0) {
1223
+ await persistRetryState(overallStatus, runState.finishedAt);
1224
+ }
1225
+ const persistedRun = await writeRunAtomic(runPath, runState);
1226
+
1227
+ await evidence.finish({
1228
+ status: overallStatus,
1229
+ planPath,
1230
+ runPath,
1231
+ sourceRunId: sourceRun.runId,
1232
+ sourceRunDigest,
1233
+ sourceRunPath,
1234
+ runDigest: persistedRun.runDigest,
1235
+ actionStatuses: planActions.map((a) => actionResults.get(a.id)),
1236
+ completedAt: clockFn(),
1237
+ });
1238
+
1239
+ return { planPath, runPath, status: overallStatus, checkpoints: resultCheckpoints };
1240
+ } catch (err) {
1241
+ await evidence.append({
1242
+ phase: 'reconcile',
1243
+ status: 'failed',
1244
+ error: { code: err.code, message: err.message },
1245
+ });
1246
+
1247
+ await evidence.finish({
1248
+ status: 'FAILED',
1249
+ error: { code: err.code, message: err.message },
1250
+ failedAt: clockFn(),
1251
+ });
1252
+
1253
+ throw err;
1254
+ }
1255
+ }