release-skill 0.8.0 → 0.9.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 (144) 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 +65 -7
  7. package/INSTALL.md +14 -2
  8. package/INSTALL.zh-CN.md +12 -2
  9. package/README.md +51 -31
  10. package/README.zh-CN.md +33 -20
  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/error-codes.json +1 -1
  14. package/adapters/claude/bin/foundation-resource-binding.json +1 -1
  15. package/adapters/claude/bin/prebuild-manifest.json +8 -4
  16. package/adapters/claude/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  17. package/adapters/claude/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  18. package/adapters/claude/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  19. package/adapters/claude/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  20. package/adapters/claude/bin/registry.json +22 -1
  21. package/adapters/claude/bin/release-skill-local-finish.mjs +4 -0
  22. package/adapters/claude/bin/release-skill.bundle.mjs +7152 -4231
  23. package/adapters/claude/bin/rules.json +1 -1
  24. package/adapters/claude/schemas/release-plan.schema.json +43 -5
  25. package/adapters/claude/schemas/release-run.schema.json +35 -1
  26. package/adapters/claude/skills/release-finish/SKILL.md +65 -0
  27. package/adapters/claude/skills/release-verify/SKILL.md +6 -1
  28. package/adapters/claude/src/schemas/filesystem-tree-observation.schema.json +55 -0
  29. package/adapters/claude/src/schemas/host-verification-result.schema.json +2 -2
  30. package/adapters/claude/src/schemas/plugin-verification-request.schema.json +374 -0
  31. package/adapters/claude/src/schemas/plugin-verification-result.schema.json +799 -0
  32. package/adapters/claude/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  33. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  34. package/adapters/codex/bin/error-codes.json +1 -1
  35. package/adapters/codex/bin/foundation-resource-binding.json +1 -1
  36. package/adapters/codex/bin/prebuild-manifest.json +8 -4
  37. package/adapters/codex/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  38. package/adapters/codex/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  39. package/adapters/codex/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  40. package/adapters/codex/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  41. package/adapters/codex/bin/registry.json +22 -1
  42. package/adapters/codex/bin/release-skill-local-finish.mjs +4 -0
  43. package/adapters/codex/bin/release-skill.bundle.mjs +7152 -4231
  44. package/adapters/codex/bin/rules.json +1 -1
  45. package/adapters/codex/schemas/release-plan.schema.json +43 -5
  46. package/adapters/codex/schemas/release-run.schema.json +35 -1
  47. package/adapters/codex/skills/release-finish/SKILL.md +72 -0
  48. package/adapters/codex/skills/release-verify/SKILL.md +6 -1
  49. package/adapters/codex/src/schemas/filesystem-tree-observation.schema.json +55 -0
  50. package/adapters/codex/src/schemas/host-verification-result.schema.json +2 -2
  51. package/adapters/codex/src/schemas/plugin-verification-request.schema.json +374 -0
  52. package/adapters/codex/src/schemas/plugin-verification-result.schema.json +799 -0
  53. package/adapters/codex/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  54. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  55. package/adapters/kimi/bin/error-codes.json +1 -1
  56. package/adapters/kimi/bin/foundation-resource-binding.json +1 -1
  57. package/adapters/kimi/bin/prebuild-manifest.json +8 -4
  58. package/adapters/kimi/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  59. package/adapters/kimi/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  60. package/adapters/kimi/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  61. package/adapters/kimi/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  62. package/adapters/kimi/bin/registry.json +22 -1
  63. package/adapters/kimi/bin/release-skill-local-finish.mjs +4 -0
  64. package/adapters/kimi/bin/release-skill.bundle.mjs +7152 -4231
  65. package/adapters/kimi/bin/rules.json +1 -1
  66. package/adapters/kimi/schemas/release-plan.schema.json +43 -5
  67. package/adapters/kimi/schemas/release-run.schema.json +35 -1
  68. package/adapters/kimi/skills/release-finish/SKILL.md +72 -0
  69. package/adapters/kimi/skills/release-verify/SKILL.md +6 -1
  70. package/adapters/kimi/src/schemas/filesystem-tree-observation.schema.json +55 -0
  71. package/adapters/kimi/src/schemas/host-verification-result.schema.json +2 -2
  72. package/adapters/kimi/src/schemas/plugin-verification-request.schema.json +374 -0
  73. package/adapters/kimi/src/schemas/plugin-verification-result.schema.json +799 -0
  74. package/adapters/kimi/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  75. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  76. package/adapters/workbuddy/bin/error-codes.json +1 -1
  77. package/adapters/workbuddy/bin/foundation-resource-binding.json +1 -1
  78. package/adapters/workbuddy/bin/prebuild-manifest.json +8 -4
  79. package/adapters/workbuddy/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  80. package/adapters/workbuddy/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  81. package/adapters/workbuddy/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  82. package/adapters/workbuddy/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  83. package/adapters/workbuddy/bin/registry.json +22 -1
  84. package/adapters/workbuddy/bin/release-skill-local-finish.mjs +4 -0
  85. package/adapters/workbuddy/bin/release-skill.bundle.mjs +7152 -4231
  86. package/adapters/workbuddy/bin/rules.json +1 -1
  87. package/adapters/workbuddy/schemas/release-plan.schema.json +43 -5
  88. package/adapters/workbuddy/schemas/release-run.schema.json +35 -1
  89. package/adapters/workbuddy/skills/release-finish/SKILL.md +65 -0
  90. package/adapters/workbuddy/skills/release-verify/SKILL.md +6 -1
  91. package/adapters/workbuddy/src/schemas/filesystem-tree-observation.schema.json +55 -0
  92. package/adapters/workbuddy/src/schemas/host-verification-result.schema.json +2 -2
  93. package/adapters/workbuddy/src/schemas/plugin-verification-request.schema.json +374 -0
  94. package/adapters/workbuddy/src/schemas/plugin-verification-result.schema.json +799 -0
  95. package/adapters/workbuddy/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  96. package/bin/error-codes.json +1 -1
  97. package/bin/foundation-resource-binding.json +1 -1
  98. package/bin/prebuild-manifest.json +8 -4
  99. package/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  100. package/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  101. package/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  102. package/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  103. package/bin/registry.json +22 -1
  104. package/bin/release-skill-cli.mjs +126 -1
  105. package/bin/release-skill-local-finish.mjs +4 -0
  106. package/bin/release-skill.bundle.mjs +7152 -4231
  107. package/bin/rules.json +1 -1
  108. package/package.json +4 -4
  109. package/platform-manifest.json +30 -4
  110. package/schemas/release-plan.schema.json +43 -5
  111. package/schemas/release-run.schema.json +35 -1
  112. package/skills/release-finish/SKILL.md +65 -0
  113. package/skills/release-verify/SKILL.md +6 -1
  114. package/skills-src/release-finish/SKILL.md +65 -0
  115. package/skills-src/release-verify/SKILL.md +6 -1
  116. package/src/commands/approve.mjs +14 -2
  117. package/src/commands/distribute.mjs +562 -321
  118. package/src/commands/post-release-local.mjs +611 -0
  119. package/src/commands/postverify.mjs +262 -155
  120. package/src/commands/prepare.mjs +83 -57
  121. package/src/commands/publish.mjs +11 -10
  122. package/src/commands/reconcile.mjs +11 -10
  123. package/src/commands/setup.mjs +26 -13
  124. package/src/commands/ship.mjs +45 -12
  125. package/src/commands/verify.mjs +81 -18
  126. package/src/core/adoption-assessment.mjs +0 -3
  127. package/src/core/approval.mjs +11 -8
  128. package/src/core/foundation-plugin-verification.mjs +247 -0
  129. package/src/core/hooks.mjs +0 -16
  130. package/src/core/plan.mjs +19 -11
  131. package/src/core/postpublish-approval.mjs +12 -2
  132. package/src/core/postpublish-bundle.mjs +51 -13
  133. package/src/core/postpublish.mjs +146 -6
  134. package/src/core/preset-executor.mjs +53 -0
  135. package/src/core/proposal-inbox.mjs +91 -27
  136. package/src/core/recovery.mjs +27 -12
  137. package/src/core/skill-resource-closure.mjs +8 -4
  138. package/src/producers/build-adapters.mjs +10 -1
  139. package/src/producers/foundation-resource-projection.mjs +3 -3
  140. package/src/schemas/filesystem-tree-observation.schema.json +55 -0
  141. package/src/schemas/host-verification-result.schema.json +2 -2
  142. package/src/schemas/plugin-verification-request.schema.json +374 -0
  143. package/src/schemas/plugin-verification-result.schema.json +799 -0
  144. package/src/schemas/watchdog-termination-envelope.schema.json +16 -0
@@ -99,16 +99,19 @@ import {
99
99
  validatePostPublishDeclaration,
100
100
  buildPostPublishContext,
101
101
  normalizePostPublishDeclaration,
102
+ normalizePostPublishView,
102
103
  orderNormalizedHooks,
103
104
  effectiveHookRequiresApproval,
105
+ postPublishActionId,
106
+ validatePostPublishHookIdUniqueness,
104
107
  POSTPUBLISH_CONTEXT_ENV,
105
108
  PAYLOAD_SOURCE_TAG_WORKTREE,
106
109
  } from '../core/postpublish.mjs';
107
- import { verifyAndInstallExecutionBundle } from '../core/postpublish-bundle.mjs';
110
+ import { verifyAndInstallExecutionBundle, verifyExecutionBundle } from '../core/postpublish-bundle.mjs';
108
111
  import { projectPublicPayload, PROJECTION_MECHANISM, PUBLIC_PAYLOAD_DIRNAME } from '../core/postpublish-projection.mjs';
109
112
  import { assertPostPublishApprovalAuthority, validatePostPublishApproval } from '../core/postpublish-approval.mjs';
110
113
  import { readRunRecovery } from '../core/recovery.mjs';
111
- import { executePresetHook } from '../core/preset-executor.mjs';
114
+ import { executePresetHook, preflightPresetHook } from '../core/preset-executor.mjs';
112
115
  import {
113
116
  ReleaseError,
114
117
  GATE_FAILED,
@@ -325,7 +328,19 @@ export async function distributeRelease(options) {
325
328
  { expected: plan.digest, actual: actualDigest },
326
329
  );
327
330
  }
328
- if (!plan.postPublish) {
331
+ // v3 contract (§4.3): postPublish is a frozen declaration ARRAY. Every
332
+ // downstream read goes through the single read-only normalization entry
333
+ // normalizePostPublishView — never a direct single-object read. Legacy
334
+ // (planVersion 1/2) absent stays fail-closed exactly as before; a v3 empty
335
+ // array is legal and means "no post-release execution" (handled right after
336
+ // the run authority is allocated, below).
337
+ const declarations = normalizePostPublishView(plan);
338
+ // Rework R-02: the single array-level explicit-hook-id uniqueness authority
339
+ // (core/postpublish.mjs) re-asserts the frozen view here — a digest-correct,
340
+ // schema-valid plan with duplicate hook ids across units must fail before
341
+ // any run authority, approval consumption, or side effect.
342
+ validatePostPublishHookIdUniqueness(declarations);
343
+ if (declarations.length === 0 && plan.planVersion !== 3) {
329
344
  throw new ReleaseError(
330
345
  GATE_FAILED,
331
346
  'release plan has no postPublish declaration; nothing to distribute',
@@ -352,6 +367,17 @@ export async function distributeRelease(options) {
352
367
  let sourceRunDigest = null;
353
368
  let finalRecordWritten = false;
354
369
 
370
+ // Saga-level chain state (single run authority; §6 matrix classification).
371
+ // Declared up here so every failBlocked/catch path can classify PARTIAL vs
372
+ // BLOCKED from the external-success facts (rework R-01): an exception after
373
+ // a successful external checkpoint must never be recorded BLOCKED.
374
+ let stopped = false;
375
+ let stopReason = null; // 'TARGET' | 'HOOK' — which failure stopped the saga (rework R-03).
376
+ let failures = 0;
377
+ let pushedWrites = 0;
378
+ let awaitingApproval = 0;
379
+ let hookSuccesses = 0;
380
+
355
381
  const startedAt = clockFn();
356
382
  let checkpoints = [];
357
383
  let stateSequence = -1;
@@ -401,11 +427,18 @@ export async function distributeRelease(options) {
401
427
  return appendRunState(runDir, stateSequence, buildPersistedState(status));
402
428
  };
403
429
 
404
- /** Persist the BLOCKED run record once lineage is known (idempotent). */
405
- const recordBlocked = async () => {
430
+ /**
431
+ * Persist the failure run record once lineage is known (idempotent).
432
+ * Rework R-01: an exception after a successful external checkpoint enters
433
+ * PARTIAL (the success checkpoint is retained — never rolled back); a
434
+ * failure with zero external successes stays BLOCKED.
435
+ */
436
+ const recordFailure = async () => {
406
437
  if (finalRecordWritten || !lineageKnown) return;
438
+ const externalCheckpointSuccesses = pushedWrites + hookSuccesses;
439
+ const status = externalCheckpointSuccesses > 0 ? PARTIAL : BLOCKED;
407
440
  try {
408
- await writeRunAtomic(runPath, buildPersistedState(BLOCKED, clockFn()));
441
+ await writeRunAtomic(runPath, buildPersistedState(status, clockFn()));
409
442
  finalRecordWritten = true;
410
443
  } catch {
411
444
  // Persistence must never mask the primary failure.
@@ -503,47 +536,105 @@ export async function distributeRelease(options) {
503
536
  sourceRunStatus: sourceRun.status,
504
537
  });
505
538
 
539
+ if (declarations.length === 0) {
540
+ // v3 zero declarations (§6 matrix row 0): the post-release phase is
541
+ // skipped — no adapter call, no hook, no external write. The run keeps
542
+ // the full safety-gate chain (plan, approval, source-run lineage) and
543
+ // records an honest DISTRIBUTED skip bound to the source run.
544
+ await evidence.append({
545
+ phase: 'postpublish',
546
+ status: 'skipped',
547
+ reason: 'ZERO_DECLARATIONS',
548
+ planVersion: plan.planVersion,
549
+ });
550
+ await snapshot(DISTRIBUTED);
551
+ await writeRunAtomic(runPath, buildPersistedState(DISTRIBUTED, clockFn()));
552
+ finalRecordWritten = true;
553
+ await evidence.append({
554
+ phase: 'distribute',
555
+ status: 'completed',
556
+ overallStatus: DISTRIBUTED,
557
+ checkpointStatuses: [],
558
+ });
559
+ await evidence.finish({ status: DISTRIBUTED, planPath, runPath, finishedAt: clockFn() });
560
+ return { planPath, runPath, status: DISTRIBUTED, checkpoints: [], recoveryActionCode: null };
561
+ }
562
+
506
563
  // =======================================================================
507
- // Declaration re-validation + deterministic target ordering.
508
- // R2: preset references resolve against the built-in preset registry
509
- // (core/presets.mjs); per-preset config validation (dual addressing,
510
- // marketplace/staticFiles shapes, secret scan) fails closed here.
564
+ // Phase A — full read-only preflight across ALL declarations (v3 contract
565
+ // §4.3): structure, identity, remote pre-facts and provided approvals are
566
+ // computed for every declaration BEFORE the first external write. A later
567
+ // declaration failing preflight means NO declaration may have produced an
568
+ // external write. Legacy plans present exactly one declaration (via
569
+ // normalizePostPublishView) and keep their exact behavior.
511
570
  // =======================================================================
512
- const postPublish = plan.postPublish;
513
- validatePostPublishDeclaration(postPublish, { unitId: postPublish.unitId });
514
- const orderedTargets = orderTargetsByDependency(postPublish.targets ?? []);
515
-
516
- // Normalized hook table (design §2.2): every targets[] entry maps onto a
517
- // preset hook (payload-mirror -> git-mirror, marketplace-index ->
518
- // marketplace-index-render); the table is a deterministic projection of
519
- // the digest-bound declaration, ordered by dependency topology +
520
- // declaration order. Target execution below keeps the exact legacy
521
- // semantics; hooks[] preset execution dispatches in the hook loop
522
- // (proposal-inbox git-push is wired; other presets fail closed until
523
- // their behavior ships).
524
- const normalizedDeclaration = normalizePostPublishDeclaration(postPublish);
525
- const orderedNormalizedHooks = orderNormalizedHooks(normalizedDeclaration.hooks);
526
- await evidence.append({
527
- phase: 'postpublish-normalization',
528
- status: 'passed',
529
- preGates: normalizedDeclaration.preGates.map((gate) => gate.gate),
530
- hookCount: orderedNormalizedHooks.length,
531
- hookIds: orderedNormalizedHooks.map((hook) => hook.id),
532
- });
533
-
534
- // postPublish hooks (v0.6.3 R1): distribute-phase hooks run in this saga;
535
- // postVerify-phase hooks belong to the independent postVerify run (R3)
536
- // and are only evidenced here — never executed, never silent.
537
- const declaredHooks = postPublish.hooks ?? [];
538
- const distributeHooks = declaredHooks.filter((hook) => (hook.phase ?? 'distribute') === 'distribute');
539
- const deferredPostVerifyHooks = declaredHooks.length - distributeHooks.length;
540
-
541
- /** Gate failure after lineage is known: persist BLOCKED, then rethrow. */
571
+ const v3 = plan.planVersion === 3;
572
+ // Checkpoint action ids are namespaced by the frozen unitId on v3 so a
573
+ // checkpoint stays addressable across units; legacy ids are unchanged
574
+ // (existing runs, tests and approvals pin the bare local ids). The
575
+ // derivation is shared core/postpublish.mjs `postPublishActionId` so
576
+ // recovery/verify mappings can never drift from the executed records.
577
+ const unitActionId = (unitId, localId) => postPublishActionId({ planVersion: plan.planVersion, unitId, localId });
578
+
579
+ /** Gate failure after lineage is known: persist the failure record
580
+ * (BLOCKED at zero external successes, PARTIAL once one exists — rework
581
+ * R-01), then rethrow. */
542
582
  const failBlocked = async (error) => {
543
- await recordBlocked();
583
+ await recordFailure();
544
584
  throw error;
545
585
  };
546
586
 
587
+ // --- Phase A1: declaration structure validation + normalization ---
588
+ // R2: preset references resolve against the built-in preset registry
589
+ // (core/presets.mjs); per-preset config validation (dual addressing,
590
+ // marketplace/staticFiles shapes, secret scan) fails closed here — for
591
+ // EVERY declaration, before any adapter call or external write.
592
+ // R-01 (rework): the frozen execution bundle closure is re-verified for
593
+ // EVERY declaration here too (store re-read + Foundation closure
594
+ // recomputation, the exact implementation the Phase B install reuses) —
595
+ // a later declaration's missing/drifted bundle must fail the whole saga
596
+ // BEFORE unit A writes anything.
597
+ const prepared = [];
598
+ for (const declaration of declarations) {
599
+ validatePostPublishDeclaration(declaration, { unitId: declaration.unitId });
600
+ await verifyExecutionBundle({ planPath, postPublish: declaration });
601
+ const orderedTargets = orderTargetsByDependency(declaration.targets ?? []);
602
+
603
+ // Normalized hook table (design §2.2): every targets[] entry maps onto
604
+ // a preset hook (payload-mirror -> git-mirror, marketplace-index ->
605
+ // marketplace-index-render); the table is a deterministic projection of
606
+ // the digest-bound declaration, ordered by dependency topology +
607
+ // declaration order. Target execution below keeps the exact legacy
608
+ // semantics; hooks[] preset execution dispatches in the hook loop
609
+ // (proposal-inbox git-push is wired; other presets fail closed until
610
+ // their behavior ships).
611
+ const normalizedDeclaration = normalizePostPublishDeclaration(declaration);
612
+ const orderedNormalizedHooks = orderNormalizedHooks(normalizedDeclaration.hooks);
613
+
614
+ // postPublish hooks (v0.6.3 R1): distribute-phase hooks run in this
615
+ // saga; postVerify-phase hooks belong to the independent postVerify run
616
+ // (R3) and are only evidenced here — never executed, never silent.
617
+ const declaredHooks = declaration.hooks ?? [];
618
+ const distributeHooks = declaredHooks.filter((hook) => (hook.phase ?? 'distribute') === 'distribute');
619
+ const deferredPostVerifyHooks = declaredHooks.length - distributeHooks.length;
620
+ await evidence.append({
621
+ phase: 'postpublish-normalization',
622
+ status: 'passed',
623
+ unitId: declaration.unitId,
624
+ preGates: normalizedDeclaration.preGates.map((gate) => gate.gate),
625
+ hookCount: orderedNormalizedHooks.length,
626
+ hookIds: orderedNormalizedHooks.map((hook) => hook.id),
627
+ });
628
+ prepared.push({
629
+ declaration,
630
+ orderedTargets,
631
+ normalizedDeclaration,
632
+ orderedNormalizedHooks,
633
+ distributeHooks,
634
+ deferredPostVerifyHooks,
635
+ });
636
+ }
637
+
547
638
  // =======================================================================
548
639
  // Gate: checkpoint approvals for requiresApproval hooks. Every provided
549
640
  // record is validated fail-closed BEFORE any write: first the immutable
@@ -615,48 +706,53 @@ export async function distributeRelease(options) {
615
706
  });
616
707
  }
617
708
 
618
- // Hooks whose checkpoint approval is still missing. While any exist, the
709
+ // Hooks whose checkpoint approval is still missing (across ALL
710
+ // declarations; hook ids are globally unique). While any exist, the
619
711
  // declared postPublish steps (unaudited project code) must not execute:
620
712
  // the run parks at NEEDS_INPUT/PARTIAL and the approved reconcile rerun
621
713
  // re-executes them. Targets remain plan-approval-authorized idempotent
622
714
  // remote-state convergence and are unaffected.
623
- const pendingHookApprovals = distributeHooks.filter(
715
+ const pendingHookApprovals = prepared.flatMap((prep) => prep.distributeHooks.filter(
624
716
  (hook) => effectiveHookRequiresApproval(hook) && !approvedHookIds.has(hook.id),
625
- );
717
+ ));
626
718
 
627
- // Checkpoint registry: one probe + one mirror per target, declared order,
628
- // then one postpublish-hook checkpoint per distribute-phase hook.
719
+ // Checkpoint registry: per declaration, one probe + one mirror per target
720
+ // (declared order), then one postpublish-hook checkpoint per distribute-
721
+ // phase hook. Action ids are unit-namespaced on v3 (unitActionId).
629
722
  checkpoints = [];
630
- for (const target of orderedTargets) {
631
- checkpoints.push({
632
- actionId: `probe-${target.id}`,
633
- actionType: ActionType.DISTRIBUTE_PROBE,
634
- status: 'PENDING',
635
- remoteUrl: target.remoteUrl,
636
- branch: target.branch,
637
- tag: postPublish.tag,
638
- executor: EXECUTOR,
639
- });
640
- }
641
- for (const target of orderedTargets) {
642
- checkpoints.push({
643
- actionId: target.id,
644
- actionType: ActionType.DISTRIBUTE_MIRROR,
645
- status: 'PENDING',
646
- remoteUrl: target.remoteUrl,
647
- branch: target.branch,
648
- tag: postPublish.tag,
649
- tagCommit: SHA_RE.test(postPublish.tagCommit ?? '') ? postPublish.tagCommit : undefined,
650
- executor: EXECUTOR,
651
- });
652
- }
653
- for (const hook of distributeHooks) {
654
- checkpoints.push({
655
- actionId: hook.id,
656
- actionType: 'postpublish-hook',
657
- status: 'PENDING',
658
- executor: EXECUTOR,
659
- });
723
+ for (const prep of prepared) {
724
+ const { declaration, orderedTargets, distributeHooks } = prep;
725
+ for (const target of orderedTargets) {
726
+ checkpoints.push({
727
+ actionId: unitActionId(declaration.unitId, `probe-${target.id}`),
728
+ actionType: ActionType.DISTRIBUTE_PROBE,
729
+ status: 'PENDING',
730
+ remoteUrl: target.remoteUrl,
731
+ branch: target.branch,
732
+ tag: declaration.tag,
733
+ executor: EXECUTOR,
734
+ });
735
+ }
736
+ for (const target of orderedTargets) {
737
+ checkpoints.push({
738
+ actionId: unitActionId(declaration.unitId, target.id),
739
+ actionType: ActionType.DISTRIBUTE_MIRROR,
740
+ status: 'PENDING',
741
+ remoteUrl: target.remoteUrl,
742
+ branch: target.branch,
743
+ tag: declaration.tag,
744
+ tagCommit: SHA_RE.test(declaration.tagCommit ?? '') ? declaration.tagCommit : undefined,
745
+ executor: EXECUTOR,
746
+ });
747
+ }
748
+ for (const hook of distributeHooks) {
749
+ checkpoints.push({
750
+ actionId: unitActionId(declaration.unitId, hook.id),
751
+ actionType: 'postpublish-hook',
752
+ status: 'PENDING',
753
+ executor: EXECUTOR,
754
+ });
755
+ }
660
756
  }
661
757
  const checkpointById = new Map(checkpoints.map((cp) => [cp.actionId, cp]));
662
758
 
@@ -670,129 +766,150 @@ export async function distributeRelease(options) {
670
766
  // local tag is diagnostic only (a fresh split source repo legitimately
671
767
  // has no local tag). Missing binding/objects/lineage fails closed as
672
768
  // GATE_FAILED; remote tag missing/drifted is a REMOTE_CONFLICT requiring
673
- // human decision.
769
+ // human decision. Runs per declaration, in frozen order, all read-only.
674
770
  // =======================================================================
675
- await evidence.append({ phase: 'safety-gate', gate: 'tag-identity', status: 'started' });
676
-
677
- if (postPublish.payloadSource !== PAYLOAD_SOURCE_TAG_WORKTREE) {
678
- await failBlocked(new ReleaseError(
679
- GATE_FAILED,
680
- `postPublish.payloadSource must be "${PAYLOAD_SOURCE_TAG_WORKTREE}"`,
681
- { payloadSource: postPublish.payloadSource },
682
- ));
683
- }
684
- if (typeof postPublish.tagCommit !== 'string' || !SHA_RE.test(postPublish.tagCommit)) {
685
- await failBlocked(new ReleaseError(
686
- GATE_FAILED,
687
- 'postPublish is missing a frozen tagCommit binding; distribute fails closed',
688
- { tagCommit: postPublish.tagCommit ?? null },
689
- ));
690
- }
691
-
692
- let tagAuthority;
693
- try {
694
- tagAuthority = await resolveFrozenTagAuthority({
695
- plan,
696
- unitId: postPublish.unitId,
697
- sourceRun,
698
- root,
699
- exec,
700
- observeTagFn,
701
- observeBranchFn,
702
- // 裁决 15: the public main line is observed only for the explicit
703
- // optional gate; with it off, no branch observation happens at all.
704
- observeMainLine: postPublish.assertMainVersionAhead === true,
705
- });
706
- } catch (err) {
771
+ for (const prep of prepared) {
772
+ const { declaration } = prep;
707
773
  await evidence.append({
708
774
  phase: 'safety-gate',
709
775
  gate: 'tag-identity',
710
- status: 'failed',
711
- error: asError('TAG_IDENTITY_GATE_FAILED', boundedOutputTail(err?.message ?? String(err))),
712
- details: {
713
- code: err?.code ?? GATE_FAILED,
714
- reason: err?.details?.reason ?? null,
715
- },
716
- });
717
- await failBlocked(err instanceof ReleaseError ? err : new ReleaseError(
718
- GATE_FAILED,
719
- `tag identity cannot be verified: ${err?.message ?? err}`,
720
- ));
721
- }
722
- // Cross-section consistency: the postPublish block and the create-tag
723
- // external action must agree on the frozen tag identity.
724
- if (tagAuthority.tag !== postPublish.tag || tagAuthority.commit !== postPublish.tagCommit) {
725
- await failBlocked(new ReleaseError(
726
- GATE_FAILED,
727
- 'frozen postPublish tag identity conflicts with the frozen create-tag binding',
728
- {
729
- postPublishTag: postPublish.tag,
730
- postPublishTagCommit: postPublish.tagCommit,
731
- bindingTag: tagAuthority.tag,
732
- bindingCommit: tagAuthority.commit,
733
- },
734
- ));
735
- }
736
- if (tagAuthority.localTagDrifted) {
737
- // R-01: the local tag is a diagnostic only — never a failure.
738
- await evidence.append({
739
- phase: 'safety-gate',
740
- gate: 'tag-identity',
741
- status: 'warning',
742
- detail: 'local tag drifted from the frozen commit; public remote stays authoritative',
743
- localTagCommit: tagAuthority.localTagCommit,
744
- frozenTagCommit: tagAuthority.commit,
776
+ status: 'started',
777
+ unitId: declaration.unitId,
745
778
  });
746
- }
747
779
 
748
- await evidence.append({
749
- phase: 'safety-gate',
750
- gate: 'tag-identity',
751
- status: 'passed',
752
- tagCommit: tagAuthority.commit,
753
- observedRemoteTag: tagAuthority.observedRemoteTag,
754
- localTagPresent: tagAuthority.localTagPresent,
755
- });
780
+ if (declaration.payloadSource !== PAYLOAD_SOURCE_TAG_WORKTREE) {
781
+ await failBlocked(new ReleaseError(
782
+ GATE_FAILED,
783
+ `postPublish.payloadSource must be "${PAYLOAD_SOURCE_TAG_WORKTREE}"`,
784
+ { payloadSource: declaration.payloadSource, unitId: declaration.unitId },
785
+ ));
786
+ }
787
+ if (typeof declaration.tagCommit !== 'string' || !SHA_RE.test(declaration.tagCommit)) {
788
+ await failBlocked(new ReleaseError(
789
+ GATE_FAILED,
790
+ 'postPublish is missing a frozen tagCommit binding; distribute fails closed',
791
+ { tagCommit: declaration.tagCommit ?? null, unitId: declaration.unitId },
792
+ ));
793
+ }
756
794
 
757
- // =======================================================================
758
- // Gate 5 (optional): the public main line must have moved ahead of the
759
- // tag (observed from the public remote, not the live workspace).
760
- // =======================================================================
761
- if (postPublish.assertMainVersionAhead === true) {
762
- await evidence.append({ phase: 'safety-gate', gate: 'main-version-ahead', status: 'started' });
795
+ let tagAuthority;
763
796
  try {
764
- await assertMainLineAhead({
765
- tagCommit: tagAuthority.commit,
766
- branchCommit: tagAuthority.branchCommit,
767
- branch: tagAuthority.branch,
768
- gitDir: tagAuthority.gitDir,
769
- remoteUrl: tagAuthority.remoteUrl,
797
+ tagAuthority = await resolveFrozenTagAuthority({
798
+ plan,
799
+ unitId: declaration.unitId,
800
+ sourceRun,
801
+ root,
770
802
  exec,
803
+ observeTagFn,
804
+ observeBranchFn,
805
+ // 裁决 15: the public main line is observed only for the explicit
806
+ // optional gate; with it off, no branch observation happens at all.
807
+ observeMainLine: declaration.assertMainVersionAhead === true,
771
808
  });
772
809
  } catch (err) {
773
810
  await evidence.append({
774
811
  phase: 'safety-gate',
775
- gate: 'main-version-ahead',
812
+ gate: 'tag-identity',
776
813
  status: 'failed',
777
- error: asError('MAIN_VERSION_AHEAD_FAILED', boundedOutputTail(err?.message ?? String(err))),
814
+ error: asError('TAG_IDENTITY_GATE_FAILED', boundedOutputTail(err?.message ?? String(err))),
815
+ details: {
816
+ code: err?.code ?? GATE_FAILED,
817
+ reason: err?.details?.reason ?? null,
818
+ unitId: declaration.unitId,
819
+ },
778
820
  });
779
821
  await failBlocked(err instanceof ReleaseError ? err : new ReleaseError(
780
822
  GATE_FAILED,
781
- `assertMainVersionAhead failed: ${err?.message ?? err}`,
823
+ `tag identity cannot be verified: ${err?.message ?? err}`,
824
+ ));
825
+ }
826
+ // Cross-section consistency: the declaration and the create-tag
827
+ // external action must agree on the frozen tag identity.
828
+ if (tagAuthority.tag !== declaration.tag || tagAuthority.commit !== declaration.tagCommit) {
829
+ await failBlocked(new ReleaseError(
830
+ GATE_FAILED,
831
+ 'frozen postPublish tag identity conflicts with the frozen create-tag binding',
832
+ {
833
+ postPublishTag: declaration.tag,
834
+ postPublishTagCommit: declaration.tagCommit,
835
+ bindingTag: tagAuthority.tag,
836
+ bindingCommit: tagAuthority.commit,
837
+ unitId: declaration.unitId,
838
+ },
782
839
  ));
783
840
  }
784
- // failBlocked always throws, so this line runs only when the check passed.
841
+ if (tagAuthority.localTagDrifted) {
842
+ // R-01: the local tag is a diagnostic only — never a failure.
843
+ await evidence.append({
844
+ phase: 'safety-gate',
845
+ gate: 'tag-identity',
846
+ status: 'warning',
847
+ detail: 'local tag drifted from the frozen commit; public remote stays authoritative',
848
+ localTagCommit: tagAuthority.localTagCommit,
849
+ frozenTagCommit: tagAuthority.commit,
850
+ unitId: declaration.unitId,
851
+ });
852
+ }
853
+
785
854
  await evidence.append({
786
855
  phase: 'safety-gate',
787
- gate: 'main-version-ahead',
856
+ gate: 'tag-identity',
788
857
  status: 'passed',
789
- branch: tagAuthority.branch,
790
- branchCommit: tagAuthority.branchCommit,
858
+ tagCommit: tagAuthority.commit,
859
+ observedRemoteTag: tagAuthority.observedRemoteTag,
860
+ localTagPresent: tagAuthority.localTagPresent,
861
+ unitId: declaration.unitId,
791
862
  });
863
+
864
+ // ===================================================================
865
+ // Gate 5 (optional): the public main line must have moved ahead of the
866
+ // tag (observed from the public remote, not the live workspace).
867
+ // ===================================================================
868
+ if (declaration.assertMainVersionAhead === true) {
869
+ await evidence.append({
870
+ phase: 'safety-gate',
871
+ gate: 'main-version-ahead',
872
+ status: 'started',
873
+ unitId: declaration.unitId,
874
+ });
875
+ try {
876
+ await assertMainLineAhead({
877
+ tagCommit: tagAuthority.commit,
878
+ branchCommit: tagAuthority.branchCommit,
879
+ branch: tagAuthority.branch,
880
+ gitDir: tagAuthority.gitDir,
881
+ remoteUrl: tagAuthority.remoteUrl,
882
+ exec,
883
+ });
884
+ } catch (err) {
885
+ await evidence.append({
886
+ phase: 'safety-gate',
887
+ gate: 'main-version-ahead',
888
+ status: 'failed',
889
+ error: asError('MAIN_VERSION_AHEAD_FAILED', boundedOutputTail(err?.message ?? String(err))),
890
+ });
891
+ await failBlocked(err instanceof ReleaseError ? err : new ReleaseError(
892
+ GATE_FAILED,
893
+ `assertMainVersionAhead failed: ${err?.message ?? err}`,
894
+ ));
895
+ }
896
+ // failBlocked always throws, so this line runs only when the check passed.
897
+ await evidence.append({
898
+ phase: 'safety-gate',
899
+ gate: 'main-version-ahead',
900
+ status: 'passed',
901
+ branch: tagAuthority.branch,
902
+ branchCommit: tagAuthority.branchCommit,
903
+ unitId: declaration.unitId,
904
+ });
905
+ }
906
+
907
+ prep.tagAuthority = tagAuthority;
792
908
  }
793
909
 
794
910
  // =======================================================================
795
- // Gate 6: adapter availability + per-target reachability preflight.
911
+ // Gate 6: adapter availability (once, saga-level) + per-target reachability
912
+ // preflight (read-only) for EVERY declaration before any write.
796
913
  // =======================================================================
797
914
  let probeAdapter;
798
915
  let mirrorAdapter;
@@ -806,99 +923,123 @@ export async function distributeRelease(options) {
806
923
  ));
807
924
  }
808
925
 
809
- const probeObservations = new Map();
810
- for (const target of orderedTargets) {
811
- const probeCheckpoint = checkpointById.get(`probe-${target.id}`);
812
- probeCheckpoint.startedAt = clockFn();
813
- await evidence.append({
814
- phase: 'safety-gate',
815
- gate: `probe-${target.id}`,
816
- status: 'started',
817
- remoteUrl: target.remoteUrl,
818
- });
819
- const probeAction = {
820
- actionType: ActionType.DISTRIBUTE_PROBE,
821
- targetId: target.id,
822
- remoteUrl: target.remoteUrl,
823
- tag: postPublish.tag,
824
- };
825
- let probeResult;
826
- try {
827
- probeResult = await probeAdapter.preflight(probeAction, {
828
- externalWritesAuthorized: false,
829
- plan,
830
- root,
831
- runDir,
926
+ for (const prep of prepared) {
927
+ const { declaration, orderedTargets } = prep;
928
+ const probeObservations = new Map();
929
+ for (const target of orderedTargets) {
930
+ const probeCheckpoint = checkpointById.get(unitActionId(declaration.unitId, `probe-${target.id}`));
931
+ probeCheckpoint.startedAt = clockFn();
932
+ await evidence.append({
933
+ phase: 'safety-gate',
934
+ gate: `probe-${target.id}`,
935
+ status: 'started',
936
+ remoteUrl: target.remoteUrl,
937
+ unitId: declaration.unitId,
832
938
  });
833
- } catch (err) {
834
- probeResult = { status: 'PREFLIGHT_FAILED', error: err?.message ?? String(err), details: null };
835
- }
836
- probeCheckpoint.finishedAt = clockFn();
837
- if (probeResult.status !== 'PREFLIGHT_PASSED') {
838
- const code = [REMOTE_UNAVAILABLE, REMOTE_CONFLICT].includes(probeResult.details?.code)
839
- ? probeResult.details.code
840
- : GATE_FAILED;
841
- probeCheckpoint.status = 'FAILED';
842
- probeCheckpoint.error = { code, message: probeResult.error ?? 'distribute probe failed' };
939
+ const probeAction = {
940
+ actionType: ActionType.DISTRIBUTE_PROBE,
941
+ targetId: target.id,
942
+ remoteUrl: target.remoteUrl,
943
+ tag: declaration.tag,
944
+ };
945
+ let probeResult;
946
+ try {
947
+ probeResult = await probeAdapter.preflight(probeAction, {
948
+ externalWritesAuthorized: false,
949
+ plan,
950
+ root,
951
+ runDir,
952
+ });
953
+ } catch (err) {
954
+ probeResult = { status: 'PREFLIGHT_FAILED', error: err?.message ?? String(err), details: null };
955
+ }
956
+ probeCheckpoint.finishedAt = clockFn();
957
+ if (probeResult.status !== 'PREFLIGHT_PASSED') {
958
+ const code = [REMOTE_UNAVAILABLE, REMOTE_CONFLICT].includes(probeResult.details?.code)
959
+ ? probeResult.details.code
960
+ : GATE_FAILED;
961
+ probeCheckpoint.status = 'FAILED';
962
+ probeCheckpoint.error = { code, message: probeResult.error ?? 'distribute probe failed' };
963
+ await evidence.append({
964
+ phase: 'safety-gate',
965
+ gate: `probe-${target.id}`,
966
+ status: 'failed',
967
+ error: asError('PROBE_FAILED', probeResult.error),
968
+ details: { code },
969
+ });
970
+ await recordFailure();
971
+ throw new ReleaseError(
972
+ code,
973
+ `distribute probe failed for target "${target.id}": ${probeResult.error ?? 'remote unreachable'}`,
974
+ { targetId: target.id, remoteUrl: target.remoteUrl },
975
+ );
976
+ }
977
+ probeCheckpoint.status = 'SUCCEEDED';
978
+ probeObservations.set(target.id, probeResult.observation ?? {});
843
979
  await evidence.append({
844
980
  phase: 'safety-gate',
845
981
  gate: `probe-${target.id}`,
846
- status: 'failed',
847
- error: asError('PROBE_FAILED', probeResult.error),
848
- details: { code },
982
+ status: 'passed',
983
+ tagExists: probeResult.observation?.tagExists ?? false,
984
+ unitId: declaration.unitId,
849
985
  });
850
- await recordBlocked();
851
- throw new ReleaseError(
852
- code,
853
- `distribute probe failed for target "${target.id}": ${probeResult.error ?? 'remote unreachable'}`,
854
- { targetId: target.id, remoteUrl: target.remoteUrl },
855
- );
856
986
  }
857
- probeCheckpoint.status = 'SUCCEEDED';
858
- probeObservations.set(target.id, probeResult.observation ?? {});
859
- await evidence.append({
860
- phase: 'safety-gate',
861
- gate: `probe-${target.id}`,
862
- status: 'passed',
863
- tagExists: probeResult.observation?.tagExists ?? false,
864
- });
987
+ prep.probeObservations = probeObservations;
865
988
  }
866
989
 
867
990
  // =======================================================================
868
- // R1 timing contract: detached worktree at the frozen tagCommit. The
869
- // payload may never come from the live workspace. R-01: the worktree is
870
- // materialized from a COPY of the frozen bare repo (never registered in
871
- // the source repo's worktree metadata, never touching its refs).
991
+ // R-01 (rework): preset deterministic-conflict preflight the narrow
992
+ // READ-ONLY entry (core/preset-executor.mjs preflightPresetHook) calls the
993
+ // SAME observation implementation the execution-phase delivery uses, for
994
+ // every preset hook that will actually execute this run. A deterministic
995
+ // conflict (e.g. the proposal path already exists with different bytes)
996
+ // fails the whole saga here, BEFORE the first external write. This is not
997
+ // a lock: the execution phase re-observes every remote regardless. Hooks
998
+ // parked at AWAITING_APPROVAL are not observed here — the approved
999
+ // reconcile rerun observes them at execution time.
872
1000
  // =======================================================================
873
- await evidence.append({ phase: 'worktree', status: 'started', tagCommit: tagAuthority.commit });
874
- try {
875
- tmpBase = await mkdtemp(join(tmpdir(), 'release-skill-distribute-'));
876
- ({ worktreePath } = await createFrozenTagWorktree({
877
- gitDir: tagAuthority.gitDir,
878
- commit: tagAuthority.commit,
879
- tmpBase,
880
- exec,
881
- }));
882
- } catch (err) {
883
- await evidence.append({ phase: 'worktree', status: 'failed', error: asError('WORKTREE_FAILED', boundedOutputTail(err?.stderr ?? err?.message)) });
884
- await failBlocked(new ReleaseError(
885
- GATE_FAILED,
886
- `cannot create the detached tag worktree at ${tagAuthority.commit}: ${err?.message ?? err}`,
887
- { tagCommit: tagAuthority.commit },
888
- ));
1001
+ for (const prep of prepared) {
1002
+ for (const hook of prep.distributeHooks) {
1003
+ if (effectiveHookRequiresApproval(hook) && !approvedHookIds.has(hook.id)) continue;
1004
+ // Lineage-stable proposal identity: the SAME deterministic projection
1005
+ // the execution phase serializes (never the per-attempt runId).
1006
+ const proposalProjection = buildPostPublishContext({
1007
+ plan,
1008
+ postPublish: prep.declaration,
1009
+ runId: `distribute-${sourceRunId}`,
1010
+ sourceRun,
1011
+ payloadDir: undefined,
1012
+ phase: 'distribute',
1013
+ });
1014
+ const observation = await preflightPresetHook({
1015
+ hook,
1016
+ contextProjection: proposalProjection,
1017
+ exec,
1018
+ });
1019
+ if (observation !== null) {
1020
+ await evidence.append({
1021
+ phase: 'safety-gate',
1022
+ gate: 'preset-preflight',
1023
+ status: 'passed',
1024
+ hookId: hook.id,
1025
+ unitId: prep.declaration.unitId,
1026
+ verdict: observation.verdict,
1027
+ });
1028
+ }
1029
+ }
889
1030
  }
890
- await evidence.append({ phase: 'worktree', status: 'passed' });
891
1031
 
892
1032
  // =======================================================================
893
- // Private execution bundle (F-01 / T1): consumer-declared commands may
894
- // reference scripts that exist only in the parent workspace (tooling
895
- // never leaks into the frozen public surface). prepare froze those bytes
896
- // digest-addressed under the plan's .release-skill; re-verify them
897
- // through Foundation and install ONLY the verified bytes into the tag
898
- // worktree before any hook runs. Any mismatch fails closed here —
899
- // before any hook and before any external write.
900
- // =======================================================================
1033
+ // Phase B — serial execution in frozen declaration order. Each declaration
1034
+ // gets its own detached tag worktree, execution-bundle install, payload
1035
+ // staging, steps, target writes and distribute-phase hooks. The saga keeps
1036
+ // ONE run chain: a failure stops all later checkpoints (targets and hooks
1037
+ // of later declarations), and the first external success followed by a
1038
+ // failure enters PARTIAL (success checkpoints are retained never rolled
1039
+ // back). Legacy plans iterate exactly one declaration (normalize view)
1040
+ // with byte-identical behavior.
901
1041
  // =======================================================================
1042
+
902
1043
  // F-04 root split: the saga holds TWO distinct roots from here on —
903
1044
  // - releaseWorkspaceRoot: the real project root the user releases from;
904
1045
  // only used to resolve preset target.workspace, compare the release-
@@ -907,7 +1048,6 @@ export async function distributeRelease(options) {
907
1048
  // frozen tagCommit; only used as the hook runner context.root for
908
1049
  // materialize, steps and custom command hooks.
909
1050
  // The two roots never fall back onto each other through defaults.
910
- // =======================================================================
911
1051
  let releaseWorkspaceRoot;
912
1052
  try {
913
1053
  releaseWorkspaceRoot = await realpath(root);
@@ -918,31 +1058,115 @@ export async function distributeRelease(options) {
918
1058
  { root, cause: err.code },
919
1059
  ));
920
1060
  }
921
- let installedBundlePaths = [];
922
- try {
923
- ({ installed: installedBundlePaths } = await verifyAndInstallExecutionBundle({
924
- plan,
925
- planPath,
926
- worktreePath,
927
- }));
928
- } catch (err) {
1061
+
1062
+ const adapterContext = {
1063
+ externalWritesAuthorized: true, // dry-run side effects are adapter-guaranteed zero
1064
+ plan,
1065
+ root,
1066
+ runDir,
1067
+ };
1068
+
1069
+ // Rework R-03: a stop propagates to the SAGA level — when a previous
1070
+ // declaration's distribute-phase preset or custom hook failed (timeout,
1071
+ // non-zero exit, execution failure), the remaining declaration's
1072
+ // materialize, steps, targets, presets and custom hooks must NOT execute;
1073
+ // its planned checkpoints are recorded SKIPPED.
1074
+ const skipReason = () => (stopReason === 'HOOK' ? 'EARLIER_HOOK_FAILED' : 'EARLIER_TARGET_FAILED');
1075
+
1076
+ for (const prep of prepared) {
1077
+ const {
1078
+ declaration,
1079
+ orderedTargets,
1080
+ distributeHooks,
1081
+ deferredPostVerifyHooks,
1082
+ probeObservations,
1083
+ tagAuthority,
1084
+ } = prep;
1085
+
1086
+ if (stopped) {
1087
+ for (const target of orderedTargets) {
1088
+ const cp = checkpointById.get(unitActionId(declaration.unitId, target.id));
1089
+ cp.status = 'SKIPPED';
1090
+ cp.reason = skipReason();
1091
+ cp.finishedAt = clockFn();
1092
+ }
1093
+ for (const hook of distributeHooks) {
1094
+ const cp = checkpointById.get(unitActionId(declaration.unitId, hook.id));
1095
+ cp.status = 'SKIPPED';
1096
+ cp.reason = skipReason();
1097
+ cp.finishedAt = clockFn();
1098
+ }
1099
+ await evidence.append({
1100
+ phase: 'postpublish-declaration',
1101
+ status: 'skipped',
1102
+ reason: skipReason(),
1103
+ unitId: declaration.unitId,
1104
+ });
1105
+ continue;
1106
+ }
1107
+
1108
+ // R1 timing contract: detached worktree at the frozen tagCommit. The
1109
+ // payload may never come from the live workspace. R-01: the worktree is
1110
+ // materialized from a COPY of the frozen bare repo (never registered in
1111
+ // the source repo's worktree metadata, never touching its refs).
1112
+ await evidence.append({
1113
+ phase: 'worktree',
1114
+ status: 'started',
1115
+ tagCommit: tagAuthority.commit,
1116
+ unitId: declaration.unitId,
1117
+ });
1118
+ try {
1119
+ tmpBase = await mkdtemp(join(tmpdir(), 'release-skill-distribute-'));
1120
+ ({ worktreePath } = await createFrozenTagWorktree({
1121
+ gitDir: tagAuthority.gitDir,
1122
+ commit: tagAuthority.commit,
1123
+ tmpBase,
1124
+ exec,
1125
+ }));
1126
+ } catch (err) {
1127
+ await evidence.append({ phase: 'worktree', status: 'failed', error: asError('WORKTREE_FAILED', boundedOutputTail(err?.stderr ?? err?.message)) });
1128
+ await failBlocked(new ReleaseError(
1129
+ GATE_FAILED,
1130
+ `cannot create the detached tag worktree at ${tagAuthority.commit}: ${err?.message ?? err}`,
1131
+ { tagCommit: tagAuthority.commit },
1132
+ ));
1133
+ }
1134
+ await evidence.append({ phase: 'worktree', status: 'passed' });
1135
+
1136
+ // =====================================================================
1137
+ // Private execution bundle (F-01 / T1): consumer-declared commands may
1138
+ // reference scripts that exist only in the parent workspace (tooling
1139
+ // never leaks into the frozen public surface). prepare froze those bytes
1140
+ // digest-addressed under the plan's .release-skill; re-verify them
1141
+ // through Foundation and install ONLY the verified bytes into the tag
1142
+ // worktree before any hook runs. Any mismatch fails closed here —
1143
+ // before any hook and before any external write.
1144
+ // =====================================================================
1145
+ let installedBundlePaths = [];
1146
+ try {
1147
+ ({ installed: installedBundlePaths } = await verifyAndInstallExecutionBundle({
1148
+ planPath,
1149
+ worktreePath,
1150
+ postPublish: declaration,
1151
+ }));
1152
+ } catch (err) {
1153
+ await evidence.append({
1154
+ phase: 'worktree',
1155
+ gate: 'execution-bundle',
1156
+ status: 'failed',
1157
+ error: asError('EXECUTION_BUNDLE_FAILED', boundedOutputTail(err?.message ?? String(err))),
1158
+ });
1159
+ await failBlocked(err instanceof ReleaseError ? err : new ReleaseError(
1160
+ GATE_FAILED,
1161
+ `cannot verify the frozen execution bundle: ${err?.message ?? err}`,
1162
+ ));
1163
+ }
929
1164
  await evidence.append({
930
1165
  phase: 'worktree',
931
1166
  gate: 'execution-bundle',
932
- status: 'failed',
933
- error: asError('EXECUTION_BUNDLE_FAILED', boundedOutputTail(err?.message ?? String(err))),
1167
+ status: 'passed',
1168
+ installed: installedBundlePaths,
934
1169
  });
935
- await failBlocked(err instanceof ReleaseError ? err : new ReleaseError(
936
- GATE_FAILED,
937
- `cannot verify the frozen execution bundle: ${err?.message ?? err}`,
938
- ));
939
- }
940
- await evidence.append({
941
- phase: 'worktree',
942
- gate: 'execution-bundle',
943
- status: 'passed',
944
- installed: installedBundlePaths,
945
- });
946
1170
 
947
1171
  // =======================================================================
948
1172
  // Payload staging (fail-closed). The frozen plan selects exactly one
@@ -959,7 +1183,7 @@ export async function distributeRelease(options) {
959
1183
  // rollback belongs to Foundation and is never reimplemented here.
960
1184
  // =======================================================================
961
1185
  let payloadReal;
962
- const materialize = postPublish.materialize;
1186
+ const materialize = declaration.materialize;
963
1187
 
964
1188
  if (materialize !== undefined) {
965
1189
  await evidence.append({ phase: 'materialize', status: 'started' });
@@ -1058,7 +1282,7 @@ export async function distributeRelease(options) {
1058
1282
 
1059
1283
  // Release-domain parameter selection ONLY: the frozen mapping rides the
1060
1284
  // plan digest; live project configuration is never read here.
1061
- const publicFiles = postPublish.executionBundle?.publicFiles;
1285
+ const publicFiles = declaration.executionBundle?.publicFiles;
1062
1286
  if (!Array.isArray(publicFiles) || publicFiles.length === 0) {
1063
1287
  await evidence.append({
1064
1288
  phase: 'materialize',
@@ -1107,7 +1331,7 @@ export async function distributeRelease(options) {
1107
1331
  // =======================================================================
1108
1332
  // Declared postPublish steps, in order (fail-closed).
1109
1333
  // =======================================================================
1110
- for (const step of postPublish.steps ?? []) {
1334
+ for (const step of declaration.steps ?? []) {
1111
1335
  if (dryRun === true) {
1112
1336
  // R1 dry-run contract: steps are arbitrary project code with
1113
1337
  // potentially remote side effects; they never execute in a rehearsal.
@@ -1161,42 +1385,33 @@ export async function distributeRelease(options) {
1161
1385
  }
1162
1386
 
1163
1387
  // =======================================================================
1164
- // Target distribution: sequential, dependency-ordered, observe-before-
1165
- // write (reconcile equivalence — never force).
1388
+ // Target distribution (per declaration): sequential, dependency-ordered,
1389
+ // observe-before-write (reconcile equivalence — never force).
1166
1390
  // =======================================================================
1167
1391
  await evidence.append({
1168
1392
  phase: 'distribute',
1169
1393
  status: 'started',
1394
+ unitId: declaration.unitId,
1170
1395
  targetCount: orderedTargets.length,
1171
1396
  dryRun: dryRun === true,
1172
1397
  });
1173
1398
 
1174
- const mirrorResults = new Map(); // targetId -> { sha } for dependents
1175
- let stopped = false;
1176
- let failures = 0;
1177
- let pushedWrites = 0;
1178
-
1179
- const adapterContext = {
1180
- externalWritesAuthorized: true, // dry-run side effects are adapter-guaranteed zero
1181
- plan,
1182
- root,
1183
- runDir,
1184
- };
1399
+ const mirrorResults = new Map(); // targetId -> { sha } for dependents (declaration-local)
1185
1400
 
1186
1401
  for (const target of orderedTargets) {
1187
- const cp = checkpointById.get(target.id);
1402
+ const cp = checkpointById.get(unitActionId(declaration.unitId, target.id));
1188
1403
  cp.startedAt = clockFn();
1189
1404
 
1190
1405
  if (stopped) {
1191
1406
  cp.status = 'SKIPPED';
1192
1407
  cp.reason = 'EARLIER_TARGET_FAILED';
1193
1408
  cp.finishedAt = clockFn();
1194
- await evidence.append({ phase: 'checkpoint', actionId: target.id, status: 'skipped', reason: 'EARLIER_TARGET_FAILED' });
1409
+ await evidence.append({ phase: 'checkpoint', actionId: unitActionId(declaration.unitId, target.id), status: 'skipped', reason: 'EARLIER_TARGET_FAILED' });
1195
1410
  continue;
1196
1411
  }
1197
1412
 
1198
1413
  // --- Pre-observe: a remote already at the frozen tag is SKIPPED. ---
1199
- await evidence.append({ phase: 'checkpoint', actionId: target.id, status: 'pre-observe' });
1414
+ await evidence.append({ phase: 'checkpoint', actionId: unitActionId(declaration.unitId, target.id), status: 'pre-observe' });
1200
1415
  let preObserved = {};
1201
1416
  try {
1202
1417
  const observeResult = await mirrorAdapter.observe(
@@ -1205,7 +1420,7 @@ export async function distributeRelease(options) {
1205
1420
  targetId: target.id,
1206
1421
  remoteUrl: target.remoteUrl,
1207
1422
  branch: target.branch,
1208
- tag: postPublish.tag,
1423
+ tag: declaration.tag,
1209
1424
  },
1210
1425
  adapterContext,
1211
1426
  );
@@ -1214,7 +1429,7 @@ export async function distributeRelease(options) {
1214
1429
  preObserved = {};
1215
1430
  await evidence.append({
1216
1431
  phase: 'checkpoint',
1217
- actionId: target.id,
1432
+ actionId: unitActionId(declaration.unitId, target.id),
1218
1433
  status: 'pre-observe-unobservable',
1219
1434
  error: asError('PRE_OBSERVE_FAILED', err?.message ?? String(err)),
1220
1435
  });
@@ -1226,7 +1441,7 @@ export async function distributeRelease(options) {
1226
1441
  mirrorResults.set(target.id, { sha: preObserved.tagOid });
1227
1442
  await evidence.append({
1228
1443
  phase: 'checkpoint',
1229
- actionId: target.id,
1444
+ actionId: unitActionId(declaration.unitId, target.id),
1230
1445
  status: 'skipped',
1231
1446
  details: { preObserve: 'CONSISTENT', sha: preObserved.tagOid },
1232
1447
  });
@@ -1244,15 +1459,15 @@ export async function distributeRelease(options) {
1244
1459
  kind: target.kind,
1245
1460
  remoteUrl: target.remoteUrl,
1246
1461
  branch: target.branch,
1247
- tag: postPublish.tag,
1248
- commitIdentity: postPublish.commitIdentity,
1462
+ tag: declaration.tag,
1463
+ commitIdentity: declaration.commitIdentity,
1249
1464
  dryRun: dryRun === true,
1250
1465
  };
1251
1466
  if (target.kind === 'payload-mirror') {
1252
1467
  action.payloadDir = payloadReal;
1253
1468
  } else {
1254
1469
  action.marketplace = target.marketplace;
1255
- action.pluginName = resolvePluginName(plan, postPublish.unitId);
1470
+ action.pluginName = resolvePluginName(plan, declaration.unitId);
1256
1471
  const dependencyTarget = orderedTargets.find((entry) => entry.id === target.dependsOn);
1257
1472
  action.dependency = {
1258
1473
  remoteUrl: dependencyTarget.remoteUrl,
@@ -1274,7 +1489,7 @@ export async function distributeRelease(options) {
1274
1489
  });
1275
1490
 
1276
1491
  // --- Execute (the adapter never throws; it returns EXECUTE_FAILED). ---
1277
- await evidence.append({ phase: 'checkpoint', actionId: target.id, status: 'started' });
1492
+ await evidence.append({ phase: 'checkpoint', actionId: unitActionId(declaration.unitId, target.id), status: 'started' });
1278
1493
  let executeResult;
1279
1494
  try {
1280
1495
  executeResult = await mirrorAdapter.execute(action, adapterContext);
@@ -1295,9 +1510,10 @@ export async function distributeRelease(options) {
1295
1510
  cp.finishedAt = clockFn();
1296
1511
  failures += 1;
1297
1512
  stopped = true;
1513
+ stopReason = 'TARGET';
1298
1514
  await evidence.append({
1299
1515
  phase: 'checkpoint',
1300
- actionId: target.id,
1516
+ actionId: unitActionId(declaration.unitId, target.id),
1301
1517
  status: 'failed',
1302
1518
  error: asError('EXECUTE_FAILED', executeResult.error),
1303
1519
  details: { code },
@@ -1317,7 +1533,7 @@ export async function distributeRelease(options) {
1317
1533
  ?? observation.previousHead
1318
1534
  ?? null;
1319
1535
  mirrorResults.set(target.id, { sha });
1320
- await evidence.append({ phase: 'checkpoint', actionId: target.id, status: 'no-change' });
1536
+ await evidence.append({ phase: 'checkpoint', actionId: unitActionId(declaration.unitId, target.id), status: 'no-change' });
1321
1537
  await snapshot(PARTIAL);
1322
1538
  continue;
1323
1539
  }
@@ -1345,7 +1561,7 @@ export async function distributeRelease(options) {
1345
1561
  targetId: target.id,
1346
1562
  remoteUrl: target.remoteUrl,
1347
1563
  branch: target.branch,
1348
- tag: postPublish.tag,
1564
+ tag: declaration.tag,
1349
1565
  ...(pushed ? { pushedCommit: observation.pushedCommit } : {}),
1350
1566
  },
1351
1567
  adapterContext,
@@ -1364,9 +1580,10 @@ export async function distributeRelease(options) {
1364
1580
  cp.finishedAt = clockFn();
1365
1581
  failures += 1;
1366
1582
  stopped = true;
1583
+ stopReason = 'TARGET';
1367
1584
  await evidence.append({
1368
1585
  phase: 'checkpoint',
1369
- actionId: target.id,
1586
+ actionId: unitActionId(declaration.unitId, target.id),
1370
1587
  status: 'verify-failed',
1371
1588
  error: asError('POST_PUBLISH_VERIFY_FAILED', verifyResult.error ?? null),
1372
1589
  });
@@ -1379,7 +1596,7 @@ export async function distributeRelease(options) {
1379
1596
  cp.finishedAt = clockFn();
1380
1597
  await evidence.append({
1381
1598
  phase: 'checkpoint',
1382
- actionId: target.id,
1599
+ actionId: unitActionId(declaration.unitId, target.id),
1383
1600
  status: 'completed',
1384
1601
  mode: observation.mode,
1385
1602
  pushedCommit: pushed ? observation.pushedCommit : null,
@@ -1397,14 +1614,22 @@ export async function distributeRelease(options) {
1397
1614
  // the independent postVerify run (R3) and are evidenced as deferred.
1398
1615
  // =======================================================================
1399
1616
  if (deferredPostVerifyHooks > 0) {
1400
- await evidence.append({ phase: 'postpublish-hooks', status: 'deferred', deferredPostVerifyHooks });
1617
+ await evidence.append({
1618
+ phase: 'postpublish-hooks',
1619
+ status: 'deferred',
1620
+ deferredPostVerifyHooks,
1621
+ unitId: declaration.unitId,
1622
+ });
1401
1623
  }
1402
1624
 
1403
- let hooksStopped = stopped;
1404
- let awaitingApproval = 0;
1405
- let hookSuccesses = 0;
1625
+ // Per-declaration hook chain: a failure inside this declaration stops the
1626
+ // SAGA (rework R-03) — the remaining hooks of this declaration and every
1627
+ // later declaration's materialize, steps, targets, presets and custom
1628
+ // hooks are SKIPPED. `stopped`/`stopReason` are the single saga-level
1629
+ // chain state.
1406
1630
  const hookContextProjection = buildPostPublishContext({
1407
1631
  plan,
1632
+ postPublish: declaration,
1408
1633
  runId,
1409
1634
  sourceRun,
1410
1635
  payloadDir: payloadReal,
@@ -1421,18 +1646,18 @@ export async function distributeRelease(options) {
1421
1646
  };
1422
1647
 
1423
1648
  for (const hook of distributeHooks) {
1424
- const cp = checkpointById.get(hook.id);
1649
+ const cp = checkpointById.get(unitActionId(declaration.unitId, hook.id));
1425
1650
  cp.startedAt = clockFn();
1426
1651
 
1427
- if (hooksStopped) {
1652
+ if (stopped) {
1428
1653
  cp.status = 'SKIPPED';
1429
- cp.reason = 'EARLIER_TARGET_FAILED';
1654
+ cp.reason = skipReason();
1430
1655
  cp.finishedAt = clockFn();
1431
1656
  await evidence.append({
1432
1657
  phase: 'postpublish-hook',
1433
1658
  hookId: hook.id,
1434
1659
  status: 'skipped',
1435
- reason: 'EARLIER_TARGET_FAILED',
1660
+ reason: skipReason(),
1436
1661
  });
1437
1662
  continue;
1438
1663
  }
@@ -1468,7 +1693,7 @@ export async function distributeRelease(options) {
1468
1693
  hook,
1469
1694
  contextProjection: hookContextProjection,
1470
1695
  proposalContextProjection,
1471
- commitIdentity: postPublish.commitIdentity,
1696
+ commitIdentity: declaration.commitIdentity,
1472
1697
  // F-04: presets receive the RELEASE workspace root (target.workspace
1473
1698
  // resolution + release-workspace write exclusion). The detached
1474
1699
  // worktree is the execution worktree and never impersonates it.
@@ -1484,7 +1709,8 @@ export async function distributeRelease(options) {
1484
1709
  cp.error = { code, message: err?.message ?? String(err) };
1485
1710
  cp.finishedAt = clockFn();
1486
1711
  failures += 1;
1487
- hooksStopped = true;
1712
+ stopped = true;
1713
+ stopReason = 'HOOK';
1488
1714
  await evidence.append({
1489
1715
  phase: 'postpublish-hook',
1490
1716
  hookId: hook.id,
@@ -1555,7 +1781,8 @@ export async function distributeRelease(options) {
1555
1781
  };
1556
1782
  cp.finishedAt = clockFn();
1557
1783
  failures += 1;
1558
- hooksStopped = true;
1784
+ stopped = true;
1785
+ stopReason = 'HOOK';
1559
1786
  await evidence.append({
1560
1787
  phase: 'postpublish-hook',
1561
1788
  hookId: hook.id,
@@ -1571,6 +1798,7 @@ export async function distributeRelease(options) {
1571
1798
  try {
1572
1799
  hookExecution = await hookRunner(
1573
1800
  {
1801
+ id: hook.id,
1574
1802
  command: hook.command,
1575
1803
  ...(hook.cwd ? { cwd: hook.cwd } : {}),
1576
1804
  ...(hook.timeoutMs !== undefined ? { timeoutMs: hook.timeoutMs } : {}),
@@ -1593,7 +1821,8 @@ export async function distributeRelease(options) {
1593
1821
  cp.error = { code, message: err?.message ?? String(err) };
1594
1822
  cp.finishedAt = clockFn();
1595
1823
  failures += 1;
1596
- hooksStopped = true;
1824
+ stopped = true;
1825
+ stopReason = 'HOOK';
1597
1826
  await evidence.append({
1598
1827
  phase: 'postpublish-hook',
1599
1828
  hookId: hook.id,
@@ -1612,7 +1841,8 @@ export async function distributeRelease(options) {
1612
1841
  };
1613
1842
  cp.finishedAt = clockFn();
1614
1843
  failures += 1;
1615
- hooksStopped = true;
1844
+ stopped = true;
1845
+ stopReason = 'HOOK';
1616
1846
  await evidence.append({
1617
1847
  phase: 'postpublish-hook',
1618
1848
  hookId: hook.id,
@@ -1632,6 +1862,11 @@ export async function distributeRelease(options) {
1632
1862
  await snapshot(PARTIAL);
1633
1863
  }
1634
1864
 
1865
+ // This declaration's scratch worktree is consumed; release it. The
1866
+ // outer finally keeps the safety net for a mid-declaration throw.
1867
+ await cleanupWorktree();
1868
+ }
1869
+
1635
1870
  // =======================================================================
1636
1871
  // Classification (returned, not thrown):
1637
1872
  // - DISTRIBUTED: no failures and no awaiting-approval hooks;
@@ -1675,14 +1910,20 @@ export async function distributeRelease(options) {
1675
1910
  } catch (err) {
1676
1911
  // The happy path returns before this catch, so `evidence.finish` can never
1677
1912
  // double-run here: it must ALWAYS close the evidence stream and seal the
1678
- // summary, even when failBlocked already persisted the BLOCKED record.
1913
+ // summary, even when failBlocked already persisted the failure record.
1914
+ // Rework R-01: the persisted status and the summary reflect the existing
1915
+ // success checkpoints — PARTIAL once one external checkpoint succeeded,
1916
+ // BLOCKED at zero external side effects.
1917
+ const catchStatus = !lineageKnown
1918
+ ? 'FAILED'
1919
+ : (pushedWrites + hookSuccesses) > 0 ? PARTIAL : BLOCKED;
1679
1920
  try {
1680
1921
  await evidence.append({
1681
1922
  phase: 'distribute',
1682
1923
  status: 'failed',
1683
1924
  error: { code: err.code, message: err.message },
1684
1925
  });
1685
- if (lineageKnown && !finalRecordWritten) await recordBlocked();
1926
+ if (lineageKnown && !finalRecordWritten) await recordFailure();
1686
1927
  } catch {
1687
1928
  // Persistence must never mask the primary failure.
1688
1929
  }
@@ -1691,7 +1932,7 @@ export async function distributeRelease(options) {
1691
1932
  });
1692
1933
  err.details = { ...err.details, recoveryActionCode };
1693
1934
  await evidence.finish({
1694
- status: lineageKnown ? BLOCKED : 'FAILED',
1935
+ status: catchStatus,
1695
1936
  recoveryActionCode,
1696
1937
  error: { code: err.code, message: err.message, details: err.details },
1697
1938
  details: { recoveryActionCode },