release-skill 0.5.1 → 0.6.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 (100) 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 +27 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +15 -14
  10. package/README.zh-CN.md +16 -15
  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 +3779 -769
  14. package/adapters/claude/schemas/.render-manifest.json +6 -6
  15. package/adapters/claude/schemas/release-plan.schema.json +401 -2
  16. package/adapters/claude/schemas/release-project.schema.json +299 -0
  17. package/adapters/claude/schemas/release-run.schema.json +59 -6
  18. package/adapters/claude/skills/release-config/SKILL.md +140 -0
  19. package/adapters/claude/skills/release-docs/SKILL.md +106 -0
  20. package/adapters/claude/skills/release-help/SKILL.md +39 -0
  21. package/adapters/claude/skills/release-marketplace/SKILL.md +147 -0
  22. package/adapters/claude/skills/release-publish/SKILL.md +25 -6
  23. package/adapters/claude/skills/release-verify/SKILL.md +10 -0
  24. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  25. package/adapters/codex/bin/release-skill.bundle.mjs +3779 -769
  26. package/adapters/codex/schemas/.render-manifest.json +6 -6
  27. package/adapters/codex/schemas/release-plan.schema.json +401 -2
  28. package/adapters/codex/schemas/release-project.schema.json +299 -0
  29. package/adapters/codex/schemas/release-run.schema.json +59 -6
  30. package/adapters/codex/skills/release-config/SKILL.md +147 -0
  31. package/adapters/codex/skills/release-docs/SKILL.md +113 -0
  32. package/adapters/codex/skills/release-help/SKILL.md +39 -0
  33. package/adapters/codex/skills/release-marketplace/SKILL.md +154 -0
  34. package/adapters/codex/skills/release-publish/SKILL.md +25 -6
  35. package/adapters/codex/skills/release-verify/SKILL.md +10 -0
  36. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  37. package/adapters/kimi/bin/release-skill.bundle.mjs +3779 -769
  38. package/adapters/kimi/schemas/.render-manifest.json +6 -6
  39. package/adapters/kimi/schemas/release-plan.schema.json +401 -2
  40. package/adapters/kimi/schemas/release-project.schema.json +299 -0
  41. package/adapters/kimi/schemas/release-run.schema.json +59 -6
  42. package/adapters/kimi/skills/release-config/SKILL.md +147 -0
  43. package/adapters/kimi/skills/release-docs/SKILL.md +113 -0
  44. package/adapters/kimi/skills/release-help/SKILL.md +39 -0
  45. package/adapters/kimi/skills/release-marketplace/SKILL.md +154 -0
  46. package/adapters/kimi/skills/release-publish/SKILL.md +25 -6
  47. package/adapters/kimi/skills/release-verify/SKILL.md +10 -0
  48. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  49. package/adapters/workbuddy/bin/release-skill.bundle.mjs +3779 -769
  50. package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
  51. package/adapters/workbuddy/schemas/release-plan.schema.json +401 -2
  52. package/adapters/workbuddy/schemas/release-project.schema.json +299 -0
  53. package/adapters/workbuddy/schemas/release-run.schema.json +59 -6
  54. package/adapters/workbuddy/skills/release-config/SKILL.md +140 -0
  55. package/adapters/workbuddy/skills/release-docs/SKILL.md +106 -0
  56. package/adapters/workbuddy/skills/release-help/SKILL.md +39 -0
  57. package/adapters/workbuddy/skills/release-marketplace/SKILL.md +147 -0
  58. package/adapters/workbuddy/skills/release-publish/SKILL.md +25 -6
  59. package/adapters/workbuddy/skills/release-verify/SKILL.md +10 -0
  60. package/bin/release-skill-cli.mjs +246 -7
  61. package/bin/release-skill.bundle.mjs +3779 -769
  62. package/package.json +3 -2
  63. package/platform-manifest.json +359 -0
  64. package/references/.render-manifest.json +9 -9
  65. package/references/02-project-config.md +35 -0
  66. package/references/05-evidence-and-errors.md +45 -0
  67. package/references/06-adapter-contract.md +13 -0
  68. package/schemas/.render-manifest.json +6 -6
  69. package/schemas/release-plan.schema.json +401 -2
  70. package/schemas/release-project.schema.json +299 -0
  71. package/schemas/release-run.schema.json +59 -6
  72. package/scripts/build-bundle.mjs +11 -2
  73. package/scripts/sync-public-files.mjs +4 -0
  74. package/skills/release-config/SKILL.md +140 -0
  75. package/skills/release-docs/SKILL.md +106 -0
  76. package/skills/release-help/SKILL.md +39 -0
  77. package/skills/release-marketplace/SKILL.md +147 -0
  78. package/skills/release-publish/SKILL.md +25 -6
  79. package/skills/release-verify/SKILL.md +10 -0
  80. package/skills-src/release-config/SKILL.md +140 -0
  81. package/skills-src/release-docs/SKILL.md +106 -0
  82. package/skills-src/release-help/SKILL.md +39 -0
  83. package/skills-src/release-marketplace/SKILL.md +147 -0
  84. package/skills-src/release-publish/SKILL.md +25 -6
  85. package/skills-src/release-verify/SKILL.md +10 -0
  86. package/src/adapters/contract.mjs +5 -0
  87. package/src/adapters/distribute-git.mjs +625 -0
  88. package/src/commands/distribute.mjs +1078 -0
  89. package/src/commands/lineage.mjs +616 -0
  90. package/src/commands/prepare.mjs +548 -83
  91. package/src/commands/route.mjs +686 -0
  92. package/src/commands/ship.mjs +45 -1
  93. package/src/commands/verify.mjs +198 -0
  94. package/src/core/baseline-advance.mjs +185 -0
  95. package/src/core/bundle-freshness.mjs +236 -0
  96. package/src/core/checkpoints.mjs +25 -0
  97. package/src/core/errors.mjs +2 -0
  98. package/src/core/frozen-marker.mjs +97 -0
  99. package/src/core/hooks.mjs +12 -1
  100. package/src/core/postpublish.mjs +315 -0
@@ -0,0 +1,1078 @@
1
+ /**
2
+ * Distribute command: post-publish distribution saga (R1–R4).
3
+ *
4
+ * After publish has pushed the frozen tag, `distributeRelease` mirrors the
5
+ * release payload to the consumer-declared postPublish targets (append-only
6
+ * git mirrors + marketplace indexes) through the distribute-git adapter.
7
+ *
8
+ * Timing contract (R1): the payload may ONLY ever come from a detached git
9
+ * worktree checked out at the frozen `postPublish.tagCommit` — never from
10
+ * the live workspace, which has already moved ahead. The consumer-declared
11
+ * materialize hook runs inside that worktree and announces the isolated
12
+ * payload directory through its `outputMarker` line.
13
+ *
14
+ * Safety gates (all verified before any adapter execute):
15
+ * 1. plan schema validation
16
+ * 2. plan digest verification (computePlanDigest vs plan.digest)
17
+ * 3. approval record validation (core/approval.mjs)
18
+ * 4. source run lineage: publish|reconcile run at PUBLISHED|VERIFIED whose
19
+ * planDigest matches the frozen plan (loadRun requireDigest)
20
+ * 5. tag identity: `git rev-parse --verify <tag>^{commit}` in root must
21
+ * equal the frozen postPublish.tagCommit (fail-closed when absent)
22
+ * 5b. optional assertMainVersionAhead (commit descent of HEAD over the tag)
23
+ * 6. postPublish declaration re-validation (core/postpublish.mjs)
24
+ * 7. per-target reachability preflight (DISTRIBUTE_PROBE)
25
+ *
26
+ * Reconcile equivalence: a rerun IS the reconcile. Every target is observed
27
+ * BEFORE any write; a remote whose tag exists and whose branch tip equals
28
+ * the tag oid is SKIPPED (CONSISTENT) without executing. distribute never
29
+ * forces: NO_CHANGE is reported honestly, existing-tag conflicts fail closed.
30
+ *
31
+ * Failure semantics:
32
+ * - Gate failures THROW ReleaseError. From the source-run gate on (lineage
33
+ * known) a BLOCKED run record is persisted before rethrowing.
34
+ * - Target failures RETURN: PARTIAL when at least one push succeeded,
35
+ * BLOCKED when zero writes landed. Targets after a failure are skipped.
36
+ *
37
+ * @module commands/distribute
38
+ */
39
+
40
+ import { execFile as execFileCb } from 'node:child_process';
41
+ import { promisify } from 'node:util';
42
+ import { realpath, lstat, mkdir, readFile, rm } from 'node:fs/promises';
43
+ import { mkdtemp } from 'node:fs/promises';
44
+ import { tmpdir } from 'node:os';
45
+ import { isAbsolute, join, relative, resolve } from 'node:path';
46
+
47
+ import { assertImmutablePlanAuthority, computePlanDigest, validatePlan } from '../core/plan.mjs';
48
+ import {
49
+ assertImmutableApprovalAuthority,
50
+ computeApprovalDigest,
51
+ validateApproval,
52
+ validateApprovalRecordSchema,
53
+ } from '../core/approval.mjs';
54
+ import {
55
+ appendRunState,
56
+ createProductionRunDir,
57
+ loadRun,
58
+ resolveDefaultRunDir,
59
+ validateRunPlanDigest,
60
+ writeRunAtomic,
61
+ } from '../core/run.mjs';
62
+ import { createEvidenceWriter } from '../core/evidence.mjs';
63
+ import { runHook } from '../core/hooks.mjs';
64
+ import {
65
+ orderTargetsByDependency,
66
+ validatePostPublishDeclaration,
67
+ PAYLOAD_SOURCE_TAG_WORKTREE,
68
+ } from '../core/postpublish.mjs';
69
+ import {
70
+ ReleaseError,
71
+ GATE_FAILED,
72
+ POST_PUBLISH_VERIFY_FAILED,
73
+ REMOTE_CONFLICT,
74
+ REMOTE_UNAVAILABLE,
75
+ } from '../core/errors.mjs';
76
+ import { ActionType } from '../adapters/contract.mjs';
77
+
78
+ const execFileAsync = promisify(execFileCb);
79
+
80
+ /** Executor identity recorded in every distribute checkpoint trace. */
81
+ const EXECUTOR = 'release-skill distribute';
82
+
83
+ /** Tail length for hook stdout/stderr recorded in evidence. */
84
+ const TAIL_CHARS = 4000;
85
+
86
+ /** Full 40-hex commit sha. */
87
+ const SHA_RE = /^[a-f0-9]{40}$/;
88
+
89
+ /** Checkpoint error codes accepted by the release-run schema enum. */
90
+ const SCHEMA_ERROR_CODES = new Set([
91
+ 'CONFIG_INVALID',
92
+ 'BASELINE_CHANGED',
93
+ 'DIRTY_SCOPE_CONFLICT',
94
+ 'GATE_FAILED',
95
+ 'AUTH_MISSING',
96
+ 'REMOTE_CONFLICT',
97
+ 'REMOTE_UNAVAILABLE',
98
+ 'HOOK_TIMEOUT',
99
+ 'PARTIAL_RELEASE',
100
+ 'POST_PUBLISH_VERIFY_FAILED',
101
+ ]);
102
+
103
+ /** Run statuses. */
104
+ const DISTRIBUTING = 'DISTRIBUTING';
105
+ const DISTRIBUTED = 'DISTRIBUTED';
106
+ const PARTIAL = 'PARTIAL';
107
+ const BLOCKED = 'BLOCKED';
108
+
109
+ function defaultClock() {
110
+ return new Date().toISOString();
111
+ }
112
+
113
+ function defaultExec(command, args, options = {}) {
114
+ return execFileAsync(command, args, { shell: false, encoding: 'utf8', timeout: 120_000, ...options });
115
+ }
116
+
117
+ function tail(text, limit = TAIL_CHARS) {
118
+ const value = `${text ?? ''}`;
119
+ return value.length > limit ? value.slice(-limit) : value;
120
+ }
121
+
122
+ /** Map an adapter/details error code onto the run-schema checkpoint enum. */
123
+ function mapToSchemaCode(code) {
124
+ return SCHEMA_ERROR_CODES.has(code) ? code : GATE_FAILED;
125
+ }
126
+
127
+ /**
128
+ * Parse the first JSON object line from hook stdout
129
+ * (`requireReport.parse: "stdout-first-json"`).
130
+ *
131
+ * @param {string} stdout
132
+ * @returns {Object|null} The first line that parses to a plain object.
133
+ */
134
+ export function parseFirstJsonObject(stdout) {
135
+ for (const line of `${stdout ?? ''}`.split('\n')) {
136
+ const trimmed = line.trim();
137
+ if (!trimmed.startsWith('{')) continue;
138
+ try {
139
+ const value = JSON.parse(trimmed);
140
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
141
+ } catch {
142
+ // Not JSON: keep scanning (hooks may print brace-prefixed noise).
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Resolve the LAST stdout line containing the output marker to the text
150
+ * after the marker (the announced payload directory).
151
+ *
152
+ * @param {string} stdout
153
+ * @param {string} marker
154
+ * @returns {string|null}
155
+ */
156
+ export function parseOutputMarker(stdout, marker) {
157
+ const lines = `${stdout ?? ''}`.split('\n');
158
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
159
+ const markerIndex = lines[index].indexOf(marker);
160
+ if (markerIndex >= 0) {
161
+ return lines[index].slice(markerIndex + marker.length).trim();
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /** Assert `candidate` is a real directory contained inside `container`. */
168
+ async function assertContainedDirectory(container, candidate, label) {
169
+ let real;
170
+ try {
171
+ real = await realpath(candidate);
172
+ } catch {
173
+ throw new ReleaseError(
174
+ POST_PUBLISH_VERIFY_FAILED,
175
+ `${label} does not resolve to an existing path`,
176
+ { candidate },
177
+ );
178
+ }
179
+ const containerReal = await realpath(container);
180
+ const rel = relative(containerReal, real);
181
+ if (rel === '' || isAbsolute(rel) || rel === '..'
182
+ || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
183
+ throw new ReleaseError(
184
+ POST_PUBLISH_VERIFY_FAILED,
185
+ `${label} escapes the tag worktree; payload must stay inside the frozen checkout`,
186
+ { candidate },
187
+ );
188
+ }
189
+ const stats = await lstat(real).catch(() => null);
190
+ if (!stats || !stats.isDirectory()) {
191
+ throw new ReleaseError(
192
+ POST_PUBLISH_VERIFY_FAILED,
193
+ `${label} is not a directory`,
194
+ { candidate },
195
+ );
196
+ }
197
+ return real;
198
+ }
199
+
200
+ /**
201
+ * Resolve the marketplace pluginName from the frozen plan unit's
202
+ * distributions: claude-plugin first, codex-plugin as fallback.
203
+ */
204
+ function resolvePluginName(plan, unitId) {
205
+ const unit = (plan.units ?? []).find((entry) => entry.id === unitId);
206
+ const distributions = unit?.distributions ?? [];
207
+ const pluginDistribution = distributions.find((entry) => entry.type === 'claude-plugin')
208
+ ?? distributions.find((entry) => entry.type === 'codex-plugin');
209
+ if (!pluginDistribution || typeof pluginDistribution.plugin !== 'string' || pluginDistribution.plugin.length === 0) {
210
+ throw new ReleaseError(
211
+ GATE_FAILED,
212
+ `marketplace-index targets require a claude-plugin or codex-plugin distribution with a plugin name on unit "${unitId}"`,
213
+ { unitId },
214
+ );
215
+ }
216
+ return pluginDistribution.plugin;
217
+ }
218
+
219
+ /**
220
+ * Execute the post-publish distribution saga for a frozen, approved plan.
221
+ *
222
+ * @param {Object} options
223
+ * @param {string} options.planPath - Absolute path to the frozen release plan.
224
+ * @param {string} options.approvalPath - Absolute path to the approval record.
225
+ * @param {string} options.sourceRunPath - Absolute path to the sealed publish/reconcile run.
226
+ * @param {Object} options.adapterRegistry - Registry carrying the distribute adapter.
227
+ * @param {string} [options.root] - Project root (git repository). Defaults to cwd.
228
+ * @param {string} [options.runDir] - Evidence directory override.
229
+ * @param {boolean} [options.dryRun] - Stop before remote pushes (adapter-level).
230
+ * @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
231
+ * @param {Function} [options.execFn] - Injectable git exec (tests).
232
+ * @param {Function} [options.runHookFn] - Injectable hook runner (tests).
233
+ *
234
+ * @returns {Promise<{ planPath: string, runPath: string, status: string, checkpoints: Object[] }>}
235
+ *
236
+ * @throws {ReleaseError} on any safety-gate failure (BLOCKED record persisted
237
+ * once the source-run lineage is known).
238
+ */
239
+ export async function distributeRelease(options) {
240
+ const {
241
+ planPath,
242
+ approvalPath,
243
+ sourceRunPath,
244
+ adapterRegistry,
245
+ root = process.cwd(),
246
+ runDir: runDirOpt,
247
+ dryRun = false,
248
+ clock: clockOpt,
249
+ execFn,
250
+ runHookFn,
251
+ } = options ?? {};
252
+
253
+ const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
254
+ const exec = typeof execFn === 'function' ? execFn : defaultExec;
255
+ const hookRunner = typeof runHookFn === 'function' ? runHookFn : runHook;
256
+
257
+ // =========================================================================
258
+ // Gate 1: load and validate the frozen plan (before any write — a rejected
259
+ // plan must never allocate a run authority).
260
+ // =========================================================================
261
+ let planRaw;
262
+ try {
263
+ planRaw = await readFile(planPath, 'utf8');
264
+ } catch (err) {
265
+ throw new ReleaseError(GATE_FAILED, `cannot read release plan: ${err.message}`, { planPath, cause: err.code });
266
+ }
267
+ let plan;
268
+ try {
269
+ plan = JSON.parse(planRaw);
270
+ } catch (err) {
271
+ throw new ReleaseError(GATE_FAILED, `release plan is not valid JSON: ${err.message}`, { planPath });
272
+ }
273
+ validatePlan(plan);
274
+ assertImmutablePlanAuthority(planPath, plan);
275
+
276
+ const actualDigest = computePlanDigest(plan);
277
+ if (plan.digest && plan.digest !== actualDigest) {
278
+ throw new ReleaseError(
279
+ GATE_FAILED,
280
+ `plan digest mismatch: expected ${String(plan.digest).slice(0, 16)}..., computed ${actualDigest.slice(0, 16)}...`,
281
+ { expected: plan.digest, actual: actualDigest },
282
+ );
283
+ }
284
+ if (!plan.postPublish) {
285
+ throw new ReleaseError(
286
+ GATE_FAILED,
287
+ 'release plan has no postPublish declaration; nothing to distribute',
288
+ { planPath },
289
+ );
290
+ }
291
+
292
+ // =========================================================================
293
+ // Run authority + evidence. Every subsequent gate failure is evidenced.
294
+ // =========================================================================
295
+ const runId = `distribute-${Date.now()}`;
296
+ let runDir = runDirOpt ?? resolveDefaultRunDir(planPath, 'distribute', runId);
297
+ if (plan.production?.mode === 'github-npm-v1') {
298
+ runDir = await createProductionRunDir(runDir, planPath);
299
+ } else {
300
+ await mkdir(runDir, { recursive: true });
301
+ }
302
+ const runPath = join(runDir, 'release-run.json');
303
+ const evidence = createEvidenceWriter({ runDir, command: 'distribute', clock: clockFn });
304
+
305
+ // Lineage becomes known at gate 3; only then may a BLOCKED record persist.
306
+ let lineageKnown = false;
307
+ let sourceRunId = null;
308
+ let sourceRunDigest = null;
309
+ let finalRecordWritten = false;
310
+
311
+ const startedAt = clockFn();
312
+ let checkpoints = [];
313
+ let stateSequence = -1;
314
+
315
+ const buildPersistedState = (status, finishedAt) => ({
316
+ runId,
317
+ command: 'distribute',
318
+ status,
319
+ planDigest: plan.digest ?? actualDigest,
320
+ planPath,
321
+ ...(approvalDigestValue ? { approvalDigest: approvalDigestValue } : {}),
322
+ ...(approvalPath ? { approvalPath } : {}),
323
+ ...(sourceRunId ? { sourceRunId } : {}),
324
+ ...(sourceRunDigest ? { sourceRunDigest } : {}),
325
+ ...(sourceRunPath ? { sourceRunPath } : {}),
326
+ startedAt,
327
+ ...(finishedAt ? { finishedAt } : {}),
328
+ checkpoints: checkpoints.map((cp) => ({
329
+ actionId: cp.actionId,
330
+ actionType: cp.actionType,
331
+ status: cp.status === 'SUCCEEDED' ? 'succeeded'
332
+ : cp.status === 'FAILED' ? 'failed'
333
+ : cp.status === 'SKIPPED' ? 'skipped'
334
+ : cp.status === 'UNCERTAIN' ? 'uncertain'
335
+ : cp.status === 'PENDING' ? 'pending'
336
+ : cp.status, // NO_CHANGE passes through as-is
337
+ ...(cp.preObserve ? { preObserve: cp.preObserve } : {}),
338
+ ...(cp.postObserve ? { postObserve: cp.postObserve } : {}),
339
+ ...(cp.remoteUrl ? { remoteUrl: cp.remoteUrl } : {}),
340
+ ...(cp.branch ? { branch: cp.branch } : {}),
341
+ ...(cp.tag ? { tag: cp.tag } : {}),
342
+ ...(cp.tagCommit ? { tagCommit: cp.tagCommit } : {}),
343
+ ...(cp.previousHead ? { previousHead: cp.previousHead } : {}),
344
+ ...(cp.pushedCommit ? { pushedCommit: cp.pushedCommit } : {}),
345
+ ...(cp.mode ? { mode: cp.mode } : {}),
346
+ ...(cp.executor ? { executor: cp.executor } : {}),
347
+ ...(cp.payloadFileCount !== undefined ? { payloadFileCount: cp.payloadFileCount } : {}),
348
+ ...(cp.startedAt ? { startedAt: cp.startedAt } : {}),
349
+ ...(cp.finishedAt ? { finishedAt: cp.finishedAt } : {}),
350
+ ...(cp.reason ? { reason: cp.reason } : {}),
351
+ ...(cp.error ? { error: { code: cp.error.code, ...(cp.error.message ? { message: cp.error.message } : {}) } } : {}),
352
+ })),
353
+ });
354
+
355
+ const snapshot = async (status) => {
356
+ stateSequence += 1;
357
+ return appendRunState(runDir, stateSequence, buildPersistedState(status));
358
+ };
359
+
360
+ /** Persist the BLOCKED run record once lineage is known (idempotent). */
361
+ const recordBlocked = async () => {
362
+ if (finalRecordWritten || !lineageKnown) return;
363
+ try {
364
+ await writeRunAtomic(runPath, buildPersistedState(BLOCKED, clockFn()));
365
+ finalRecordWritten = true;
366
+ } catch {
367
+ // Persistence must never mask the primary failure.
368
+ }
369
+ };
370
+
371
+ let approvalDigestValue = null;
372
+
373
+ // Worktree cleanup: registered exactly once, idempotent, failure-tolerant.
374
+ let worktreePath = null;
375
+ let tmpBase = null;
376
+ const cleanupWorktree = async () => {
377
+ if (worktreePath) {
378
+ await exec('git', ['-C', root, 'worktree', 'remove', '--force', worktreePath]).catch(() => {});
379
+ worktreePath = null;
380
+ }
381
+ if (tmpBase) {
382
+ await rm(tmpBase, { recursive: true, force: true }).catch(() => {});
383
+ tmpBase = null;
384
+ }
385
+ };
386
+
387
+ try {
388
+ // =======================================================================
389
+ // Gate 2: approval record validation.
390
+ // =======================================================================
391
+ await evidence.append({ phase: 'safety-gate', gate: 'approval-load', status: 'started' });
392
+
393
+ let approvalRaw;
394
+ try {
395
+ approvalRaw = await readFile(approvalPath, 'utf8');
396
+ } catch (err) {
397
+ throw new ReleaseError(GATE_FAILED, `cannot read approval record: ${err.message}`, { approvalPath, cause: err.code });
398
+ }
399
+ let approval;
400
+ try {
401
+ approval = JSON.parse(approvalRaw);
402
+ } catch (err) {
403
+ throw new ReleaseError(GATE_FAILED, `approval record is not valid JSON: ${err.message}`, { approvalPath });
404
+ }
405
+
406
+ approvalDigestValue = assertImmutableApprovalAuthority(approvalPath, plan, approvalRaw)
407
+ ?? computeApprovalDigest(approvalRaw);
408
+
409
+ validateApprovalRecordSchema(approval);
410
+ validateApproval(plan, approval, { clock: clockFn });
411
+
412
+ await evidence.append({ phase: 'safety-gate', gate: 'approval-validated', status: 'passed' });
413
+
414
+ // =======================================================================
415
+ // Gate 3: source run lineage (publish|reconcile at PUBLISHED|VERIFIED,
416
+ // digest-bound to the same frozen plan).
417
+ // =======================================================================
418
+ await evidence.append({ phase: 'safety-gate', gate: 'source-run', status: 'started', sourceRunPath });
419
+
420
+ const sourceRun = await loadRun(sourceRunPath, { requireDigest: true });
421
+ if (!['publish', 'reconcile'].includes(sourceRun.command)) {
422
+ throw new ReleaseError(
423
+ GATE_FAILED,
424
+ `source run command must be publish or reconcile, got "${sourceRun.command}"`,
425
+ { sourceRunPath, command: sourceRun.command },
426
+ );
427
+ }
428
+ if (!['PUBLISHED', 'VERIFIED'].includes(sourceRun.status)) {
429
+ throw new ReleaseError(
430
+ GATE_FAILED,
431
+ `source run must be PUBLISHED or VERIFIED before distribute, got "${sourceRun.status}"`,
432
+ { sourceRunPath, status: sourceRun.status },
433
+ );
434
+ }
435
+ validateRunPlanDigest(sourceRun, plan, { planPath });
436
+
437
+ sourceRunId = sourceRun.runId;
438
+ sourceRunDigest = sourceRun.runDigest;
439
+ lineageKnown = true;
440
+
441
+ await evidence.append({
442
+ phase: 'safety-gate',
443
+ gate: 'source-run',
444
+ status: 'passed',
445
+ sourceRunId,
446
+ sourceRunStatus: sourceRun.status,
447
+ });
448
+
449
+ // =======================================================================
450
+ // Declaration re-validation + deterministic target ordering.
451
+ // =======================================================================
452
+ const postPublish = plan.postPublish;
453
+ validatePostPublishDeclaration(postPublish, { unitId: postPublish.unitId });
454
+ const orderedTargets = orderTargetsByDependency(postPublish.targets);
455
+
456
+ // Checkpoint registry: one probe + one mirror per target, declared order.
457
+ checkpoints = [];
458
+ for (const target of orderedTargets) {
459
+ checkpoints.push({
460
+ actionId: `probe-${target.id}`,
461
+ actionType: ActionType.DISTRIBUTE_PROBE,
462
+ status: 'PENDING',
463
+ remoteUrl: target.remoteUrl,
464
+ branch: target.branch,
465
+ tag: postPublish.tag,
466
+ executor: EXECUTOR,
467
+ });
468
+ }
469
+ for (const target of orderedTargets) {
470
+ checkpoints.push({
471
+ actionId: target.id,
472
+ actionType: ActionType.DISTRIBUTE_MIRROR,
473
+ status: 'PENDING',
474
+ remoteUrl: target.remoteUrl,
475
+ branch: target.branch,
476
+ tag: postPublish.tag,
477
+ tagCommit: SHA_RE.test(postPublish.tagCommit ?? '') ? postPublish.tagCommit : undefined,
478
+ executor: EXECUTOR,
479
+ });
480
+ }
481
+ const checkpointById = new Map(checkpoints.map((cp) => [cp.actionId, cp]));
482
+
483
+ // Durable pre-execute authority (seq 0).
484
+ await snapshot(DISTRIBUTING);
485
+
486
+ /** Gate failure after lineage is known: persist BLOCKED, then rethrow. */
487
+ const failBlocked = async (error) => {
488
+ await recordBlocked();
489
+ throw error;
490
+ };
491
+
492
+ // =======================================================================
493
+ // Gate 4: tag identity — the live tag must still point at the frozen
494
+ // tagCommit. A missing binding or a moved tag fails closed.
495
+ // =======================================================================
496
+ await evidence.append({ phase: 'safety-gate', gate: 'tag-identity', status: 'started' });
497
+
498
+ if (postPublish.payloadSource !== PAYLOAD_SOURCE_TAG_WORKTREE) {
499
+ await failBlocked(new ReleaseError(
500
+ GATE_FAILED,
501
+ `postPublish.payloadSource must be "${PAYLOAD_SOURCE_TAG_WORKTREE}"`,
502
+ { payloadSource: postPublish.payloadSource },
503
+ ));
504
+ }
505
+ if (typeof postPublish.tagCommit !== 'string' || !SHA_RE.test(postPublish.tagCommit)) {
506
+ await failBlocked(new ReleaseError(
507
+ GATE_FAILED,
508
+ 'postPublish is missing a frozen tagCommit binding; distribute fails closed',
509
+ { tagCommit: postPublish.tagCommit ?? null },
510
+ ));
511
+ }
512
+ let observedTagCommit;
513
+ try {
514
+ const { stdout } = await exec('git', ['-C', root, 'rev-parse', '--verify', `${postPublish.tag}^{commit}`]);
515
+ observedTagCommit = `${stdout}`.trim();
516
+ } catch (err) {
517
+ await evidence.append({
518
+ phase: 'safety-gate',
519
+ gate: 'tag-identity',
520
+ status: 'failed',
521
+ error: tail(err?.stderr ?? err?.message),
522
+ });
523
+ await failBlocked(new ReleaseError(
524
+ GATE_FAILED,
525
+ `tag "${postPublish.tag}" does not resolve to a commit in the source repository; distribute fails closed`,
526
+ { tag: postPublish.tag },
527
+ ));
528
+ }
529
+ if (observedTagCommit !== postPublish.tagCommit) {
530
+ await evidence.append({
531
+ phase: 'safety-gate',
532
+ gate: 'tag-identity',
533
+ status: 'failed',
534
+ frozenTagCommit: postPublish.tagCommit,
535
+ observedTagCommit,
536
+ });
537
+ await failBlocked(new ReleaseError(
538
+ GATE_FAILED,
539
+ `tag "${postPublish.tag}" points at ${observedTagCommit}, but the frozen plan binds tagCommit ${postPublish.tagCommit}`,
540
+ { tag: postPublish.tag, frozenTagCommit: postPublish.tagCommit, observedTagCommit },
541
+ ));
542
+ }
543
+
544
+ await evidence.append({ phase: 'safety-gate', gate: 'tag-identity', status: 'passed', tagCommit: observedTagCommit });
545
+
546
+ // =======================================================================
547
+ // Gate 5 (optional): the main line must have moved ahead of the tag.
548
+ // Interpreted as commit descent: tagCommit is an ancestor of HEAD and
549
+ // HEAD is not the tag commit itself.
550
+ // =======================================================================
551
+ if (postPublish.assertMainVersionAhead === true) {
552
+ await evidence.append({ phase: 'safety-gate', gate: 'main-version-ahead', status: 'started' });
553
+ let branchName;
554
+ try {
555
+ const { stdout } = await exec('git', ['-C', root, 'symbolic-ref', '--short', 'HEAD']);
556
+ branchName = `${stdout}`.trim();
557
+ } catch {
558
+ await failBlocked(new ReleaseError(
559
+ GATE_FAILED,
560
+ 'assertMainVersionAhead requires a checked-out branch, but HEAD is detached',
561
+ ));
562
+ }
563
+ try {
564
+ await exec('git', ['-C', root, 'merge-base', '--is-ancestor', postPublish.tagCommit, 'HEAD']);
565
+ } catch {
566
+ await failBlocked(new ReleaseError(
567
+ GATE_FAILED,
568
+ `assertMainVersionAhead failed: tagCommit ${postPublish.tagCommit} is not an ancestor of HEAD on branch "${branchName}"`,
569
+ { branch: branchName, tagCommit: postPublish.tagCommit },
570
+ ));
571
+ }
572
+ const { stdout: headOut } = await exec('git', ['-C', root, 'rev-parse', 'HEAD']);
573
+ if (`${headOut}`.trim() === postPublish.tagCommit) {
574
+ await failBlocked(new ReleaseError(
575
+ GATE_FAILED,
576
+ `assertMainVersionAhead failed: HEAD on branch "${branchName}" is still the tag commit; the main line has not moved ahead`,
577
+ { branch: branchName, tagCommit: postPublish.tagCommit },
578
+ ));
579
+ }
580
+ await evidence.append({ phase: 'safety-gate', gate: 'main-version-ahead', status: 'passed', branch: branchName });
581
+ }
582
+
583
+ // =======================================================================
584
+ // Gate 6: adapter availability + per-target reachability preflight.
585
+ // =======================================================================
586
+ let probeAdapter;
587
+ let mirrorAdapter;
588
+ try {
589
+ probeAdapter = adapterRegistry.getAdapter(ActionType.DISTRIBUTE_PROBE);
590
+ mirrorAdapter = adapterRegistry.getAdapter(ActionType.DISTRIBUTE_MIRROR);
591
+ } catch (err) {
592
+ await failBlocked(new ReleaseError(
593
+ GATE_FAILED,
594
+ `no distribute adapter registered: ${err.message}`,
595
+ ));
596
+ }
597
+
598
+ const probeObservations = new Map();
599
+ for (const target of orderedTargets) {
600
+ const probeCheckpoint = checkpointById.get(`probe-${target.id}`);
601
+ probeCheckpoint.startedAt = clockFn();
602
+ await evidence.append({
603
+ phase: 'safety-gate',
604
+ gate: `probe-${target.id}`,
605
+ status: 'started',
606
+ remoteUrl: target.remoteUrl,
607
+ });
608
+ const probeAction = {
609
+ actionType: ActionType.DISTRIBUTE_PROBE,
610
+ targetId: target.id,
611
+ remoteUrl: target.remoteUrl,
612
+ tag: postPublish.tag,
613
+ };
614
+ let probeResult;
615
+ try {
616
+ probeResult = await probeAdapter.preflight(probeAction, {
617
+ externalWritesAuthorized: false,
618
+ plan,
619
+ root,
620
+ runDir,
621
+ });
622
+ } catch (err) {
623
+ probeResult = { status: 'PREFLIGHT_FAILED', error: err?.message ?? String(err), details: null };
624
+ }
625
+ probeCheckpoint.finishedAt = clockFn();
626
+ if (probeResult.status !== 'PREFLIGHT_PASSED') {
627
+ const code = [REMOTE_UNAVAILABLE, REMOTE_CONFLICT].includes(probeResult.details?.code)
628
+ ? probeResult.details.code
629
+ : GATE_FAILED;
630
+ probeCheckpoint.status = 'FAILED';
631
+ probeCheckpoint.error = { code, message: probeResult.error ?? 'distribute probe failed' };
632
+ await evidence.append({
633
+ phase: 'safety-gate',
634
+ gate: `probe-${target.id}`,
635
+ status: 'failed',
636
+ error: probeResult.error,
637
+ details: { code },
638
+ });
639
+ await recordBlocked();
640
+ throw new ReleaseError(
641
+ code,
642
+ `distribute probe failed for target "${target.id}": ${probeResult.error ?? 'remote unreachable'}`,
643
+ { targetId: target.id, remoteUrl: target.remoteUrl },
644
+ );
645
+ }
646
+ probeCheckpoint.status = 'SUCCEEDED';
647
+ probeObservations.set(target.id, probeResult.observation ?? {});
648
+ await evidence.append({
649
+ phase: 'safety-gate',
650
+ gate: `probe-${target.id}`,
651
+ status: 'passed',
652
+ tagExists: probeResult.observation?.tagExists ?? false,
653
+ });
654
+ }
655
+
656
+ // =======================================================================
657
+ // R1 timing contract: detached worktree at the frozen tagCommit. The
658
+ // payload may never come from the live workspace.
659
+ // =======================================================================
660
+ await evidence.append({ phase: 'worktree', status: 'started', tagCommit: postPublish.tagCommit });
661
+ try {
662
+ tmpBase = await mkdtemp(join(tmpdir(), 'release-skill-distribute-'));
663
+ worktreePath = join(tmpBase, 'worktree');
664
+ await exec('git', ['-C', root, 'worktree', 'add', '--detach', worktreePath, postPublish.tagCommit]);
665
+ } catch (err) {
666
+ await evidence.append({ phase: 'worktree', status: 'failed', error: tail(err?.stderr ?? err?.message) });
667
+ await failBlocked(new ReleaseError(
668
+ GATE_FAILED,
669
+ `cannot create the detached tag worktree at ${postPublish.tagCommit}: ${err?.message ?? err}`,
670
+ { tagCommit: postPublish.tagCommit },
671
+ ));
672
+ }
673
+ await evidence.append({ phase: 'worktree', status: 'passed' });
674
+
675
+ // =======================================================================
676
+ // Materialize: run the consumer hook inside the tag worktree, verify its
677
+ // report, and bind the announced payload directory (fail-closed).
678
+ // =======================================================================
679
+ await evidence.append({ phase: 'materialize', status: 'started' });
680
+
681
+ const materialize = postPublish.materialize;
682
+ const hookResult = await hookRunner(
683
+ {
684
+ command: materialize.command,
685
+ ...(materialize.cwd ? { cwd: materialize.cwd } : {}),
686
+ ...(materialize.timeoutMs !== undefined ? { timeoutMs: materialize.timeoutMs } : {}),
687
+ ...(materialize.envAllowlist ? { envAllowlist: materialize.envAllowlist } : {}),
688
+ },
689
+ { root: worktreePath, env: process.env },
690
+ );
691
+
692
+ if (hookResult.exitCode !== 0) {
693
+ await evidence.append({
694
+ phase: 'materialize',
695
+ status: 'failed',
696
+ exitCode: hookResult.exitCode,
697
+ stdoutTail: tail(hookResult.stdout),
698
+ stderrTail: tail(hookResult.stderr),
699
+ });
700
+ await failBlocked(new ReleaseError(
701
+ POST_PUBLISH_VERIFY_FAILED,
702
+ `materialize hook exited with code ${hookResult.exitCode}; payload cannot be trusted`,
703
+ { exitCode: hookResult.exitCode, stdoutTail: tail(hookResult.stdout), stderrTail: tail(hookResult.stderr) },
704
+ ));
705
+ }
706
+
707
+ // requireReport: stdout-first-json deep compare (declared equals subset).
708
+ if (materialize.requireReport) {
709
+ const report = parseFirstJsonObject(hookResult.stdout);
710
+ if (!report) {
711
+ await evidence.append({
712
+ phase: 'materialize',
713
+ status: 'failed',
714
+ reason: 'report-missing',
715
+ stdoutTail: tail(hookResult.stdout),
716
+ });
717
+ await failBlocked(new ReleaseError(
718
+ POST_PUBLISH_VERIFY_FAILED,
719
+ 'materialize report missing: no JSON object found on stdout (requireReport.parse = stdout-first-json)',
720
+ { stdoutTail: tail(hookResult.stdout) },
721
+ ));
722
+ }
723
+ const equals = materialize.requireReport.equals ?? {};
724
+ for (const [key, expectedValue] of Object.entries(equals)) {
725
+ if (JSON.stringify(report[key]) !== JSON.stringify(expectedValue)) {
726
+ await evidence.append({
727
+ phase: 'materialize',
728
+ status: 'failed',
729
+ reason: 'report-mismatch',
730
+ mismatchedKeys: Object.keys(equals).filter(
731
+ (k) => JSON.stringify(report[k]) !== JSON.stringify(equals[k]),
732
+ ),
733
+ });
734
+ await failBlocked(new ReleaseError(
735
+ POST_PUBLISH_VERIFY_FAILED,
736
+ `materialize report mismatch: "${key}" did not meet the frozen requireReport contract`,
737
+ { mismatchedKey: key },
738
+ ));
739
+ }
740
+ }
741
+ }
742
+
743
+ // outputMarker: the LAST line containing the marker announces the payload.
744
+ const announced = parseOutputMarker(hookResult.stdout, materialize.outputMarker);
745
+ if (!announced) {
746
+ await evidence.append({
747
+ phase: 'materialize',
748
+ status: 'failed',
749
+ reason: 'marker-missing',
750
+ stdoutTail: tail(hookResult.stdout),
751
+ });
752
+ await failBlocked(new ReleaseError(
753
+ POST_PUBLISH_VERIFY_FAILED,
754
+ `materialize output marker "${materialize.outputMarker}" not found on stdout; payload directory unbound`,
755
+ { stdoutTail: tail(hookResult.stdout) },
756
+ ));
757
+ }
758
+ const payloadReal = await assertContainedDirectory(
759
+ worktreePath,
760
+ resolve(worktreePath, announced),
761
+ 'materialized payload directory',
762
+ );
763
+ await evidence.append({ phase: 'materialize', status: 'passed', payloadDirAnnounced: announced });
764
+
765
+ // =======================================================================
766
+ // Declared postPublish steps, in order (fail-closed).
767
+ // =======================================================================
768
+ for (const step of postPublish.steps ?? []) {
769
+ await evidence.append({ phase: 'postpublish-step', step: step.name, status: 'started' });
770
+ const stepResult = await hookRunner(
771
+ {
772
+ command: step.command,
773
+ ...(step.cwd ? { cwd: step.cwd } : {}),
774
+ ...(step.timeoutMs !== undefined ? { timeoutMs: step.timeoutMs } : {}),
775
+ ...(step.envAllowlist ? { envAllowlist: step.envAllowlist } : {}),
776
+ },
777
+ { root: worktreePath, env: process.env },
778
+ );
779
+ if (stepResult.exitCode !== 0) {
780
+ await evidence.append({
781
+ phase: 'postpublish-step',
782
+ step: step.name,
783
+ status: 'failed',
784
+ exitCode: stepResult.exitCode,
785
+ stdoutTail: tail(stepResult.stdout),
786
+ stderrTail: tail(stepResult.stderr),
787
+ });
788
+ await failBlocked(new ReleaseError(
789
+ GATE_FAILED,
790
+ `postPublish step "${step.name}" exited with code ${stepResult.exitCode}`,
791
+ { step: step.name, exitCode: stepResult.exitCode },
792
+ ));
793
+ }
794
+ await evidence.append({ phase: 'postpublish-step', step: step.name, status: 'passed' });
795
+ }
796
+
797
+ // =======================================================================
798
+ // Target distribution: sequential, dependency-ordered, observe-before-
799
+ // write (reconcile equivalence — never force).
800
+ // =======================================================================
801
+ await evidence.append({
802
+ phase: 'distribute',
803
+ status: 'started',
804
+ targetCount: orderedTargets.length,
805
+ dryRun: dryRun === true,
806
+ });
807
+
808
+ const mirrorResults = new Map(); // targetId -> { sha } for dependents
809
+ let stopped = false;
810
+ let failures = 0;
811
+ let pushedWrites = 0;
812
+
813
+ const adapterContext = {
814
+ externalWritesAuthorized: true, // dry-run side effects are adapter-guaranteed zero
815
+ plan,
816
+ root,
817
+ runDir,
818
+ };
819
+
820
+ for (const target of orderedTargets) {
821
+ const cp = checkpointById.get(target.id);
822
+ cp.startedAt = clockFn();
823
+
824
+ if (stopped) {
825
+ cp.status = 'SKIPPED';
826
+ cp.reason = 'EARLIER_TARGET_FAILED';
827
+ cp.finishedAt = clockFn();
828
+ await evidence.append({ phase: 'checkpoint', actionId: target.id, status: 'skipped', reason: 'EARLIER_TARGET_FAILED' });
829
+ continue;
830
+ }
831
+
832
+ // --- Pre-observe: a remote already at the frozen tag is SKIPPED. ---
833
+ await evidence.append({ phase: 'checkpoint', actionId: target.id, status: 'pre-observe' });
834
+ let preObserved = {};
835
+ try {
836
+ const observeResult = await mirrorAdapter.observe(
837
+ {
838
+ actionType: ActionType.DISTRIBUTE_MIRROR,
839
+ targetId: target.id,
840
+ remoteUrl: target.remoteUrl,
841
+ branch: target.branch,
842
+ tag: postPublish.tag,
843
+ },
844
+ adapterContext,
845
+ );
846
+ preObserved = observeResult?.observation ?? {};
847
+ } catch (err) {
848
+ preObserved = {};
849
+ await evidence.append({
850
+ phase: 'checkpoint',
851
+ actionId: target.id,
852
+ status: 'pre-observe-unobservable',
853
+ error: err?.message ?? String(err),
854
+ });
855
+ }
856
+ if (preObserved.tagOid && preObserved.branchTip === preObserved.tagOid) {
857
+ cp.status = 'SKIPPED';
858
+ cp.preObserve = 'CONSISTENT';
859
+ cp.finishedAt = clockFn();
860
+ mirrorResults.set(target.id, { sha: preObserved.tagOid });
861
+ await evidence.append({
862
+ phase: 'checkpoint',
863
+ actionId: target.id,
864
+ status: 'skipped',
865
+ details: { preObserve: 'CONSISTENT', sha: preObserved.tagOid },
866
+ });
867
+ continue;
868
+ }
869
+
870
+ // --- UNCERTAIN-before-write snapshot (durable pre-execute authority). ---
871
+ cp.status = 'UNCERTAIN';
872
+ await snapshot(DISTRIBUTING);
873
+
874
+ // --- Build the mirror action. ---
875
+ const action = {
876
+ actionType: ActionType.DISTRIBUTE_MIRROR,
877
+ targetId: target.id,
878
+ kind: target.kind,
879
+ remoteUrl: target.remoteUrl,
880
+ branch: target.branch,
881
+ tag: postPublish.tag,
882
+ commitIdentity: postPublish.commitIdentity,
883
+ dryRun: dryRun === true,
884
+ };
885
+ if (target.kind === 'payload-mirror') {
886
+ action.payloadDir = payloadReal;
887
+ } else {
888
+ action.marketplace = target.marketplace;
889
+ action.pluginName = resolvePluginName(plan, postPublish.unitId);
890
+ const dependencyTarget = orderedTargets.find((entry) => entry.id === target.dependsOn);
891
+ action.dependency = {
892
+ remoteUrl: dependencyTarget.remoteUrl,
893
+ sha: mirrorResults.get(target.dependsOn)?.sha ?? null,
894
+ };
895
+ }
896
+ action.staticFiles = (target.staticFiles ?? []).map((file) => {
897
+ const sourcePath = resolve(payloadReal, file.from);
898
+ const rel = relative(payloadReal, sourcePath);
899
+ if (rel === '' || isAbsolute(rel) || rel === '..'
900
+ || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
901
+ throw new ReleaseError(
902
+ GATE_FAILED,
903
+ `target "${target.id}" staticFiles entry escapes the payload directory`,
904
+ { targetId: target.id, from: file.from },
905
+ );
906
+ }
907
+ return { sourcePath, to: file.to };
908
+ });
909
+
910
+ // --- Execute (the adapter never throws; it returns EXECUTE_FAILED). ---
911
+ await evidence.append({ phase: 'checkpoint', actionId: target.id, status: 'started' });
912
+ let executeResult;
913
+ try {
914
+ executeResult = await mirrorAdapter.execute(action, adapterContext);
915
+ } catch (err) {
916
+ executeResult = {
917
+ status: 'EXECUTE_FAILED',
918
+ error: err?.message ?? String(err),
919
+ details: { code: err?.code ?? GATE_FAILED },
920
+ };
921
+ }
922
+
923
+ const observation = executeResult.observation ?? {};
924
+
925
+ if (executeResult.status === 'EXECUTE_FAILED') {
926
+ const code = mapToSchemaCode(executeResult.details?.code);
927
+ cp.status = 'FAILED';
928
+ cp.error = { code, message: executeResult.error ?? 'mirror execute failed' };
929
+ cp.finishedAt = clockFn();
930
+ failures += 1;
931
+ stopped = true;
932
+ await evidence.append({
933
+ phase: 'checkpoint',
934
+ actionId: target.id,
935
+ status: 'failed',
936
+ error: executeResult.error,
937
+ details: { code },
938
+ });
939
+ await snapshot(PARTIAL);
940
+ continue;
941
+ }
942
+
943
+ if (executeResult.status === 'NO_CHANGE') {
944
+ cp.status = 'NO_CHANGE';
945
+ cp.mode = 'no-change';
946
+ cp.previousHead = observation.previousHead ?? null;
947
+ cp.payloadFileCount = observation.payloadFileCount;
948
+ cp.finishedAt = clockFn();
949
+ const sha = probeObservations.get(target.id)?.tagOid
950
+ ?? observation.branchTip
951
+ ?? observation.previousHead
952
+ ?? null;
953
+ mirrorResults.set(target.id, { sha });
954
+ await evidence.append({ phase: 'checkpoint', actionId: target.id, status: 'no-change' });
955
+ await snapshot(PARTIAL);
956
+ continue;
957
+ }
958
+
959
+ // EXECUTED: pushed or dry-run.
960
+ cp.mode = observation.mode;
961
+ cp.previousHead = observation.previousHead ?? undefined;
962
+ cp.payloadFileCount = observation.payloadFileCount;
963
+ const pushed = observation.mode === 'pushed';
964
+ if (pushed) {
965
+ pushedWrites += 1;
966
+ cp.pushedCommit = observation.pushedCommit;
967
+ mirrorResults.set(target.id, { sha: observation.pushedCommit });
968
+ } else {
969
+ // Dry-run: no push result exists; dependents render without a sha.
970
+ mirrorResults.set(target.id, { sha: null });
971
+ }
972
+
973
+ // --- Post-execute verify (observe cross-check; pushed only). ---
974
+ let verifyResult;
975
+ try {
976
+ verifyResult = await mirrorAdapter.verify(
977
+ {
978
+ actionType: ActionType.DISTRIBUTE_MIRROR,
979
+ targetId: target.id,
980
+ remoteUrl: target.remoteUrl,
981
+ branch: target.branch,
982
+ tag: postPublish.tag,
983
+ ...(pushed ? { pushedCommit: observation.pushedCommit } : {}),
984
+ },
985
+ adapterContext,
986
+ );
987
+ } catch (err) {
988
+ verifyResult = { status: 'VERIFY_FAILED', error: err?.message ?? String(err) };
989
+ }
990
+
991
+ if (verifyResult.status !== 'VERIFIED') {
992
+ cp.status = 'FAILED';
993
+ cp.postObserve = 'CONFLICTING';
994
+ cp.error = {
995
+ code: POST_PUBLISH_VERIFY_FAILED,
996
+ message: verifyResult.error ?? 'post-execute verification failed',
997
+ };
998
+ cp.finishedAt = clockFn();
999
+ failures += 1;
1000
+ stopped = true;
1001
+ await evidence.append({
1002
+ phase: 'checkpoint',
1003
+ actionId: target.id,
1004
+ status: 'verify-failed',
1005
+ error: verifyResult.error ?? null,
1006
+ });
1007
+ await snapshot(PARTIAL);
1008
+ continue;
1009
+ }
1010
+
1011
+ cp.status = 'SUCCEEDED';
1012
+ cp.postObserve = pushed ? 'CONSISTENT' : cp.postObserve;
1013
+ cp.finishedAt = clockFn();
1014
+ await evidence.append({
1015
+ phase: 'checkpoint',
1016
+ actionId: target.id,
1017
+ status: 'completed',
1018
+ mode: observation.mode,
1019
+ pushedCommit: pushed ? observation.pushedCommit : null,
1020
+ });
1021
+ await snapshot(PARTIAL);
1022
+ }
1023
+
1024
+ // =======================================================================
1025
+ // Classification: DISTRIBUTED | PARTIAL | BLOCKED (returned, not thrown).
1026
+ // =======================================================================
1027
+ let overallStatus;
1028
+ if (failures === 0) {
1029
+ overallStatus = DISTRIBUTED;
1030
+ } else if (pushedWrites > 0) {
1031
+ overallStatus = PARTIAL;
1032
+ } else {
1033
+ overallStatus = BLOCKED;
1034
+ }
1035
+
1036
+ const finishedAt = clockFn();
1037
+ await snapshot(overallStatus);
1038
+ await writeRunAtomic(runPath, buildPersistedState(overallStatus, finishedAt));
1039
+ finalRecordWritten = true;
1040
+
1041
+ await evidence.append({
1042
+ phase: 'distribute',
1043
+ status: 'completed',
1044
+ overallStatus,
1045
+ checkpointStatuses: checkpoints.map((cp) => cp.status),
1046
+ });
1047
+ await evidence.finish({
1048
+ status: overallStatus,
1049
+ planPath,
1050
+ runPath,
1051
+ finishedAt: clockFn(),
1052
+ });
1053
+
1054
+ return { planPath, runPath, status: overallStatus, checkpoints };
1055
+ } catch (err) {
1056
+ // The happy path returns before this catch, so `evidence.finish` can never
1057
+ // double-run here: it must ALWAYS close the evidence stream and seal the
1058
+ // summary, even when failBlocked already persisted the BLOCKED record.
1059
+ try {
1060
+ await evidence.append({
1061
+ phase: 'distribute',
1062
+ status: 'failed',
1063
+ error: { code: err.code, message: err.message },
1064
+ });
1065
+ if (lineageKnown && !finalRecordWritten) await recordBlocked();
1066
+ } catch {
1067
+ // Persistence must never mask the primary failure.
1068
+ }
1069
+ await evidence.finish({
1070
+ status: lineageKnown ? BLOCKED : 'FAILED',
1071
+ error: { code: err.code, message: err.message },
1072
+ failedAt: clockFn(),
1073
+ }).catch(() => {});
1074
+ throw err;
1075
+ } finally {
1076
+ await cleanupWorktree();
1077
+ }
1078
+ }