release-skill 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.kimi-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +53 -0
  6. package/INSTALL.md +4 -4
  7. package/INSTALL.zh-CN.md +4 -4
  8. package/README.md +18 -33
  9. package/README.zh-CN.md +17 -23
  10. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  11. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  12. package/adapters/claude/bin/release-skill.bundle.mjs +2740 -1844
  13. package/adapters/claude/schemas/.render-manifest.json +8 -8
  14. package/adapters/claude/schemas/approval-record.schema.json +1 -1
  15. package/adapters/claude/schemas/release-plan.schema.json +6 -2
  16. package/adapters/claude/schemas/release-project.schema.json +14 -0
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/bin/release-skill.bundle.mjs +2740 -1844
  19. package/adapters/codex/schemas/.render-manifest.json +8 -8
  20. package/adapters/codex/schemas/approval-record.schema.json +1 -1
  21. package/adapters/codex/schemas/release-plan.schema.json +6 -2
  22. package/adapters/codex/schemas/release-project.schema.json +14 -0
  23. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  24. package/adapters/kimi/bin/release-skill.bundle.mjs +2740 -1844
  25. package/adapters/kimi/schemas/.render-manifest.json +8 -8
  26. package/adapters/kimi/schemas/approval-record.schema.json +1 -1
  27. package/adapters/kimi/schemas/release-plan.schema.json +6 -2
  28. package/adapters/kimi/schemas/release-project.schema.json +14 -0
  29. package/bin/release-skill-cli.mjs +3 -0
  30. package/bin/release-skill.bundle.mjs +2740 -1844
  31. package/package.json +8 -2
  32. package/references/.render-manifest.json +8 -8
  33. package/references/01-state-machine.md +5 -5
  34. package/references/02-project-config.md +1 -1
  35. package/references/05-evidence-and-errors.md +1 -1
  36. package/references/06-adapter-contract.md +41 -1
  37. package/schemas/.render-manifest.json +8 -8
  38. package/schemas/approval-record.schema.json +1 -1
  39. package/schemas/release-plan.schema.json +6 -2
  40. package/schemas/release-project.schema.json +14 -0
  41. package/scripts/sync-public-files.mjs +462 -0
  42. package/src/adapters/contract.mjs +60 -0
  43. package/src/adapters/plugin-marketplace.mjs +289 -730
  44. package/src/commands/prepare.mjs +195 -182
  45. package/src/commands/publish.mjs +438 -122
  46. package/src/commands/reconcile.mjs +369 -191
  47. package/src/commands/verify.mjs +13 -2
  48. package/src/core/approval.mjs +72 -45
  49. package/src/core/baseline.mjs +16 -0
  50. package/src/core/checkpoints.mjs +143 -0
  51. package/src/core/evidence.mjs +30 -3
  52. package/src/core/hook-cache.mjs +254 -0
  53. package/src/core/hooks.mjs +37 -1
  54. package/src/core/observe-retry.mjs +223 -0
  55. package/src/core/plan.mjs +162 -253
  56. package/src/platforms/kimi.mjs +514 -0
  57. package/src/platforms/registry.mjs +393 -0
  58. package/src/producers/build-adapters.mjs +14 -22
  59. package/src/snapshot/frozen.mjs +29 -5
@@ -41,6 +41,12 @@ import {
41
41
  reObservePreviousPublicBaseline,
42
42
  } from '../core/previous-public-baseline.mjs';
43
43
  import { createEvidenceWriter } from '../core/evidence.mjs';
44
+ import {
45
+ ADAPTER_ACTION_TYPE_MAP,
46
+ TIER_TABLE,
47
+ groupActionsByTier,
48
+ sortActionsByCheckpointOrder,
49
+ } from '../core/checkpoints.mjs';
44
50
  import { appendRunState, createProductionRunDir, writeRunAtomic, resolveDefaultRunDir } from '../core/run.mjs';
45
51
  import {
46
52
  ReleaseError,
@@ -50,6 +56,7 @@ import {
50
56
  } from '../core/errors.mjs';
51
57
  import { assertTransition, PUBLISHING, PUBLISHED, PARTIAL } from '../core/state-machine.mjs';
52
58
  import { matchObservation } from '../adapters/contract.mjs';
59
+ import { observeWithRetry, clampPolicyToTimeout, DEFAULT_OBSERVE_RETRY_POLICY, isPropagatingMissing } from '../core/observe-retry.mjs';
53
60
  import {
54
61
  resolveFrozenPath,
55
62
  verifyFrozenFile,
@@ -71,37 +78,8 @@ function assertInsideAssetRoot(assetRoot, candidate, label) {
71
78
 
72
79
  const ACTION_NOT_ALLOWED = 'ACTION_NOT_ALLOWED';
73
80
 
74
- /** Checkpoint order for the publish saga. */
75
- const CHECKPOINT_ORDER = [
76
- 'push-commit',
77
- 'push-snapshot',
78
- 'set-default-branch',
79
- 'create-tag',
80
- 'npm-publish',
81
- 'github-release',
82
- 'claude-marketplace-install',
83
- 'codex-marketplace-install',
84
- 'kimi-marketplace-install',
85
- ];
86
-
87
- /**
88
- * Map plan action type to adapter ActionType.
89
- *
90
- * Plan uses `push-commit`, `push-snapshot`, `create-tag`, `npm-publish`,
91
- * `github-release`. The adapter contract uses `git-push`, `git-tag`,
92
- * `npm-publish`, `github-release`.
93
- */
94
- const ADAPTER_ACTION_TYPE_MAP = {
95
- 'push-commit': 'git-push',
96
- 'push-snapshot': 'push-snapshot',
97
- 'set-default-branch': 'set-default-branch',
98
- 'create-tag': 'git-tag',
99
- 'npm-publish': 'npm-publish',
100
- 'github-release': 'github-release',
101
- 'claude-marketplace-install': 'claude-marketplace-install',
102
- 'codex-marketplace-install': 'codex-marketplace-install',
103
- 'kimi-marketplace-install': 'kimi-marketplace-install',
104
- };
81
+ // CHECKPOINT_ORDER, ADAPTER_ACTION_TYPE_MAP and TIER_TABLE live in
82
+ // ../core/checkpoints.mjs (single source shared with reconcile.mjs; T3.1 §4.7).
105
83
 
106
84
  const MARKETPLACE_TYPES = new Set([
107
85
  'claude-marketplace-install',
@@ -124,6 +102,112 @@ function deepClone(obj) {
124
102
  return JSON.parse(JSON.stringify(obj));
125
103
  }
126
104
 
105
+ // ---------------------------------------------------------------------------
106
+ // Pre-observe classification (T3.3 observe-before-execute)
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Four-valued result of classifying a single read-only pre-observe.
111
+ *
112
+ * - CONSISTENT: the remote already satisfies the frozen plan -> SKIPPED.
113
+ * - MISSING: an explicit absence marker -> proceed to execute.
114
+ * - CONFLICTING: present but disagrees with the frozen plan -> fail closed.
115
+ * - UNOBSERVABLE: no usable observation -> proceed to execute (the mandatory
116
+ * post-execute observe remains the safety net).
117
+ *
118
+ * @enum {string}
119
+ */
120
+ export const PRE_OBSERVE = Object.freeze({
121
+ CONSISTENT: 'CONSISTENT',
122
+ MISSING: 'MISSING',
123
+ CONFLICTING: 'CONFLICTING',
124
+ UNOBSERVABLE: 'UNOBSERVABLE',
125
+ });
126
+
127
+ /** True when `expected` is a non-empty object (vacuous matches are rejected). */
128
+ function hasExpected(expected) {
129
+ return !!expected && typeof expected === 'object' && Object.keys(expected).length > 0;
130
+ }
131
+
132
+ /**
133
+ * Classify a single read-only pre-observe result for one action.
134
+ *
135
+ * Shared single source for BOTH preflight layers (the per-checkpoint pre-observe
136
+ * inside executeCheckpoint and the Safety-Gate-10 global preflight arbitration);
137
+ * the two layers must never drift apart (T3.3 §4.6).
138
+ *
139
+ * Classification order is the semantics:
140
+ * 1. UNOBSERVABLE -- no observation, or an empty observation (a thrown observe
141
+ * surfaces as an empty observation with `error`). Fail-safe: the caller
142
+ * proceeds to execute.
143
+ * 2. Double-safe-state actions (push-snapshot, set-default-branch) mirror
144
+ * reconcile's special templates: a "safe pre-state" (frozen predecessor /
145
+ * still-old default branch) is MISSING (execute), NOT CONFLICTING, so a fresh
146
+ * publish is not blown up by generic three-way classification.
147
+ * 3. Generic actions -- CONSISTENT when the observation matches a non-empty
148
+ * expected; MISSING on an explicit absence marker (single-sourced from
149
+ * isPropagatingMissing); otherwise CONFLICTING.
150
+ *
151
+ * Actions with no (or an empty) expected can never be CONSISTENT here; callers
152
+ * skip pre-observe for them entirely (matchObservation({}, obs) would vacuously
153
+ * match and wrongly SKIPPED).
154
+ *
155
+ * @param {{ type?: string, expected?: Object, parameters?: Object }} action
156
+ * @param {{ observation?: Object|null, error?: string|null }|null} observeResult
157
+ * @returns {string} One of PRE_OBSERVE values.
158
+ */
159
+ export function classifyPreObservation(action, observeResult) {
160
+ const observation = observeResult?.observation;
161
+ // 1. UNOBSERVABLE: missing or empty observation (covers thrown observes, which
162
+ // adapters normalize to an empty observation + error).
163
+ if (!observation || Object.keys(observation).length === 0) {
164
+ return PRE_OBSERVE.UNOBSERVABLE;
165
+ }
166
+
167
+ const expected = action?.expected;
168
+ const parameters = action?.parameters ?? {};
169
+
170
+ // 2a. push-snapshot (mirrors reconcile.mjs advance-push template).
171
+ if (action?.type === 'push-snapshot') {
172
+ if (hasExpected(expected) && matchObservation(expected, observation).matches) {
173
+ return PRE_OBSERVE.CONSISTENT; // already at the planned successor
174
+ }
175
+ if (parameters.expectedBaselineCommit && observation.commit === parameters.expectedBaselineCommit) {
176
+ return PRE_OBSERVE.MISSING; // still at the frozen predecessor: safe to push
177
+ }
178
+ if (observation.exists === false) {
179
+ return PRE_OBSERVE.MISSING; // remote branch absent: safe to create
180
+ }
181
+ return PRE_OBSERVE.CONFLICTING; // a third-party tip
182
+ }
183
+
184
+ // 2b. set-default-branch (mirrors reconcile.mjs default-branch template).
185
+ if (action?.type === 'set-default-branch') {
186
+ // Safety floor first: the target branch tip must not have advanced past the
187
+ // frozen commit -- never switch the default branch onto a moved tip.
188
+ if (parameters.expectedNewBranchCommit && observation.newBranchCommit !== parameters.expectedNewBranchCommit) {
189
+ return PRE_OBSERVE.CONFLICTING;
190
+ }
191
+ if (observation.defaultBranch === parameters.newBranch
192
+ && hasExpected(expected) && matchObservation(expected, observation).matches) {
193
+ return PRE_OBSERVE.CONSISTENT; // already switched
194
+ }
195
+ if (observation.defaultBranch === parameters.oldBranch) {
196
+ return PRE_OBSERVE.MISSING; // not switched yet: safe to execute
197
+ }
198
+ return PRE_OBSERVE.CONFLICTING; // a third branch is the default
199
+ }
200
+
201
+ // 3. Generic actions (create-tag, npm-publish, github-release, git-push, ...).
202
+ if (hasExpected(expected) && matchObservation(expected, observation).matches) {
203
+ return PRE_OBSERVE.CONSISTENT;
204
+ }
205
+ if (isPropagatingMissing(observeResult)) {
206
+ return PRE_OBSERVE.MISSING;
207
+ }
208
+ return PRE_OBSERVE.CONFLICTING;
209
+ }
210
+
127
211
  // ---------------------------------------------------------------------------
128
212
  // Checkpoint execution
129
213
  // ---------------------------------------------------------------------------
@@ -163,6 +247,61 @@ async function executeCheckpoint(action, adapterRegistry, context) {
163
247
  };
164
248
  }
165
249
 
250
+ // T3.3 pre-observe (observe-before-execute): read the remote ONCE, before
251
+ // preflight. A remote already consistent with the frozen plan is SKIPPED
252
+ // (idempotent, zero side effects); an explicit conflict fails closed for human
253
+ // review. Order is the contract: pre-observe -> preflight -> execute, so an
254
+ // "already consistent" remote is never misread by preflight as "occupied".
255
+ // Single-shot on purpose (NOT observeWithRetry): a missing/unobservable
256
+ // pre-observe simply proceeds to execute, whose mandatory post-observe remains
257
+ // the safety net (T3.3 §4.4 asymmetry). Actions without a usable expected are
258
+ // skipped here and keep the legacy preflight -> execute behavior.
259
+ let preObserve;
260
+ if (hasExpected(action.expected)) {
261
+ const preObserveInput = {
262
+ actionType: adapterActionType,
263
+ ...action.parameters,
264
+ expected: action.expected,
265
+ };
266
+ let preObserveResult;
267
+ try {
268
+ preObserveResult = await adapter.observe(preObserveInput, context);
269
+ } catch (error) {
270
+ preObserveResult = { observation: null, error: error?.message ?? String(error) };
271
+ }
272
+ const classification = classifyPreObservation(action, preObserveResult);
273
+ await context.evidence?.append?.({
274
+ phase: 'checkpoint-pre-observe',
275
+ actionId,
276
+ actionType: planActionType,
277
+ status: 'pre-observe',
278
+ details: { preObserve: classification },
279
+ });
280
+ if (classification === PRE_OBSERVE.CONSISTENT) {
281
+ return {
282
+ actionId,
283
+ status: 'SKIPPED',
284
+ error: null,
285
+ observation: preObserveResult?.observation ?? null,
286
+ preObserve: PRE_OBSERVE.CONSISTENT,
287
+ };
288
+ }
289
+ if (classification === PRE_OBSERVE.CONFLICTING) {
290
+ const mismatches = matchObservation(action.expected, preObserveResult?.observation ?? {}).mismatches;
291
+ return {
292
+ actionId,
293
+ status: 'FAILED',
294
+ error: `pre-observe conflict: ${mismatches.join('; ') || 'remote state is present but does not match the frozen plan'}`,
295
+ observation: preObserveResult?.observation ?? null,
296
+ preObserve: PRE_OBSERVE.CONFLICTING,
297
+ };
298
+ }
299
+ if (classification === PRE_OBSERVE.MISSING) {
300
+ preObserve = PRE_OBSERVE.MISSING;
301
+ }
302
+ // UNOBSERVABLE: `preObserve` stays undefined (left empty, never MISSING).
303
+ }
304
+
166
305
  // Preflight (read-only, no authorization required)
167
306
  const preflightResult = await adapter.preflight(
168
307
  { actionType: adapterActionType, ...action.parameters },
@@ -191,18 +330,56 @@ async function executeCheckpoint(action, adapterRegistry, context) {
191
330
  // Once execute was attempted, its return value is not authoritative: the
192
331
  // remote may have accepted the write before the connection failed. Always
193
332
  // observe before classifying the checkpoint.
333
+ const observeInput = {
334
+ actionType: adapterActionType,
335
+ ...action.parameters,
336
+ expected: action.expected,
337
+ };
338
+
194
339
  let observeResult;
195
- try {
196
- observeResult = await adapter.observe(
197
- { actionType: adapterActionType, ...action.parameters, expected: action.expected },
340
+ if (executeError) {
341
+ // execute threw: remote state is unknown. Keep the SINGLE observe
342
+ // path and never retry retrying cannot resolve an unknown write
343
+ // outcome, and must not mask a real conflict. (T1.1 fail-closed.)
344
+ try {
345
+ observeResult = await adapter.observe(observeInput, context);
346
+ } catch (error) {
347
+ return {
348
+ actionId,
349
+ status: 'UNCERTAIN',
350
+ error: `execute outcome is uncertain; observe threw: ${error.message}`,
351
+ ...(preObserve ? { preObserve } : {}),
352
+ };
353
+ }
354
+ } else {
355
+ // execute did not throw: a missing/uncertain observe may be a
356
+ // transient propagation delay (e.g. npm registry eventual
357
+ // consistency). Retry the read-only observe with bounded backoff
358
+ // (T1.1 PROPAGATING handling). A present-but-mismatched
359
+ // observation (CONFLICTING) is never retried — it is an
360
+ // authoritative conflict that must fail closed for human review.
361
+ const observePolicy = MARKETPLACE_TYPES.has(planActionType)
362
+ ? clampPolicyToTimeout(DEFAULT_OBSERVE_RETRY_POLICY, action.parameters?.timeoutMs)
363
+ : DEFAULT_OBSERVE_RETRY_POLICY;
364
+ const retryOutcome = await observeWithRetry({
365
+ observe: (act, ctx) => adapter.observe(act, ctx),
366
+ action: observeInput,
198
367
  context,
199
- );
200
- } catch (error) {
201
- return {
202
- actionId,
203
- status: 'UNCERTAIN',
204
- error: `execute outcome is uncertain; observe threw: ${error.message}`,
205
- };
368
+ policy: observePolicy,
369
+ sleep: context.observeRetrySleep,
370
+ onAttempt: (info) => context.evidence?.append?.({
371
+ phase: 'checkpoint-observe-retry',
372
+ actionId,
373
+ actionType: planActionType,
374
+ attempt: info.attempt,
375
+ maxAttempts: info.maxAttempts,
376
+ missing: info.missing,
377
+ delayMs: info.delayMs,
378
+ status: info.missing ? 'propagating' : 'resolved',
379
+ error: info.error ?? null,
380
+ }),
381
+ });
382
+ observeResult = retryOutcome.result;
206
383
  }
207
384
 
208
385
  const observation = observeResult?.observation;
@@ -211,14 +388,15 @@ async function executeCheckpoint(action, adapterRegistry, context) {
211
388
  actionId,
212
389
  status: 'UNCERTAIN',
213
390
  error: `execute outcome is uncertain; observe failed: ${observeResult?.error ?? 'empty observation'}`,
391
+ ...(preObserve ? { preObserve } : {}),
214
392
  };
215
393
  }
216
394
 
217
395
  if (action.expected && matchObservation(action.expected, observation).matches) {
218
- return { actionId, status: 'SUCCEEDED', error: null, observation };
396
+ return { actionId, status: 'SUCCEEDED', error: null, observation, postObserve: PRE_OBSERVE.CONSISTENT, ...(preObserve ? { preObserve } : {}) };
219
397
  }
220
398
  if (!action.expected && !observation.mismatched && executeResult?.status === 'EXECUTED') {
221
- return { actionId, status: 'SUCCEEDED', error: null, observation };
399
+ return { actionId, status: 'SUCCEEDED', error: null, observation, postObserve: PRE_OBSERVE.CONSISTENT, ...(preObserve ? { preObserve } : {}) };
222
400
  }
223
401
 
224
402
  const explicitlyMissing = observation.exists === false
@@ -231,6 +409,8 @@ async function executeCheckpoint(action, adapterRegistry, context) {
231
409
  status: 'FAILED',
232
410
  error: executeError?.message ?? executeResult?.error ?? 'remote state is explicitly missing after execute',
233
411
  observation,
412
+ postObserve: PRE_OBSERVE.MISSING,
413
+ ...(preObserve ? { preObserve } : {}),
234
414
  };
235
415
  }
236
416
 
@@ -241,6 +421,8 @@ async function executeCheckpoint(action, adapterRegistry, context) {
241
421
  ?? executeResult?.error
242
422
  ?? 'observation does not match expected state from frozen plan',
243
423
  observation,
424
+ postObserve: PRE_OBSERVE.CONFLICTING,
425
+ ...(preObserve ? { preObserve } : {}),
244
426
  };
245
427
  }
246
428
 
@@ -277,6 +459,7 @@ export async function publishRelease(options) {
277
459
  productionMode = false,
278
460
  productionConfirmation,
279
461
  observePreviousPublicBaselineFn,
462
+ observeRetrySleep,
280
463
  } = options ?? {};
281
464
 
282
465
  const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
@@ -497,40 +680,71 @@ export async function publishRelease(options) {
497
680
 
498
681
  const currentBaseline = await captureBaselineActual(root);
499
682
 
683
+ // planVersion fork (design: t1-2-digest-decoupling.md §4.3): for v2
684
+ // plans the baseline is record-layer audit data. Drift is recorded as a
685
+ // warning and execution continues -- artifact integrity is sealed by the
686
+ // frozen-artifact re-verification above, never by workspace equality.
687
+ // v1 plans keep the BASELINE_CHANGED hard failure, byte for byte.
688
+ const planV2 = plan.planVersion === 2;
689
+
500
690
  if (currentBaseline.gitTreeHash !== plan.baseline.gitTreeHash) {
501
- await evidence.append({
502
- phase: 'safety-gate',
503
- gate: 'baseline-check',
504
- status: 'failed',
505
- planTreeHash: plan.baseline.gitTreeHash,
506
- currentTreeHash: currentBaseline.gitTreeHash,
507
- });
691
+ if (planV2) {
692
+ await evidence.append({
693
+ phase: 'safety-gate',
694
+ gate: 'baseline-check',
695
+ status: 'warning',
696
+ severity: 'warning',
697
+ reason: 'planVersion 2: baseline drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
698
+ planTreeHash: plan.baseline.gitTreeHash,
699
+ currentTreeHash: currentBaseline.gitTreeHash,
700
+ });
701
+ } else {
702
+ await evidence.append({
703
+ phase: 'safety-gate',
704
+ gate: 'baseline-check',
705
+ status: 'failed',
706
+ planTreeHash: plan.baseline.gitTreeHash,
707
+ currentTreeHash: currentBaseline.gitTreeHash,
708
+ });
508
709
 
509
- // BASELINE_CHANGED: zero adapter execute calls guaranteed
510
- throw new ReleaseError(
511
- BASELINE_CHANGED,
512
- `baseline has changed since plan freeze: plan=${plan.baseline.gitTreeHash}, current=${currentBaseline.gitTreeHash}`,
513
- { planTreeHash: plan.baseline.gitTreeHash, currentTreeHash: currentBaseline.gitTreeHash },
514
- );
710
+ // BASELINE_CHANGED: zero adapter execute calls guaranteed
711
+ throw new ReleaseError(
712
+ BASELINE_CHANGED,
713
+ `baseline has changed since plan freeze: plan=${plan.baseline.gitTreeHash}, current=${currentBaseline.gitTreeHash}`,
714
+ { planTreeHash: plan.baseline.gitTreeHash, currentTreeHash: currentBaseline.gitTreeHash },
715
+ );
716
+ }
515
717
  }
516
718
 
517
719
  if (
518
720
  plan.baseline.workspaceDigest &&
519
721
  currentBaseline.workspaceDigest !== plan.baseline.workspaceDigest
520
722
  ) {
521
- await evidence.append({
522
- phase: 'safety-gate',
523
- gate: 'baseline-check',
524
- status: 'failed',
525
- planWorkspaceDigest: plan.baseline.workspaceDigest,
526
- currentWorkspaceDigest: currentBaseline.workspaceDigest,
527
- });
723
+ if (planV2) {
724
+ await evidence.append({
725
+ phase: 'safety-gate',
726
+ gate: 'baseline-check',
727
+ status: 'warning',
728
+ severity: 'warning',
729
+ reason: 'planVersion 2: workspace digest drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
730
+ planWorkspaceDigest: plan.baseline.workspaceDigest,
731
+ currentWorkspaceDigest: currentBaseline.workspaceDigest,
732
+ });
733
+ } else {
734
+ await evidence.append({
735
+ phase: 'safety-gate',
736
+ gate: 'baseline-check',
737
+ status: 'failed',
738
+ planWorkspaceDigest: plan.baseline.workspaceDigest,
739
+ currentWorkspaceDigest: currentBaseline.workspaceDigest,
740
+ });
528
741
 
529
- throw new ReleaseError(
530
- BASELINE_CHANGED,
531
- `workspace digest has changed since plan freeze: plan=${plan.baseline.workspaceDigest}, current=${currentBaseline.workspaceDigest}`,
532
- { planWorkspaceDigest: plan.baseline.workspaceDigest, currentWorkspaceDigest: currentBaseline.workspaceDigest },
533
- );
742
+ throw new ReleaseError(
743
+ BASELINE_CHANGED,
744
+ `workspace digest has changed since plan freeze: plan=${plan.baseline.workspaceDigest}, current=${currentBaseline.workspaceDigest}`,
745
+ { planWorkspaceDigest: plan.baseline.workspaceDigest, currentWorkspaceDigest: currentBaseline.workspaceDigest },
746
+ );
747
+ }
534
748
  }
535
749
 
536
750
  await evidence.append({
@@ -673,12 +887,8 @@ export async function publishRelease(options) {
673
887
  const publishingPlan = deepClone(plan);
674
888
  publishingPlan.status = PUBLISHING;
675
889
 
676
- // Sort actions by checkpoint order
677
- const orderedActions = (publishingPlan.externalActions ?? []).slice().sort((a, b) => {
678
- const ai = CHECKPOINT_ORDER.indexOf(a.type);
679
- const bi = CHECKPOINT_ORDER.indexOf(b.type);
680
- return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
681
- });
890
+ // Sort actions by checkpoint order (shared single source, T3.1 §4.7).
891
+ const orderedActions = sortActionsByCheckpointOrder(publishingPlan.externalActions);
682
892
 
683
893
  // =======================================================================
684
894
  // Safety Gate 10: Global preflight - validate all actions before any execute
@@ -706,6 +916,39 @@ export async function publishRelease(options) {
706
916
  preflightContext,
707
917
  );
708
918
  if (preflightResult.status === 'PREFLIGHT_FAILED') {
919
+ // T3.3 §4.6: an "occupied" preflight failure overlaps semantically with
920
+ // "already consistent". For NON-marketplace action types, arbitrate with
921
+ // a single read-only pre-observe using the SAME classifier as the
922
+ // per-checkpoint path: CONSISTENT means the remote already satisfies the
923
+ // frozen plan, so let the action through to its SKIPPED path instead of
924
+ // failing the whole gate. Anything else (MISSING/CONFLICTING/UNOBSERVABLE)
925
+ // keeps the fail-closed GATE_FAILED. Marketplace preflights are local
926
+ // integrity checks, never remote occupation, so they are NEVER arbitrated
927
+ // -- a CONSISTENT observation must not bypass their completeness gate.
928
+ if (!isMarketplace && hasExpected(action.expected)) {
929
+ let arbitration;
930
+ try {
931
+ arbitration = await adapter.observe(
932
+ { actionType: adapterActionType, ...action.parameters, expected: action.expected },
933
+ preflightContext,
934
+ );
935
+ } catch (error) {
936
+ arbitration = { observation: null, error: error?.message ?? String(error) };
937
+ }
938
+ const verdict = classifyPreObservation(action, arbitration);
939
+ await evidence.append({
940
+ phase: 'global-preflight-arbitration',
941
+ actionId: action.id,
942
+ actionType: action.type,
943
+ status: 'pre-observe',
944
+ details: { preObserve: verdict, preflightError: preflightResult.error ?? null },
945
+ });
946
+ if (verdict === PRE_OBSERVE.CONSISTENT) {
947
+ // Remote already consistent: the action takes the SKIPPED path inside
948
+ // executeCheckpoint; do not fail the gate.
949
+ continue;
950
+ }
951
+ }
709
952
  throw new ReleaseError(
710
953
  GATE_FAILED,
711
954
  `global preflight failed for action "${action.id}": ${preflightResult.error}`,
@@ -742,7 +985,10 @@ export async function publishRelease(options) {
742
985
  status: checkpoint.status === 'SUCCEEDED' ? 'succeeded'
743
986
  : checkpoint.status === 'FAILED' ? 'failed'
744
987
  : checkpoint.status === 'UNCERTAIN' ? 'uncertain'
988
+ : checkpoint.status === 'SKIPPED' ? 'skipped'
745
989
  : 'pending',
990
+ ...(checkpoint.preObserve ? { preObserve: checkpoint.preObserve } : {}),
991
+ ...(checkpoint.postObserve ? { postObserve: checkpoint.postObserve } : {}),
746
992
  ...(checkpoint.error ? { error: { code: 'GATE_FAILED', message: checkpoint.error } } : {}),
747
993
  })),
748
994
  startedAt,
@@ -759,70 +1005,137 @@ export async function publishRelease(options) {
759
1005
  });
760
1006
 
761
1007
  // =======================================================================
762
- // Execute checkpoints
1008
+ // Execute checkpoints (dependency-tiered; T3.1).
1009
+ //
1010
+ // Tiers run strictly serially; the actions inside a tier are independent
1011
+ // and run concurrently with Promise.allSettled semantics -- no fail-fast
1012
+ // short-circuit, because a sibling's success and observation must never be
1013
+ // dropped (observations are reconcile's recovery authority). State is
1014
+ // snapshotted once per tier boundary instead of once per checkpoint:
1015
+ // * tier start: every action in the tier is set UNCERTAIN and one
1016
+ // appendRunState persists that durable pre-execute authority;
1017
+ // * tier end: all of the tier's results are folded in and one
1018
+ // appendRunState persists them.
1019
+ // Crash-recovery semantics: a kill inside a tier leaves that tier's actions
1020
+ // at UNCERTAIN in the last snapshot; reconcile rebuilds actual state per
1021
+ // actionId from that snapshot plus observe (it never depended on execution
1022
+ // order). appendRunState's seq chain (run.mjs) is unchanged.
763
1023
  // =======================================================================
1024
+ const checkpointByActionId = new Map(checkpoints.map((cp) => [cp.actionId, cp]));
1025
+ const { tiers, unknown } = groupActionsByTier(orderedActions);
764
1026
  let stopped = false;
765
1027
 
766
- for (let actionIndex = 0; actionIndex < orderedActions.length; actionIndex += 1) {
767
- const action = orderedActions[actionIndex];
768
- const checkpoint = checkpoints[actionIndex];
769
- if (stopped) {
770
- action.status = 'PENDING';
771
- continue;
1028
+ // Fail closed on any action type the tier table does not recognize. Such a
1029
+ // type is never silently appended to the last tier: an unrecognized external
1030
+ // write must stop the saga for human review. (Validated plans cannot contain
1031
+ // such a type -- the plan schema enumerates exactly the tier-table types --
1032
+ // so this is defense in depth, not a reachable path.)
1033
+ if (unknown.length > 0) {
1034
+ for (const action of unknown) {
1035
+ const checkpoint = checkpointByActionId.get(action.id);
1036
+ checkpoint.status = 'FAILED';
1037
+ checkpoint.error = `Unknown action type not present in the dependency tier table: ${action.type}`;
1038
+ action.status = 'FAILED';
1039
+ await evidence.append({
1040
+ phase: 'checkpoint',
1041
+ actionId: action.id,
1042
+ actionType: action.type,
1043
+ status: 'failed',
1044
+ error: checkpoint.error,
1045
+ });
772
1046
  }
773
-
774
- // The durable UNCERTAIN state must exist before execute is authorized.
775
- checkpoint.status = 'UNCERTAIN';
1047
+ stopped = true;
776
1048
  stateSequence += 1;
777
- // Once an execute is about to start, this snapshot is itself a
778
- // reconcile-consumable recovery authority. A process kill after the
779
- // adapter accepts the write must never leave only PUBLISHING state.
780
1049
  latestState = await appendRunState(runDir, stateSequence, buildPersistedState(PARTIAL));
1050
+ }
781
1051
 
782
- await evidence.append({
783
- phase: 'checkpoint',
784
- actionId: action.id,
785
- actionType: action.type,
786
- status: 'started',
787
- });
788
-
789
- const isMarketplace = MARKETPLACE_TYPES.has(action.type);
790
- const actionContext = {
791
- externalWritesAuthorized: !isMarketplace,
792
- isolatedConsumerWritesAuthorized: isMarketplace,
793
- plan: publishingPlan,
794
- baseline: plan.baseline,
795
- root,
796
- runDir,
797
- };
798
- const result = await executeCheckpoint(action, adapterRegistry, actionContext);
799
- checkpoint.status = result.status;
800
- checkpoint.error = result.error;
801
-
802
- // Update plan action status
803
- action.status = result.status;
804
-
805
- await evidence.append({
806
- phase: 'checkpoint',
807
- actionId: action.id,
808
- actionType: action.type,
809
- status: result.status === 'SUCCEEDED' ? 'completed' : 'failed',
810
- error: result.error,
811
- });
1052
+ for (let tierIndex = 0; tierIndex < tiers.length && !stopped; tierIndex += 1) {
1053
+ const tierActions = tiers[tierIndex];
1054
+ if (tierActions.length === 0) continue;
1055
+ const tierCheckpoints = tierActions.map((action) => checkpointByActionId.get(action.id));
1056
+
1057
+ // --- Tier start: persist every action in this tier as UNCERTAIN in a
1058
+ // single snapshot BEFORE any execute is authorized. Once an execute is
1059
+ // about to start, this snapshot is itself a reconcile-consumable recovery
1060
+ // authority; a process kill after an adapter accepts a write must never
1061
+ // leave only PUBLISHING state.
1062
+ for (let i = 0; i < tierActions.length; i += 1) {
1063
+ tierCheckpoints[i].status = 'UNCERTAIN';
1064
+ await evidence.append({
1065
+ phase: 'checkpoint',
1066
+ actionId: tierActions[i].id,
1067
+ actionType: tierActions[i].type,
1068
+ status: 'started',
1069
+ details: { tier: tierIndex },
1070
+ });
1071
+ }
1072
+ stateSequence += 1;
1073
+ latestState = await appendRunState(runDir, stateSequence, buildPersistedState(PARTIAL));
812
1074
 
813
- if (result.status !== 'SUCCEEDED') {
814
- stopped = true;
1075
+ // --- Tier execute: concurrent, allSettled semantics (no fail-fast).
1076
+ const results = await Promise.all(tierActions.map(async (action) => {
1077
+ const isMarketplace = MARKETPLACE_TYPES.has(action.type);
1078
+ const actionContext = {
1079
+ externalWritesAuthorized: !isMarketplace,
1080
+ isolatedConsumerWritesAuthorized: isMarketplace,
1081
+ plan: publishingPlan,
1082
+ baseline: plan.baseline,
1083
+ root,
1084
+ runDir,
1085
+ evidence,
1086
+ observeRetrySleep,
1087
+ };
1088
+ try {
1089
+ return await executeCheckpoint(action, adapterRegistry, actionContext);
1090
+ } catch (error) {
1091
+ // executeCheckpoint classifies failures rather than throwing; a throw
1092
+ // is still an uncertain outcome and must never drop the action from
1093
+ // the tier snapshot.
1094
+ return { actionId: action.id, status: 'UNCERTAIN', error: `checkpoint threw: ${error.message}` };
1095
+ }
1096
+ }));
1097
+
1098
+ // --- Tier settle: fold every result into the snapshot, successes and
1099
+ // failures alike (a failed sibling must not discard a success).
1100
+ let tierFailed = false;
1101
+ for (let i = 0; i < tierActions.length; i += 1) {
1102
+ const action = tierActions[i];
1103
+ const checkpoint = tierCheckpoints[i];
1104
+ const result = results[i];
1105
+ checkpoint.status = result.status;
1106
+ checkpoint.error = result.error;
1107
+ if (result.preObserve) checkpoint.preObserve = result.preObserve;
1108
+ if (result.postObserve) checkpoint.postObserve = result.postObserve;
1109
+ action.status = result.status;
1110
+ await evidence.append({
1111
+ phase: 'checkpoint',
1112
+ actionId: action.id,
1113
+ actionType: action.type,
1114
+ status: result.status === 'SUCCEEDED' || result.status === 'SKIPPED' ? 'completed' : 'failed',
1115
+ error: result.error,
1116
+ details: { tier: tierIndex, ...(result.status === 'SKIPPED' ? { skipped: true } : {}) },
1117
+ });
1118
+ // SKIPPED is a success (the remote was already consistent); it must not
1119
+ // stop the saga. Only FAILED/UNCERTAIN fail the tier.
1120
+ if (result.status !== 'SUCCEEDED' && result.status !== 'SKIPPED') {
1121
+ tierFailed = true;
1122
+ }
815
1123
  }
816
1124
 
1125
+ // --- Tier end: one snapshot carrying the whole tier's results.
817
1126
  stateSequence += 1;
818
1127
  latestState = await appendRunState(runDir, stateSequence, buildPersistedState(PARTIAL));
1128
+
1129
+ if (tierFailed) {
1130
+ stopped = true;
1131
+ }
819
1132
  }
820
1133
 
821
1134
  // Close the push -> default-branch TOCTOU window with a final read-only
822
1135
  // consistency pass. Both the branch tip and default-branch name are bound
823
1136
  // in the frozen action expectations. A late change keeps the saga PARTIAL
824
1137
  // and must be resolved by reconcile/human review.
825
- if (checkpoints.every((cp) => cp.status === 'SUCCEEDED')) {
1138
+ if (checkpoints.every((cp) => cp.status === 'SUCCEEDED' || cp.status === 'SKIPPED')) {
826
1139
  await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'started' });
827
1140
  for (let index = 0; index < orderedActions.length; index += 1) {
828
1141
  const action = orderedActions[index];
@@ -856,14 +1169,17 @@ export async function publishRelease(options) {
856
1169
  break;
857
1170
  }
858
1171
  }
859
- if (checkpoints.every((cp) => cp.status === 'SUCCEEDED')) {
1172
+ if (checkpoints.every((cp) => cp.status === 'SUCCEEDED' || cp.status === 'SKIPPED')) {
860
1173
  await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'passed' });
861
1174
  }
862
1175
  }
863
1176
 
864
1177
  // Determine overall status
865
1178
  const hasFailure = checkpoints.some((cp) => cp.status === 'FAILED' || cp.status === 'UNCERTAIN');
866
- const allSucceeded = checkpoints.every((cp) => cp.status === 'SUCCEEDED');
1179
+ // SKIPPED counts as success: the remote was already consistent with the
1180
+ // frozen plan, so an all-SKIPPED/SUCCEEDED run is a clean PUBLISHED (the
1181
+ // idempotent ideal -- zero writes were even needed).
1182
+ const allSucceeded = checkpoints.every((cp) => cp.status === 'SUCCEEDED' || cp.status === 'SKIPPED');
867
1183
 
868
1184
  let overallStatus;
869
1185
  if (allSucceeded) {