release-skill 0.6.2 → 0.7.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 (112) 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 +48 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/INSTALL.md +47 -2
  9. package/INSTALL.zh-CN.md +29 -2
  10. package/README.md +137 -12
  11. package/README.zh-CN.md +110 -9
  12. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  13. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  14. package/adapters/claude/bin/error-codes.json +1 -1
  15. package/adapters/claude/bin/kernel-protocol.json +1 -1
  16. package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
  17. package/adapters/claude/bin/license-texts/MIT.txt +21 -0
  18. package/adapters/claude/bin/registry.json +64 -1
  19. package/adapters/claude/bin/release-skill.bundle.mjs +50570 -42208
  20. package/adapters/claude/bin/rules.json +1 -1
  21. package/adapters/claude/schemas/.render-manifest.json +10 -6
  22. package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
  23. package/adapters/claude/schemas/release-plan.schema.json +200 -4
  24. package/adapters/claude/schemas/release-project.schema.json +86 -5
  25. package/adapters/claude/schemas/release-run.schema.json +11 -6
  26. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  27. package/adapters/codex/bin/error-codes.json +1 -1
  28. package/adapters/codex/bin/kernel-protocol.json +1 -1
  29. package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
  30. package/adapters/codex/bin/license-texts/MIT.txt +21 -0
  31. package/adapters/codex/bin/registry.json +64 -1
  32. package/adapters/codex/bin/release-skill.bundle.mjs +50570 -42208
  33. package/adapters/codex/bin/rules.json +1 -1
  34. package/adapters/codex/schemas/.render-manifest.json +10 -6
  35. package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
  36. package/adapters/codex/schemas/release-plan.schema.json +200 -4
  37. package/adapters/codex/schemas/release-project.schema.json +86 -5
  38. package/adapters/codex/schemas/release-run.schema.json +11 -6
  39. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  40. package/adapters/kimi/bin/error-codes.json +1 -1
  41. package/adapters/kimi/bin/kernel-protocol.json +1 -1
  42. package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
  43. package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
  44. package/adapters/kimi/bin/registry.json +64 -1
  45. package/adapters/kimi/bin/release-skill.bundle.mjs +50570 -42208
  46. package/adapters/kimi/bin/rules.json +1 -1
  47. package/adapters/kimi/schemas/.render-manifest.json +10 -6
  48. package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
  49. package/adapters/kimi/schemas/release-plan.schema.json +200 -4
  50. package/adapters/kimi/schemas/release-project.schema.json +86 -5
  51. package/adapters/kimi/schemas/release-run.schema.json +11 -6
  52. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  53. package/adapters/workbuddy/bin/error-codes.json +1 -1
  54. package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
  55. package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
  56. package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
  57. package/adapters/workbuddy/bin/registry.json +64 -1
  58. package/adapters/workbuddy/bin/release-skill.bundle.mjs +50570 -42208
  59. package/adapters/workbuddy/bin/rules.json +1 -1
  60. package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
  61. package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
  62. package/adapters/workbuddy/schemas/release-plan.schema.json +200 -4
  63. package/adapters/workbuddy/schemas/release-project.schema.json +86 -5
  64. package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
  65. package/bin/error-codes.json +1 -1
  66. package/bin/kernel-protocol.json +1 -1
  67. package/bin/license-texts/Apache-2.0.txt +201 -0
  68. package/bin/license-texts/MIT.txt +21 -0
  69. package/bin/registry.json +64 -1
  70. package/bin/release-skill-cli.mjs +187 -9
  71. package/bin/release-skill.bundle.mjs +50570 -42208
  72. package/bin/rules.json +1 -1
  73. package/package.json +6 -4
  74. package/platform-manifest.json +4 -4
  75. package/references/.render-manifest.json +5 -5
  76. package/references/01-state-machine.md +22 -2
  77. package/references/02-project-config.md +1 -1
  78. package/references/06-adapter-contract.md +6 -3
  79. package/schemas/.render-manifest.json +10 -6
  80. package/schemas/postpublish-approval-record.schema.json +47 -0
  81. package/schemas/release-plan.schema.json +200 -4
  82. package/schemas/release-project.schema.json +86 -5
  83. package/schemas/release-run.schema.json +11 -6
  84. package/src/adapters/distribute-git.mjs +17 -11
  85. package/src/commands/approve.mjs +163 -1
  86. package/src/commands/distribute.mjs +649 -89
  87. package/src/commands/postverify.mjs +808 -0
  88. package/src/commands/prepare.mjs +456 -45
  89. package/src/commands/setup.mjs +715 -0
  90. package/src/commands/ship.mjs +152 -5
  91. package/src/commands/verify.mjs +92 -15
  92. package/src/core/approval.mjs +93 -68
  93. package/src/core/bounded-output.mjs +46 -0
  94. package/src/core/derived-artifact-gates.mjs +258 -0
  95. package/src/core/docs-refresh-preset.mjs +171 -0
  96. package/src/core/errors.mjs +4 -0
  97. package/src/core/evidence.mjs +8 -1
  98. package/src/core/git-url-policy.mjs +214 -0
  99. package/src/core/hooks.mjs +28 -0
  100. package/src/core/marketplace-registry-entry.mjs +175 -0
  101. package/src/core/notify-handoff.mjs +76 -0
  102. package/src/core/postpublish-approval.mjs +241 -0
  103. package/src/core/postpublish-bundle.mjs +342 -0
  104. package/src/core/postpublish-projection.mjs +210 -0
  105. package/src/core/postpublish.mjs +449 -25
  106. package/src/core/preset-executor.mjs +175 -0
  107. package/src/core/preset-gitwrite.mjs +484 -0
  108. package/src/core/presets.mjs +754 -0
  109. package/src/core/proposal-inbox.mjs +646 -0
  110. package/src/core/redact.mjs +16 -3
  111. package/src/core/run.mjs +91 -6
  112. package/src/producers/build-adapters.mjs +28 -14
@@ -7,9 +7,33 @@
7
7
  *
8
8
  * Timing contract (R1): the payload may ONLY ever come from a detached git
9
9
  * worktree checked out at the frozen `postPublish.tagCommit` — never from
10
- * the live workspace, which has already moved ahead. The consumer-declared
11
- * materialize hook runs inside that worktree and announces the isolated
12
- * payload directory through its `outputMarker` line.
10
+ * the live workspace, which has already moved ahead. Since F-06 / T6 the
11
+ * frozen plan selects exactly one of two staging routes:
12
+ * - a consumer materialize hook (when declared): it runs inside that
13
+ * worktree and announces the isolated payload directory through its
14
+ * `outputMarker` line;
15
+ * - the Foundation managed projection (when the plan declares no materialize
16
+ * hook): the frozen `postPublish.executionBundle.publicFiles` mapping is
17
+ * staged from the tag worktree into a fresh `hub-payload` root through the
18
+ * Engineering Kit `compileProjectionPlan`/`runProjection` contract —
19
+ * never by live project configuration and never by a parent-workspace
20
+ * script.
21
+ *
22
+ * Private execution bundle (F-01 / T1): consumer-declared hook commands
23
+ * (materialize, steps, custom distribute-phase hooks) may reference scripts
24
+ * by workspace-relative paths that the frozen tag tree never contains —
25
+ * parent-workspace tooling never leaks into the public surface. prepare
26
+ * freezes those files (closed-world `executionFiles` manifest) into the
27
+ * plan's `postPublish.executionBundle` and publishes the bytes
28
+ * digest-addressed under the plan's `.release-skill`. Before ANY hook runs,
29
+ * distribute strictly re-reads the bundle bytes, recomputes the closure
30
+ * through Foundation, and installs ONLY the verified bytes into the tag
31
+ * worktree (never overwriting tag files). Live-workspace copying and the
32
+ * RELEASE_SKILL_WORKSPACE_ROOT injection are gone: after the freeze, the
33
+ * workspace copies are never read back. The payload timing contract is
34
+ * untouched: payload content must still be produced from the frozen
35
+ * checkout and the announced payload directory must stay inside the
36
+ * worktree.
13
37
  *
14
38
  * Safety gates (all verified before any adapter execute):
15
39
  * 1. plan schema validation
@@ -61,11 +85,21 @@ import {
61
85
  } from '../core/run.mjs';
62
86
  import { createEvidenceWriter } from '../core/evidence.mjs';
63
87
  import { runHook } from '../core/hooks.mjs';
88
+ import { boundedOutputTail } from '../core/bounded-output.mjs';
64
89
  import {
65
90
  orderTargetsByDependency,
66
91
  validatePostPublishDeclaration,
92
+ buildPostPublishContext,
93
+ normalizePostPublishDeclaration,
94
+ orderNormalizedHooks,
95
+ effectiveHookRequiresApproval,
96
+ POSTPUBLISH_CONTEXT_ENV,
67
97
  PAYLOAD_SOURCE_TAG_WORKTREE,
68
98
  } from '../core/postpublish.mjs';
99
+ import { verifyAndInstallExecutionBundle } from '../core/postpublish-bundle.mjs';
100
+ import { projectPublicPayload, PROJECTION_MECHANISM, PUBLIC_PAYLOAD_DIRNAME } from '../core/postpublish-projection.mjs';
101
+ import { assertPostPublishApprovalAuthority, validatePostPublishApproval } from '../core/postpublish-approval.mjs';
102
+ import { executePresetHook } from '../core/preset-executor.mjs';
69
103
  import {
70
104
  ReleaseError,
71
105
  GATE_FAILED,
@@ -80,9 +114,6 @@ const execFileAsync = promisify(execFileCb);
80
114
  /** Executor identity recorded in every distribute checkpoint trace. */
81
115
  const EXECUTOR = 'release-skill distribute';
82
116
 
83
- /** Tail length for hook stdout/stderr recorded in evidence. */
84
- const TAIL_CHARS = 4000;
85
-
86
117
  /** Full 40-hex commit sha. */
87
118
  const SHA_RE = /^[a-f0-9]{40}$/;
88
119
 
@@ -105,6 +136,7 @@ const DISTRIBUTING = 'DISTRIBUTING';
105
136
  const DISTRIBUTED = 'DISTRIBUTED';
106
137
  const PARTIAL = 'PARTIAL';
107
138
  const BLOCKED = 'BLOCKED';
139
+ const NEEDS_INPUT = 'NEEDS_INPUT';
108
140
 
109
141
  function defaultClock() {
110
142
  return new Date().toISOString();
@@ -114,11 +146,6 @@ function defaultExec(command, args, options = {}) {
114
146
  return execFileAsync(command, args, { shell: false, encoding: 'utf8', timeout: 120_000, ...options });
115
147
  }
116
148
 
117
- function tail(text, limit = TAIL_CHARS) {
118
- const value = `${text ?? ''}`;
119
- return value.length > limit ? value.slice(-limit) : value;
120
- }
121
-
122
149
  /** Map an adapter/details error code onto the run-schema checkpoint enum. */
123
150
  function mapToSchemaCode(code) {
124
151
  return SCHEMA_ERROR_CODES.has(code) ? code : GATE_FAILED;
@@ -230,6 +257,11 @@ function resolvePluginName(plan, unitId) {
230
257
  * @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
231
258
  * @param {Function} [options.execFn] - Injectable git exec (tests).
232
259
  * @param {Function} [options.runHookFn] - Injectable hook runner (tests).
260
+ * @param {string[]} [options.postpublishApprovalPaths] - Checkpoint approval
261
+ * records for requiresApproval postPublish hooks (v0.6.3 R1); each record
262
+ * binds (planDigest, hookId), must be consumed from the immutable
263
+ * digest-addressed authority minted by approvePostPublishHook (F-02), and
264
+ * is validated fail-closed before any write.
233
265
  *
234
266
  * @returns {Promise<{ planPath: string, runPath: string, status: string, checkpoints: Object[] }>}
235
267
  *
@@ -248,6 +280,7 @@ export async function distributeRelease(options) {
248
280
  clock: clockOpt,
249
281
  execFn,
250
282
  runHookFn,
283
+ postpublishApprovalPaths,
251
284
  } = options ?? {};
252
285
 
253
286
  const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
@@ -407,7 +440,16 @@ export async function distributeRelease(options) {
407
440
  ?? computeApprovalDigest(approvalRaw);
408
441
 
409
442
  validateApprovalRecordSchema(approval);
410
- validateApproval(plan, approval, { clock: clockFn });
443
+ // distribute is a post-publish phase: like reconcile/verify it validates
444
+ // the approval binding (planDigest/approvedActions/digest) without
445
+ // requiring an unexpired window — distribution may legitimately happen
446
+ // after the 24h publish-approval window. requiresApproval hook approvals
447
+ // remain expiry-enforced by validatePostPublishApproval.
448
+ // requireUnexpired: false aligns with reconcile.mjs:321 and
449
+ // verify.mjs:1098; the binding checks (plan digest, approved actions,
450
+ // approval digest) are NOT relaxed here — only the expiry window is
451
+ // waived for the post-publish phase.
452
+ validateApproval(plan, approval, { clock: clockFn, requireUnexpired: false });
411
453
 
412
454
  await evidence.append({ phase: 'safety-gate', gate: 'approval-validated', status: 'passed' });
413
455
 
@@ -448,12 +490,127 @@ export async function distributeRelease(options) {
448
490
 
449
491
  // =======================================================================
450
492
  // Declaration re-validation + deterministic target ordering.
493
+ // R2: preset references resolve against the built-in preset registry
494
+ // (core/presets.mjs); per-preset config validation (dual addressing,
495
+ // marketplace/staticFiles shapes, secret scan) fails closed here.
451
496
  // =======================================================================
452
497
  const postPublish = plan.postPublish;
453
498
  validatePostPublishDeclaration(postPublish, { unitId: postPublish.unitId });
454
- const orderedTargets = orderTargetsByDependency(postPublish.targets);
499
+ const orderedTargets = orderTargetsByDependency(postPublish.targets ?? []);
500
+
501
+ // Normalized hook table (design §2.2): every targets[] entry maps onto a
502
+ // preset hook (payload-mirror -> git-mirror, marketplace-index ->
503
+ // marketplace-index-render); the table is a deterministic projection of
504
+ // the digest-bound declaration, ordered by dependency topology +
505
+ // declaration order. Target execution below keeps the exact legacy
506
+ // semantics; hooks[] preset execution dispatches in the hook loop
507
+ // (proposal-inbox git-push is wired; other presets fail closed until
508
+ // their behavior ships).
509
+ const normalizedDeclaration = normalizePostPublishDeclaration(postPublish);
510
+ const orderedNormalizedHooks = orderNormalizedHooks(normalizedDeclaration.hooks);
511
+ await evidence.append({
512
+ phase: 'postpublish-normalization',
513
+ status: 'passed',
514
+ preGates: normalizedDeclaration.preGates.map((gate) => gate.gate),
515
+ hookCount: orderedNormalizedHooks.length,
516
+ hookIds: orderedNormalizedHooks.map((hook) => hook.id),
517
+ });
518
+
519
+ // postPublish hooks (v0.6.3 R1): distribute-phase hooks run in this saga;
520
+ // postVerify-phase hooks belong to the independent postVerify run (R3)
521
+ // and are only evidenced here — never executed, never silent.
522
+ const declaredHooks = postPublish.hooks ?? [];
523
+ const distributeHooks = declaredHooks.filter((hook) => (hook.phase ?? 'distribute') === 'distribute');
524
+ const deferredPostVerifyHooks = declaredHooks.length - distributeHooks.length;
525
+
526
+ /** Gate failure after lineage is known: persist BLOCKED, then rethrow. */
527
+ const failBlocked = async (error) => {
528
+ await recordBlocked();
529
+ throw error;
530
+ };
531
+
532
+ // =======================================================================
533
+ // Gate: checkpoint approvals for requiresApproval hooks. Every provided
534
+ // record is validated fail-closed BEFORE any write: first the immutable
535
+ // authority binding (F-02: the consumption path must BE the digest-
536
+ // addressed authority minted by approvePostPublishHook — recomputed
537
+ // planDigest directory, recomputed approvalDigest file name, strict
538
+ // no-follow regular-file read, no symlinked ancestor), then the content
539
+ // checks (schema, planDigest binding, declared hook, requiresApproval,
540
+ // 24h window, expiry). A bad approval aborts the whole saga; a missing
541
+ // one parks the hook at AWAITING_APPROVAL.
542
+ // =======================================================================
543
+ const approvedHookIds = new Set();
544
+ const hookApprovalPaths = postpublishApprovalPaths ?? [];
545
+ if (hookApprovalPaths.length > 0) {
546
+ await evidence.append({
547
+ phase: 'safety-gate',
548
+ gate: 'postpublish-hook-approvals',
549
+ status: 'started',
550
+ approvalCount: hookApprovalPaths.length,
551
+ });
552
+ for (const hookApprovalPath of hookApprovalPaths) {
553
+ let hookApprovalRaw;
554
+ try {
555
+ hookApprovalRaw = await readFile(hookApprovalPath, 'utf8');
556
+ } catch (err) {
557
+ await failBlocked(new ReleaseError(
558
+ GATE_FAILED,
559
+ `cannot read postpublish hook approval: ${err.message}`,
560
+ { hookApprovalPath, cause: err.code },
561
+ ));
562
+ }
563
+ let hookApproval;
564
+ try {
565
+ hookApproval = JSON.parse(hookApprovalRaw);
566
+ } catch (err) {
567
+ await failBlocked(new ReleaseError(
568
+ GATE_FAILED,
569
+ `postpublish hook approval is not valid JSON: ${err.message}`,
570
+ { hookApprovalPath },
571
+ ));
572
+ }
573
+ // F-02: identical bytes anywhere else are not an approval. The
574
+ // authority assertion runs before content validation and before the
575
+ // hook may enter approvedHookIds.
576
+ try {
577
+ await assertPostPublishApprovalAuthority(planPath, hookApprovalPath, plan, hookApprovalRaw);
578
+ } catch (err) {
579
+ await failBlocked(err instanceof ReleaseError ? err : new ReleaseError(
580
+ GATE_FAILED,
581
+ `postpublish hook approval authority check failed: ${err?.message ?? err}`,
582
+ { hookApprovalPath },
583
+ ));
584
+ }
585
+ validatePostPublishApproval(plan, hookApproval, { clock: clockFn });
586
+ if (approvedHookIds.has(hookApproval.hookId)) {
587
+ await failBlocked(new ReleaseError(
588
+ GATE_FAILED,
589
+ `duplicate postpublish hook approvals for hook "${hookApproval.hookId}"`,
590
+ { hookId: hookApproval.hookId },
591
+ ));
592
+ }
593
+ approvedHookIds.add(hookApproval.hookId);
594
+ }
595
+ await evidence.append({
596
+ phase: 'safety-gate',
597
+ gate: 'postpublish-hook-approvals',
598
+ status: 'passed',
599
+ approvedHookIds: [...approvedHookIds],
600
+ });
601
+ }
602
+
603
+ // Hooks whose checkpoint approval is still missing. While any exist, the
604
+ // declared postPublish steps (unaudited project code) must not execute:
605
+ // the run parks at NEEDS_INPUT/PARTIAL and the approved reconcile rerun
606
+ // re-executes them. Targets remain plan-approval-authorized idempotent
607
+ // remote-state convergence and are unaffected.
608
+ const pendingHookApprovals = distributeHooks.filter(
609
+ (hook) => effectiveHookRequiresApproval(hook) && !approvedHookIds.has(hook.id),
610
+ );
455
611
 
456
- // Checkpoint registry: one probe + one mirror per target, declared order.
612
+ // Checkpoint registry: one probe + one mirror per target, declared order,
613
+ // then one postpublish-hook checkpoint per distribute-phase hook.
457
614
  checkpoints = [];
458
615
  for (const target of orderedTargets) {
459
616
  checkpoints.push({
@@ -478,17 +635,19 @@ export async function distributeRelease(options) {
478
635
  executor: EXECUTOR,
479
636
  });
480
637
  }
638
+ for (const hook of distributeHooks) {
639
+ checkpoints.push({
640
+ actionId: hook.id,
641
+ actionType: 'postpublish-hook',
642
+ status: 'PENDING',
643
+ executor: EXECUTOR,
644
+ });
645
+ }
481
646
  const checkpointById = new Map(checkpoints.map((cp) => [cp.actionId, cp]));
482
647
 
483
648
  // Durable pre-execute authority (seq 0).
484
649
  await snapshot(DISTRIBUTING);
485
650
 
486
- /** Gate failure after lineage is known: persist BLOCKED, then rethrow. */
487
- const failBlocked = async (error) => {
488
- await recordBlocked();
489
- throw error;
490
- };
491
-
492
651
  // =======================================================================
493
652
  // Gate 4: tag identity — the live tag must still point at the frozen
494
653
  // tagCommit. A missing binding or a moved tag fails closed.
@@ -518,7 +677,7 @@ export async function distributeRelease(options) {
518
677
  phase: 'safety-gate',
519
678
  gate: 'tag-identity',
520
679
  status: 'failed',
521
- error: tail(err?.stderr ?? err?.message),
680
+ error: boundedOutputTail(err?.stderr ?? err?.message),
522
681
  });
523
682
  await failBlocked(new ReleaseError(
524
683
  GATE_FAILED,
@@ -663,7 +822,7 @@ export async function distributeRelease(options) {
663
822
  worktreePath = join(tmpBase, 'worktree');
664
823
  await exec('git', ['-C', root, 'worktree', 'add', '--detach', worktreePath, postPublish.tagCommit]);
665
824
  } catch (err) {
666
- await evidence.append({ phase: 'worktree', status: 'failed', error: tail(err?.stderr ?? err?.message) });
825
+ await evidence.append({ phase: 'worktree', status: 'failed', error: boundedOutputTail(err?.stderr ?? err?.message) });
667
826
  await failBlocked(new ReleaseError(
668
827
  GATE_FAILED,
669
828
  `cannot create the detached tag worktree at ${postPublish.tagCommit}: ${err?.message ?? err}`,
@@ -673,99 +832,243 @@ export async function distributeRelease(options) {
673
832
  await evidence.append({ phase: 'worktree', status: 'passed' });
674
833
 
675
834
  // =======================================================================
676
- // Materialize: run the consumer hook inside the tag worktree, verify its
677
- // report, and bind the announced payload directory (fail-closed).
835
+ // Private execution bundle (F-01 / T1): consumer-declared commands may
836
+ // reference scripts that exist only in the parent workspace (tooling
837
+ // never leaks into the frozen public surface). prepare froze those bytes
838
+ // digest-addressed under the plan's .release-skill; re-verify them
839
+ // through Foundation and install ONLY the verified bytes into the tag
840
+ // worktree before any hook runs. Any mismatch fails closed here —
841
+ // before any hook and before any external write.
678
842
  // =======================================================================
679
- await evidence.append({ phase: 'materialize', status: 'started' });
680
-
681
- const materialize = postPublish.materialize;
682
- const hookResult = await hookRunner(
683
- {
684
- command: materialize.command,
685
- ...(materialize.cwd ? { cwd: materialize.cwd } : {}),
686
- ...(materialize.timeoutMs !== undefined ? { timeoutMs: materialize.timeoutMs } : {}),
687
- ...(materialize.envAllowlist ? { envAllowlist: materialize.envAllowlist } : {}),
688
- },
689
- { root: worktreePath, env: process.env },
690
- );
691
-
692
- if (hookResult.exitCode !== 0) {
843
+ // =======================================================================
844
+ // F-04 root split: the saga holds TWO distinct roots from here on —
845
+ // - releaseWorkspaceRoot: the real project root the user releases from;
846
+ // only used to resolve preset target.workspace, compare the release-
847
+ // workspace write exclusion, and audit;
848
+ // - executionWorktreeRoot (worktreePath): the detached worktree at the
849
+ // frozen tagCommit; only used as the hook runner context.root for
850
+ // materialize, steps and custom command hooks.
851
+ // The two roots never fall back onto each other through defaults.
852
+ // =======================================================================
853
+ let releaseWorkspaceRoot;
854
+ try {
855
+ releaseWorkspaceRoot = await realpath(root);
856
+ } catch (err) {
857
+ await failBlocked(new ReleaseError(
858
+ GATE_FAILED,
859
+ `release workspace root does not resolve to an existing directory: ${err.message}`,
860
+ { root, cause: err.code },
861
+ ));
862
+ }
863
+ let installedBundlePaths = [];
864
+ try {
865
+ ({ installed: installedBundlePaths } = await verifyAndInstallExecutionBundle({
866
+ plan,
867
+ planPath,
868
+ worktreePath,
869
+ }));
870
+ } catch (err) {
693
871
  await evidence.append({
694
- phase: 'materialize',
872
+ phase: 'worktree',
873
+ gate: 'execution-bundle',
695
874
  status: 'failed',
696
- exitCode: hookResult.exitCode,
697
- stdoutTail: tail(hookResult.stdout),
698
- stderrTail: tail(hookResult.stderr),
875
+ error: boundedOutputTail(err?.message ?? String(err)),
699
876
  });
700
- await failBlocked(new ReleaseError(
701
- POST_PUBLISH_VERIFY_FAILED,
702
- `materialize hook exited with code ${hookResult.exitCode}; payload cannot be trusted`,
703
- { exitCode: hookResult.exitCode, stdoutTail: tail(hookResult.stdout), stderrTail: tail(hookResult.stderr) },
877
+ await failBlocked(err instanceof ReleaseError ? err : new ReleaseError(
878
+ GATE_FAILED,
879
+ `cannot verify the frozen execution bundle: ${err?.message ?? err}`,
704
880
  ));
705
881
  }
882
+ await evidence.append({
883
+ phase: 'worktree',
884
+ gate: 'execution-bundle',
885
+ status: 'passed',
886
+ installed: installedBundlePaths,
887
+ });
888
+
889
+ // =======================================================================
890
+ // Payload staging (fail-closed). The frozen plan selects exactly one
891
+ // route (schema anyOf + runtime re-check):
892
+ // - consumer materialize hook: run it inside the tag worktree, verify
893
+ // its report, and bind the announced payload directory;
894
+ // - Foundation managed projection (F-06 / T6): when the plan declares
895
+ // no materialize hook, the frozen executionBundle.publicFiles mapping
896
+ // is staged from the tag worktree into a fresh hub-payload root by
897
+ // the Engineering Kit compileProjectionPlan/runProjection contract.
898
+ // The saga supplies ONLY the release-domain parameters (what is
899
+ // projected, where the frozen bytes live, where the payload lands);
900
+ // every preflight, containment check, transactional write, and
901
+ // rollback belongs to Foundation and is never reimplemented here.
902
+ // =======================================================================
903
+ let payloadReal;
904
+ const materialize = postPublish.materialize;
905
+
906
+ if (materialize !== undefined) {
907
+ await evidence.append({ phase: 'materialize', status: 'started' });
706
908
 
707
- // requireReport: stdout-first-json deep compare (declared equals subset).
708
- if (materialize.requireReport) {
709
- const report = parseFirstJsonObject(hookResult.stdout);
710
- if (!report) {
909
+ const hookResult = await hookRunner(
910
+ {
911
+ command: materialize.command,
912
+ ...(materialize.cwd ? { cwd: materialize.cwd } : {}),
913
+ ...(materialize.timeoutMs !== undefined ? { timeoutMs: materialize.timeoutMs } : {}),
914
+ ...(materialize.envAllowlist ? { envAllowlist: materialize.envAllowlist } : {}),
915
+ },
916
+ {
917
+ // F-04: materialize runs in the execution worktree, never in the
918
+ // release workspace. Private workspace-side inputs reach the hook
919
+ // exclusively through the frozen execution bundle installed above —
920
+ // no live workspace root is announced anymore (F-01 / T1).
921
+ root: worktreePath,
922
+ env: process.env,
923
+ },
924
+ );
925
+
926
+ if (hookResult.exitCode !== 0) {
711
927
  await evidence.append({
712
928
  phase: 'materialize',
713
929
  status: 'failed',
714
- reason: 'report-missing',
715
- stdoutTail: tail(hookResult.stdout),
930
+ exitCode: hookResult.exitCode,
931
+ stdoutTail: boundedOutputTail(hookResult.stdout),
932
+ stderrTail: boundedOutputTail(hookResult.stderr),
716
933
  });
717
934
  await failBlocked(new ReleaseError(
718
935
  POST_PUBLISH_VERIFY_FAILED,
719
- 'materialize report missing: no JSON object found on stdout (requireReport.parse = stdout-first-json)',
720
- { stdoutTail: tail(hookResult.stdout) },
936
+ `materialize hook exited with code ${hookResult.exitCode}; payload cannot be trusted`,
937
+ { exitCode: hookResult.exitCode, stdoutTail: boundedOutputTail(hookResult.stdout), stderrTail: boundedOutputTail(hookResult.stderr) },
721
938
  ));
722
939
  }
723
- const equals = materialize.requireReport.equals ?? {};
724
- for (const [key, expectedValue] of Object.entries(equals)) {
725
- if (JSON.stringify(report[key]) !== JSON.stringify(expectedValue)) {
940
+
941
+ // requireReport: stdout-first-json deep compare (declared equals subset).
942
+ if (materialize.requireReport) {
943
+ const report = parseFirstJsonObject(hookResult.stdout);
944
+ if (!report) {
726
945
  await evidence.append({
727
946
  phase: 'materialize',
728
947
  status: 'failed',
729
- reason: 'report-mismatch',
730
- mismatchedKeys: Object.keys(equals).filter(
731
- (k) => JSON.stringify(report[k]) !== JSON.stringify(equals[k]),
732
- ),
948
+ reason: 'report-missing',
949
+ stdoutTail: boundedOutputTail(hookResult.stdout),
733
950
  });
734
951
  await failBlocked(new ReleaseError(
735
952
  POST_PUBLISH_VERIFY_FAILED,
736
- `materialize report mismatch: "${key}" did not meet the frozen requireReport contract`,
737
- { mismatchedKey: key },
953
+ 'materialize report missing: no JSON object found on stdout (requireReport.parse = stdout-first-json)',
954
+ { stdoutTail: boundedOutputTail(hookResult.stdout) },
738
955
  ));
739
956
  }
957
+ const equals = materialize.requireReport.equals ?? {};
958
+ for (const [key, expectedValue] of Object.entries(equals)) {
959
+ if (JSON.stringify(report[key]) !== JSON.stringify(expectedValue)) {
960
+ await evidence.append({
961
+ phase: 'materialize',
962
+ status: 'failed',
963
+ reason: 'report-mismatch',
964
+ mismatchedKeys: Object.keys(equals).filter(
965
+ (k) => JSON.stringify(report[k]) !== JSON.stringify(equals[k]),
966
+ ),
967
+ });
968
+ await failBlocked(new ReleaseError(
969
+ POST_PUBLISH_VERIFY_FAILED,
970
+ `materialize report mismatch: "${key}" did not meet the frozen requireReport contract`,
971
+ { mismatchedKey: key },
972
+ ));
973
+ }
974
+ }
740
975
  }
741
- }
742
976
 
743
- // outputMarker: the LAST line containing the marker announces the payload.
744
- const announced = parseOutputMarker(hookResult.stdout, materialize.outputMarker);
745
- if (!announced) {
977
+ // outputMarker: the LAST line containing the marker announces the payload.
978
+ const announced = parseOutputMarker(hookResult.stdout, materialize.outputMarker);
979
+ if (!announced) {
980
+ await evidence.append({
981
+ phase: 'materialize',
982
+ status: 'failed',
983
+ reason: 'marker-missing',
984
+ stdoutTail: boundedOutputTail(hookResult.stdout),
985
+ });
986
+ await failBlocked(new ReleaseError(
987
+ POST_PUBLISH_VERIFY_FAILED,
988
+ `materialize output marker "${materialize.outputMarker}" not found on stdout; payload directory unbound`,
989
+ { stdoutTail: boundedOutputTail(hookResult.stdout) },
990
+ ));
991
+ }
992
+ payloadReal = await assertContainedDirectory(
993
+ worktreePath,
994
+ resolve(worktreePath, announced),
995
+ 'materialized payload directory',
996
+ );
997
+ await evidence.append({ phase: 'materialize', status: 'passed', payloadDirAnnounced: announced });
998
+ } else {
999
+ await evidence.append({ phase: 'materialize', status: 'started', mechanism: PROJECTION_MECHANISM });
1000
+
1001
+ // Release-domain parameter selection ONLY: the frozen mapping rides the
1002
+ // plan digest; live project configuration is never read here.
1003
+ const publicFiles = postPublish.executionBundle?.publicFiles;
1004
+ if (!Array.isArray(publicFiles) || publicFiles.length === 0) {
1005
+ await evidence.append({
1006
+ phase: 'materialize',
1007
+ status: 'failed',
1008
+ mechanism: PROJECTION_MECHANISM,
1009
+ reason: 'public-files-missing',
1010
+ });
1011
+ await failBlocked(new ReleaseError(
1012
+ POST_PUBLISH_VERIFY_FAILED,
1013
+ 'public payload projection: the frozen plan declares no materialize hook and carries no non-empty executionBundle.publicFiles mapping; the payload cannot be staged',
1014
+ ));
1015
+ }
1016
+
1017
+ // Disposable candidate staging root, external to the payload root and
1018
+ // owned by the saga (cleaned up with tmpBase).
1019
+ const candidateRoot = join(tmpBase, 'projection-candidate');
1020
+ try {
1021
+ await mkdir(candidateRoot, { recursive: true });
1022
+ const projected = await projectPublicPayload({
1023
+ executionWorktreeRoot: worktreePath,
1024
+ candidateRoot,
1025
+ publicFiles,
1026
+ });
1027
+ payloadReal = projected.payloadRoot;
1028
+ } catch (err) {
1029
+ await evidence.append({
1030
+ phase: 'materialize',
1031
+ status: 'failed',
1032
+ mechanism: PROJECTION_MECHANISM,
1033
+ error: boundedOutputTail(err?.message ?? String(err)),
1034
+ });
1035
+ await failBlocked(err instanceof ReleaseError ? err : new ReleaseError(
1036
+ POST_PUBLISH_VERIFY_FAILED,
1037
+ `public payload projection failed: ${err?.message ?? err}`,
1038
+ ));
1039
+ }
746
1040
  await evidence.append({
747
1041
  phase: 'materialize',
748
- status: 'failed',
749
- reason: 'marker-missing',
750
- stdoutTail: tail(hookResult.stdout),
1042
+ status: 'passed',
1043
+ mechanism: PROJECTION_MECHANISM,
1044
+ payloadDir: PUBLIC_PAYLOAD_DIRNAME,
1045
+ fileCount: publicFiles.length,
751
1046
  });
752
- await failBlocked(new ReleaseError(
753
- POST_PUBLISH_VERIFY_FAILED,
754
- `materialize output marker "${materialize.outputMarker}" not found on stdout; payload directory unbound`,
755
- { stdoutTail: tail(hookResult.stdout) },
756
- ));
757
1047
  }
758
- const payloadReal = await assertContainedDirectory(
759
- worktreePath,
760
- resolve(worktreePath, announced),
761
- 'materialized payload directory',
762
- );
763
- await evidence.append({ phase: 'materialize', status: 'passed', payloadDirAnnounced: announced });
764
1048
 
765
1049
  // =======================================================================
766
1050
  // Declared postPublish steps, in order (fail-closed).
767
1051
  // =======================================================================
768
1052
  for (const step of postPublish.steps ?? []) {
1053
+ if (dryRun === true) {
1054
+ // R1 dry-run contract: steps are arbitrary project code with
1055
+ // potentially remote side effects; they never execute in a rehearsal.
1056
+ await evidence.append({ phase: 'postpublish-step', step: step.name, status: 'skipped', reason: 'DRY_RUN' });
1057
+ continue;
1058
+ }
1059
+ if (pendingHookApprovals.length > 0) {
1060
+ // A requiresApproval hook is still unapproved: the step pipeline must
1061
+ // not execute before the checkpoint approval exists; the approved
1062
+ // reconcile rerun re-executes the steps (idempotence-by-default).
1063
+ await evidence.append({
1064
+ phase: 'postpublish-step',
1065
+ step: step.name,
1066
+ status: 'skipped',
1067
+ reason: 'AWAITING_CHECKPOINT_APPROVAL',
1068
+ pendingHookApprovals: pendingHookApprovals.map((hook) => hook.id),
1069
+ });
1070
+ continue;
1071
+ }
769
1072
  await evidence.append({ phase: 'postpublish-step', step: step.name, status: 'started' });
770
1073
  const stepResult = await hookRunner(
771
1074
  {
@@ -774,7 +1077,12 @@ export async function distributeRelease(options) {
774
1077
  ...(step.timeoutMs !== undefined ? { timeoutMs: step.timeoutMs } : {}),
775
1078
  ...(step.envAllowlist ? { envAllowlist: step.envAllowlist } : {}),
776
1079
  },
777
- { root: worktreePath, env: process.env },
1080
+ {
1081
+ // F-04: steps run in the execution worktree (see materialize).
1082
+ // Private inputs arrive via the frozen execution bundle only.
1083
+ root: worktreePath,
1084
+ env: process.env,
1085
+ },
778
1086
  );
779
1087
  if (stepResult.exitCode !== 0) {
780
1088
  await evidence.append({
@@ -782,8 +1090,8 @@ export async function distributeRelease(options) {
782
1090
  step: step.name,
783
1091
  status: 'failed',
784
1092
  exitCode: stepResult.exitCode,
785
- stdoutTail: tail(stepResult.stdout),
786
- stderrTail: tail(stepResult.stderr),
1093
+ stdoutTail: boundedOutputTail(stepResult.stdout),
1094
+ stderrTail: boundedOutputTail(stepResult.stderr),
787
1095
  });
788
1096
  await failBlocked(new ReleaseError(
789
1097
  GATE_FAILED,
@@ -1022,15 +1330,267 @@ export async function distributeRelease(options) {
1022
1330
  }
1023
1331
 
1024
1332
  // =======================================================================
1025
- // Classification: DISTRIBUTED | PARTIAL | BLOCKED (returned, not thrown).
1333
+ // postPublish hooks (distribute phase), executed AFTER the target writes.
1334
+ // Contract (design §2.3): the read-only frozen-plan projection travels via
1335
+ // RELEASE_SKILL_POSTPUBLISH_CONTEXT; hooks run inside the frozen tag
1336
+ // worktree; a failure stops the hook chain; a requiresApproval hook
1337
+ // without a checkpoint approval parks at AWAITING_APPROVAL and never
1338
+ // executes; dry-run executes nothing; postVerify-phase hooks belong to
1339
+ // the independent postVerify run (R3) and are evidenced as deferred.
1340
+ // =======================================================================
1341
+ if (deferredPostVerifyHooks > 0) {
1342
+ await evidence.append({ phase: 'postpublish-hooks', deferredPostVerifyHooks });
1343
+ }
1344
+
1345
+ let hooksStopped = stopped;
1346
+ let awaitingApproval = 0;
1347
+ let hookSuccesses = 0;
1348
+ const hookContextProjection = buildPostPublishContext({
1349
+ plan,
1350
+ runId,
1351
+ sourceRun,
1352
+ payloadDir: payloadReal,
1353
+ phase: 'distribute',
1354
+ });
1355
+
1356
+ // Proposal documents must stay byte-deterministic across redeliveries of
1357
+ // the SAME release event (NO_CHANGE idempotence on reconcile reruns):
1358
+ // they travel with the stable lineage-derived event identity, not the
1359
+ // per-attempt runId.
1360
+ const proposalContextProjection = {
1361
+ ...hookContextProjection,
1362
+ runId: `distribute-${sourceRunId}`,
1363
+ };
1364
+
1365
+ for (const hook of distributeHooks) {
1366
+ const cp = checkpointById.get(hook.id);
1367
+ cp.startedAt = clockFn();
1368
+
1369
+ if (hooksStopped) {
1370
+ cp.status = 'SKIPPED';
1371
+ cp.reason = 'EARLIER_TARGET_FAILED';
1372
+ cp.finishedAt = clockFn();
1373
+ await evidence.append({
1374
+ phase: 'postpublish-hook',
1375
+ hookId: hook.id,
1376
+ status: 'skipped',
1377
+ reason: 'EARLIER_TARGET_FAILED',
1378
+ });
1379
+ continue;
1380
+ }
1381
+
1382
+ if (dryRun === true) {
1383
+ cp.status = 'SKIPPED';
1384
+ cp.reason = 'DRY_RUN';
1385
+ cp.finishedAt = clockFn();
1386
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'skipped', reason: 'DRY_RUN' });
1387
+ continue;
1388
+ }
1389
+
1390
+ if (effectiveHookRequiresApproval(hook) && !approvedHookIds.has(hook.id)) {
1391
+ // No checkpoint approval: the hook must not execute. It parks (does
1392
+ // not stop the chain — later reconcile reruns retry it once approved).
1393
+ cp.status = 'AWAITING_APPROVAL';
1394
+ cp.finishedAt = clockFn();
1395
+ awaitingApproval += 1;
1396
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'awaiting-approval' });
1397
+ continue;
1398
+ }
1399
+
1400
+ // -----------------------------------------------------------------
1401
+ // Preset hooks dispatch through the R4 preset executor (one seam for
1402
+ // every registered preset; fail-closed wording for presets registered
1403
+ // but not yet shipped).
1404
+ // -----------------------------------------------------------------
1405
+ if (hook.preset !== undefined) {
1406
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'started' });
1407
+ let delivery;
1408
+ try {
1409
+ delivery = await executePresetHook({
1410
+ hook,
1411
+ contextProjection: hookContextProjection,
1412
+ proposalContextProjection,
1413
+ commitIdentity: postPublish.commitIdentity,
1414
+ // F-04: presets receive the RELEASE workspace root (target.workspace
1415
+ // resolution + release-workspace write exclusion). The detached
1416
+ // worktree is the execution worktree and never impersonates it.
1417
+ releaseWorkspaceRoot,
1418
+ evidencePath: join(runDir, 'evidence.jsonl'),
1419
+ payloadDir: hookContextProjection.payloadDir,
1420
+ exec,
1421
+ hookRunner,
1422
+ });
1423
+ } catch (err) {
1424
+ const code = mapToSchemaCode(err?.code);
1425
+ cp.status = 'FAILED';
1426
+ cp.error = { code, message: err?.message ?? String(err) };
1427
+ cp.finishedAt = clockFn();
1428
+ failures += 1;
1429
+ hooksStopped = true;
1430
+ await evidence.append({
1431
+ phase: 'postpublish-hook',
1432
+ hookId: hook.id,
1433
+ status: 'failed',
1434
+ error: err?.message ?? String(err),
1435
+ details: { code },
1436
+ });
1437
+ await snapshot(PARTIAL);
1438
+ continue;
1439
+ }
1440
+
1441
+ if (delivery.status === 'NO_CHANGE') {
1442
+ cp.status = 'NO_CHANGE';
1443
+ cp.mode = 'no-change';
1444
+ cp.finishedAt = clockFn();
1445
+ hookSuccesses += 1;
1446
+ await evidence.append({
1447
+ phase: 'postpublish-hook',
1448
+ hookId: hook.id,
1449
+ status: 'no-change',
1450
+ ...(delivery.manualSyncPrompt ? { manualSyncPrompt: delivery.manualSyncPrompt } : {}),
1451
+ // §2.6 execution realpath evidence (R4 review m-2).
1452
+ ...(delivery.observation?.workspaceRealpath
1453
+ ? { workspaceRealpath: delivery.observation.workspaceRealpath }
1454
+ : {}),
1455
+ ...(delivery.workspaceRealpath ? { workspaceRealpath: delivery.workspaceRealpath } : {}),
1456
+ });
1457
+ await snapshot(PARTIAL);
1458
+ continue;
1459
+ }
1460
+
1461
+ cp.status = 'SUCCEEDED';
1462
+ if (delivery.observation?.mode === 'pushed' && delivery.observation?.pushedCommit) {
1463
+ cp.mode = 'pushed';
1464
+ cp.pushedCommit = delivery.observation.pushedCommit;
1465
+ }
1466
+ cp.finishedAt = clockFn();
1467
+ hookSuccesses += 1;
1468
+ await evidence.append({
1469
+ phase: 'postpublish-hook',
1470
+ hookId: hook.id,
1471
+ status: 'succeeded',
1472
+ preset: hook.preset,
1473
+ mode: delivery.mode ?? delivery.observation?.mode,
1474
+ ...(delivery.observation?.pushedCommit ? { pushedCommit: delivery.observation.pushedCommit } : {}),
1475
+ ...(delivery.manualSyncPrompt ? { manualSyncPrompt: delivery.manualSyncPrompt } : {}),
1476
+ ...(delivery.checklist ? { checklist: delivery.checklist } : {}),
1477
+ ...(delivery.degradedToNotifyHandoff === true ? { degradedToNotifyHandoff: true } : {}),
1478
+ ...(Array.isArray(delivery.observations) ? { targets: delivery.observations } : {}),
1479
+ // §2.6 execution realpath evidence (R4 review m-2) + explicit
1480
+ // cross-check skip note (R4 review m-4).
1481
+ ...(delivery.observation?.workspaceRealpath
1482
+ ? { workspaceRealpath: delivery.observation.workspaceRealpath }
1483
+ : {}),
1484
+ ...(delivery.workspaceRealpath ? { workspaceRealpath: delivery.workspaceRealpath } : {}),
1485
+ ...(delivery.observation?.crossCheck ? { crossCheck: delivery.observation.crossCheck } : {}),
1486
+ });
1487
+ await snapshot(PARTIAL);
1488
+ continue;
1489
+ }
1490
+
1491
+ if (!Array.isArray(hook.command)) {
1492
+ // Fail-closed: a hook with no executable command stops the chain.
1493
+ cp.status = 'FAILED';
1494
+ cp.error = {
1495
+ code: POST_PUBLISH_VERIFY_FAILED,
1496
+ message: `hook "${hook.id}" has no executable command`,
1497
+ };
1498
+ cp.finishedAt = clockFn();
1499
+ failures += 1;
1500
+ hooksStopped = true;
1501
+ await evidence.append({
1502
+ phase: 'postpublish-hook',
1503
+ hookId: hook.id,
1504
+ status: 'failed',
1505
+ reason: 'no-command',
1506
+ });
1507
+ await snapshot(PARTIAL);
1508
+ continue;
1509
+ }
1510
+
1511
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'started' });
1512
+ let hookExecution;
1513
+ try {
1514
+ hookExecution = await hookRunner(
1515
+ {
1516
+ command: hook.command,
1517
+ ...(hook.cwd ? { cwd: hook.cwd } : {}),
1518
+ ...(hook.timeoutMs !== undefined ? { timeoutMs: hook.timeoutMs } : {}),
1519
+ ...(hook.envAllowlist ? { envAllowlist: hook.envAllowlist } : {}),
1520
+ },
1521
+ {
1522
+ // F-04: custom command hooks keep running in the execution
1523
+ // worktree; the runner's cwd containment binds them there.
1524
+ root: worktreePath,
1525
+ env: process.env,
1526
+ injectEnv: {
1527
+ [POSTPUBLISH_CONTEXT_ENV]: JSON.stringify(hookContextProjection),
1528
+ },
1529
+ },
1530
+ );
1531
+ } catch (err) {
1532
+ // HOOK_TIMEOUT (or a runner defect): FAILED checkpoint, stop the chain.
1533
+ const code = err?.code === 'HOOK_TIMEOUT' ? 'HOOK_TIMEOUT' : POST_PUBLISH_VERIFY_FAILED;
1534
+ cp.status = 'FAILED';
1535
+ cp.error = { code, message: err?.message ?? String(err) };
1536
+ cp.finishedAt = clockFn();
1537
+ failures += 1;
1538
+ hooksStopped = true;
1539
+ await evidence.append({
1540
+ phase: 'postpublish-hook',
1541
+ hookId: hook.id,
1542
+ status: 'failed',
1543
+ error: err?.message ?? String(err),
1544
+ });
1545
+ await snapshot(PARTIAL);
1546
+ continue;
1547
+ }
1548
+
1549
+ if (hookExecution.exitCode !== 0) {
1550
+ cp.status = 'FAILED';
1551
+ cp.error = {
1552
+ code: POST_PUBLISH_VERIFY_FAILED,
1553
+ message: `postPublish hook "${hook.id}" exited with code ${hookExecution.exitCode}`,
1554
+ };
1555
+ cp.finishedAt = clockFn();
1556
+ failures += 1;
1557
+ hooksStopped = true;
1558
+ await evidence.append({
1559
+ phase: 'postpublish-hook',
1560
+ hookId: hook.id,
1561
+ status: 'failed',
1562
+ exitCode: hookExecution.exitCode,
1563
+ stdoutTail: boundedOutputTail(hookExecution.stdout),
1564
+ stderrTail: boundedOutputTail(hookExecution.stderr),
1565
+ });
1566
+ await snapshot(PARTIAL);
1567
+ continue;
1568
+ }
1569
+
1570
+ cp.status = 'SUCCEEDED';
1571
+ cp.finishedAt = clockFn();
1572
+ hookSuccesses += 1;
1573
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'succeeded' });
1574
+ await snapshot(PARTIAL);
1575
+ }
1576
+
1577
+ // =======================================================================
1578
+ // Classification (returned, not thrown):
1579
+ // - DISTRIBUTED: no failures and no awaiting-approval hooks;
1580
+ // - NEEDS_INPUT: only awaiting-approval checkpoints and zero external
1581
+ // side effects so far (pure input-needed state, never PARTIAL);
1582
+ // - PARTIAL: at least one external success (pushed write or succeeded
1583
+ // hook) alongside failures or awaiting-approval checkpoints;
1584
+ // - BLOCKED: failures with zero external side effects landed.
1026
1585
  // =======================================================================
1586
+ const externalCheckpointSuccesses = pushedWrites + hookSuccesses;
1027
1587
  let overallStatus;
1028
- if (failures === 0) {
1588
+ if (failures === 0 && awaitingApproval === 0) {
1029
1589
  overallStatus = DISTRIBUTED;
1030
- } else if (pushedWrites > 0) {
1031
- overallStatus = PARTIAL;
1590
+ } else if (failures === 0) {
1591
+ overallStatus = externalCheckpointSuccesses > 0 ? PARTIAL : NEEDS_INPUT;
1032
1592
  } else {
1033
- overallStatus = BLOCKED;
1593
+ overallStatus = externalCheckpointSuccesses > 0 ? PARTIAL : BLOCKED;
1034
1594
  }
1035
1595
 
1036
1596
  const finishedAt = clockFn();