release-skill 0.6.2 → 0.6.3

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 (73) 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 +23 -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 +126 -9
  11. package/README.zh-CN.md +108 -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/release-skill.bundle.mjs +6408 -1654
  15. package/adapters/claude/schemas/.render-manifest.json +10 -6
  16. package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
  17. package/adapters/claude/schemas/release-plan.schema.json +65 -1
  18. package/adapters/claude/schemas/release-project.schema.json +73 -1
  19. package/adapters/claude/schemas/release-run.schema.json +11 -6
  20. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  21. package/adapters/codex/bin/release-skill.bundle.mjs +6408 -1654
  22. package/adapters/codex/schemas/.render-manifest.json +10 -6
  23. package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
  24. package/adapters/codex/schemas/release-plan.schema.json +65 -1
  25. package/adapters/codex/schemas/release-project.schema.json +73 -1
  26. package/adapters/codex/schemas/release-run.schema.json +11 -6
  27. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  28. package/adapters/kimi/bin/release-skill.bundle.mjs +6408 -1654
  29. package/adapters/kimi/schemas/.render-manifest.json +10 -6
  30. package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
  31. package/adapters/kimi/schemas/release-plan.schema.json +65 -1
  32. package/adapters/kimi/schemas/release-project.schema.json +73 -1
  33. package/adapters/kimi/schemas/release-run.schema.json +11 -6
  34. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  35. package/adapters/workbuddy/bin/release-skill.bundle.mjs +6408 -1654
  36. package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
  37. package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
  38. package/adapters/workbuddy/schemas/release-plan.schema.json +65 -1
  39. package/adapters/workbuddy/schemas/release-project.schema.json +73 -1
  40. package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
  41. package/bin/release-skill-cli.mjs +181 -3
  42. package/bin/release-skill.bundle.mjs +6408 -1654
  43. package/package.json +2 -1
  44. package/platform-manifest.json +4 -4
  45. package/references/.render-manifest.json +5 -5
  46. package/references/01-state-machine.md +22 -2
  47. package/schemas/.render-manifest.json +10 -6
  48. package/schemas/postpublish-approval-record.schema.json +47 -0
  49. package/schemas/release-plan.schema.json +65 -1
  50. package/schemas/release-project.schema.json +73 -1
  51. package/schemas/release-run.schema.json +11 -6
  52. package/src/commands/approve.mjs +167 -1
  53. package/src/commands/distribute.mjs +411 -33
  54. package/src/commands/postverify.mjs +734 -0
  55. package/src/commands/prepare.mjs +280 -42
  56. package/src/commands/setup.mjs +715 -0
  57. package/src/commands/ship.mjs +152 -5
  58. package/src/commands/verify.mjs +92 -15
  59. package/src/core/approval.mjs +93 -68
  60. package/src/core/bounded-output.mjs +46 -0
  61. package/src/core/derived-artifact-gates.mjs +258 -0
  62. package/src/core/docs-refresh-preset.mjs +167 -0
  63. package/src/core/errors.mjs +4 -0
  64. package/src/core/hooks.mjs +28 -0
  65. package/src/core/marketplace-registry-entry.mjs +174 -0
  66. package/src/core/notify-handoff.mjs +76 -0
  67. package/src/core/postpublish-approval.mjs +110 -0
  68. package/src/core/postpublish.mjs +424 -7
  69. package/src/core/preset-executor.mjs +156 -0
  70. package/src/core/preset-gitwrite.mjs +463 -0
  71. package/src/core/presets.mjs +706 -0
  72. package/src/core/proposal-inbox.mjs +630 -0
  73. package/src/core/run.mjs +91 -6
@@ -61,11 +61,19 @@ import {
61
61
  } from '../core/run.mjs';
62
62
  import { createEvidenceWriter } from '../core/evidence.mjs';
63
63
  import { runHook } from '../core/hooks.mjs';
64
+ import { boundedOutputTail } from '../core/bounded-output.mjs';
64
65
  import {
65
66
  orderTargetsByDependency,
66
67
  validatePostPublishDeclaration,
68
+ buildPostPublishContext,
69
+ normalizePostPublishDeclaration,
70
+ orderNormalizedHooks,
71
+ effectiveHookRequiresApproval,
72
+ POSTPUBLISH_CONTEXT_ENV,
67
73
  PAYLOAD_SOURCE_TAG_WORKTREE,
68
74
  } from '../core/postpublish.mjs';
75
+ import { validatePostPublishApproval } from '../core/postpublish-approval.mjs';
76
+ import { executePresetHook } from '../core/preset-executor.mjs';
69
77
  import {
70
78
  ReleaseError,
71
79
  GATE_FAILED,
@@ -80,9 +88,6 @@ const execFileAsync = promisify(execFileCb);
80
88
  /** Executor identity recorded in every distribute checkpoint trace. */
81
89
  const EXECUTOR = 'release-skill distribute';
82
90
 
83
- /** Tail length for hook stdout/stderr recorded in evidence. */
84
- const TAIL_CHARS = 4000;
85
-
86
91
  /** Full 40-hex commit sha. */
87
92
  const SHA_RE = /^[a-f0-9]{40}$/;
88
93
 
@@ -105,6 +110,7 @@ const DISTRIBUTING = 'DISTRIBUTING';
105
110
  const DISTRIBUTED = 'DISTRIBUTED';
106
111
  const PARTIAL = 'PARTIAL';
107
112
  const BLOCKED = 'BLOCKED';
113
+ const NEEDS_INPUT = 'NEEDS_INPUT';
108
114
 
109
115
  function defaultClock() {
110
116
  return new Date().toISOString();
@@ -114,11 +120,6 @@ function defaultExec(command, args, options = {}) {
114
120
  return execFileAsync(command, args, { shell: false, encoding: 'utf8', timeout: 120_000, ...options });
115
121
  }
116
122
 
117
- function tail(text, limit = TAIL_CHARS) {
118
- const value = `${text ?? ''}`;
119
- return value.length > limit ? value.slice(-limit) : value;
120
- }
121
-
122
123
  /** Map an adapter/details error code onto the run-schema checkpoint enum. */
123
124
  function mapToSchemaCode(code) {
124
125
  return SCHEMA_ERROR_CODES.has(code) ? code : GATE_FAILED;
@@ -230,6 +231,9 @@ function resolvePluginName(plan, unitId) {
230
231
  * @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
231
232
  * @param {Function} [options.execFn] - Injectable git exec (tests).
232
233
  * @param {Function} [options.runHookFn] - Injectable hook runner (tests).
234
+ * @param {string[]} [options.postpublishApprovalPaths] - Checkpoint approval
235
+ * records for requiresApproval postPublish hooks (v0.6.3 R1); each record
236
+ * binds (planDigest, hookId) and is validated fail-closed before any write.
233
237
  *
234
238
  * @returns {Promise<{ planPath: string, runPath: string, status: string, checkpoints: Object[] }>}
235
239
  *
@@ -248,6 +252,7 @@ export async function distributeRelease(options) {
248
252
  clock: clockOpt,
249
253
  execFn,
250
254
  runHookFn,
255
+ postpublishApprovalPaths,
251
256
  } = options ?? {};
252
257
 
253
258
  const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
@@ -407,7 +412,16 @@ export async function distributeRelease(options) {
407
412
  ?? computeApprovalDigest(approvalRaw);
408
413
 
409
414
  validateApprovalRecordSchema(approval);
410
- validateApproval(plan, approval, { clock: clockFn });
415
+ // distribute is a post-publish phase: like reconcile/verify it validates
416
+ // the approval binding (planDigest/approvedActions/digest) without
417
+ // requiring an unexpired window — distribution may legitimately happen
418
+ // after the 24h publish-approval window. requiresApproval hook approvals
419
+ // remain expiry-enforced by validatePostPublishApproval.
420
+ // requireUnexpired: false aligns with reconcile.mjs:321 and
421
+ // verify.mjs:1098; the binding checks (plan digest, approved actions,
422
+ // approval digest) are NOT relaxed here — only the expiry window is
423
+ // waived for the post-publish phase.
424
+ validateApproval(plan, approval, { clock: clockFn, requireUnexpired: false });
411
425
 
412
426
  await evidence.append({ phase: 'safety-gate', gate: 'approval-validated', status: 'passed' });
413
427
 
@@ -448,12 +462,110 @@ export async function distributeRelease(options) {
448
462
 
449
463
  // =======================================================================
450
464
  // Declaration re-validation + deterministic target ordering.
465
+ // R2: preset references resolve against the built-in preset registry
466
+ // (core/presets.mjs); per-preset config validation (dual addressing,
467
+ // marketplace/staticFiles shapes, secret scan) fails closed here.
451
468
  // =======================================================================
452
469
  const postPublish = plan.postPublish;
453
470
  validatePostPublishDeclaration(postPublish, { unitId: postPublish.unitId });
454
- const orderedTargets = orderTargetsByDependency(postPublish.targets);
471
+ const orderedTargets = orderTargetsByDependency(postPublish.targets ?? []);
472
+
473
+ // Normalized hook table (design §2.2): every targets[] entry maps onto a
474
+ // preset hook (payload-mirror -> git-mirror, marketplace-index ->
475
+ // marketplace-index-render); the table is a deterministic projection of
476
+ // the digest-bound declaration, ordered by dependency topology +
477
+ // declaration order. Target execution below keeps the exact legacy
478
+ // semantics; hooks[] preset execution dispatches in the hook loop
479
+ // (proposal-inbox git-push is wired; other presets fail closed until
480
+ // their behavior ships).
481
+ const normalizedDeclaration = normalizePostPublishDeclaration(postPublish);
482
+ const orderedNormalizedHooks = orderNormalizedHooks(normalizedDeclaration.hooks);
483
+ await evidence.append({
484
+ phase: 'postpublish-normalization',
485
+ status: 'passed',
486
+ preGates: normalizedDeclaration.preGates.map((gate) => gate.gate),
487
+ hookCount: orderedNormalizedHooks.length,
488
+ hookIds: orderedNormalizedHooks.map((hook) => hook.id),
489
+ });
490
+
491
+ // postPublish hooks (v0.6.3 R1): distribute-phase hooks run in this saga;
492
+ // postVerify-phase hooks belong to the independent postVerify run (R3)
493
+ // and are only evidenced here — never executed, never silent.
494
+ const declaredHooks = postPublish.hooks ?? [];
495
+ const distributeHooks = declaredHooks.filter((hook) => (hook.phase ?? 'distribute') === 'distribute');
496
+ const deferredPostVerifyHooks = declaredHooks.length - distributeHooks.length;
497
+
498
+ /** Gate failure after lineage is known: persist BLOCKED, then rethrow. */
499
+ const failBlocked = async (error) => {
500
+ await recordBlocked();
501
+ throw error;
502
+ };
503
+
504
+ // =======================================================================
505
+ // Gate: checkpoint approvals for requiresApproval hooks. Every provided
506
+ // record is validated fail-closed BEFORE any write (planDigest binding,
507
+ // declared hook, requiresApproval, 24h window, expiry). A bad approval
508
+ // aborts the whole saga; a missing one parks the hook at AWAITING_APPROVAL.
509
+ // =======================================================================
510
+ const approvedHookIds = new Set();
511
+ const hookApprovalPaths = postpublishApprovalPaths ?? [];
512
+ if (hookApprovalPaths.length > 0) {
513
+ await evidence.append({
514
+ phase: 'safety-gate',
515
+ gate: 'postpublish-hook-approvals',
516
+ status: 'started',
517
+ approvalCount: hookApprovalPaths.length,
518
+ });
519
+ for (const hookApprovalPath of hookApprovalPaths) {
520
+ let hookApprovalRaw;
521
+ try {
522
+ hookApprovalRaw = await readFile(hookApprovalPath, 'utf8');
523
+ } catch (err) {
524
+ await failBlocked(new ReleaseError(
525
+ GATE_FAILED,
526
+ `cannot read postpublish hook approval: ${err.message}`,
527
+ { hookApprovalPath, cause: err.code },
528
+ ));
529
+ }
530
+ let hookApproval;
531
+ try {
532
+ hookApproval = JSON.parse(hookApprovalRaw);
533
+ } catch (err) {
534
+ await failBlocked(new ReleaseError(
535
+ GATE_FAILED,
536
+ `postpublish hook approval is not valid JSON: ${err.message}`,
537
+ { hookApprovalPath },
538
+ ));
539
+ }
540
+ validatePostPublishApproval(plan, hookApproval, { clock: clockFn });
541
+ if (approvedHookIds.has(hookApproval.hookId)) {
542
+ await failBlocked(new ReleaseError(
543
+ GATE_FAILED,
544
+ `duplicate postpublish hook approvals for hook "${hookApproval.hookId}"`,
545
+ { hookId: hookApproval.hookId },
546
+ ));
547
+ }
548
+ approvedHookIds.add(hookApproval.hookId);
549
+ }
550
+ await evidence.append({
551
+ phase: 'safety-gate',
552
+ gate: 'postpublish-hook-approvals',
553
+ status: 'passed',
554
+ approvedHookIds: [...approvedHookIds],
555
+ });
556
+ }
557
+
558
+ // Hooks whose checkpoint approval is still missing. While any exist, the
559
+ // declared postPublish steps (unaudited project code) must not execute:
560
+ // the run parks at NEEDS_INPUT/PARTIAL and the approved reconcile rerun
561
+ // re-executes them. Targets remain plan-approval-authorized idempotent
562
+ // remote-state convergence and are unaffected.
563
+ const pendingHookApprovals = distributeHooks.filter(
564
+ (hook) => effectiveHookRequiresApproval(hook) && !approvedHookIds.has(hook.id),
565
+ );
455
566
 
456
- // Checkpoint registry: one probe + one mirror per target, declared order.
567
+ // Checkpoint registry: one probe + one mirror per target, declared order,
568
+ // then one postpublish-hook checkpoint per distribute-phase hook.
457
569
  checkpoints = [];
458
570
  for (const target of orderedTargets) {
459
571
  checkpoints.push({
@@ -478,17 +590,19 @@ export async function distributeRelease(options) {
478
590
  executor: EXECUTOR,
479
591
  });
480
592
  }
593
+ for (const hook of distributeHooks) {
594
+ checkpoints.push({
595
+ actionId: hook.id,
596
+ actionType: 'postpublish-hook',
597
+ status: 'PENDING',
598
+ executor: EXECUTOR,
599
+ });
600
+ }
481
601
  const checkpointById = new Map(checkpoints.map((cp) => [cp.actionId, cp]));
482
602
 
483
603
  // Durable pre-execute authority (seq 0).
484
604
  await snapshot(DISTRIBUTING);
485
605
 
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
606
  // =======================================================================
493
607
  // Gate 4: tag identity — the live tag must still point at the frozen
494
608
  // tagCommit. A missing binding or a moved tag fails closed.
@@ -518,7 +632,7 @@ export async function distributeRelease(options) {
518
632
  phase: 'safety-gate',
519
633
  gate: 'tag-identity',
520
634
  status: 'failed',
521
- error: tail(err?.stderr ?? err?.message),
635
+ error: boundedOutputTail(err?.stderr ?? err?.message),
522
636
  });
523
637
  await failBlocked(new ReleaseError(
524
638
  GATE_FAILED,
@@ -663,7 +777,7 @@ export async function distributeRelease(options) {
663
777
  worktreePath = join(tmpBase, 'worktree');
664
778
  await exec('git', ['-C', root, 'worktree', 'add', '--detach', worktreePath, postPublish.tagCommit]);
665
779
  } catch (err) {
666
- await evidence.append({ phase: 'worktree', status: 'failed', error: tail(err?.stderr ?? err?.message) });
780
+ await evidence.append({ phase: 'worktree', status: 'failed', error: boundedOutputTail(err?.stderr ?? err?.message) });
667
781
  await failBlocked(new ReleaseError(
668
782
  GATE_FAILED,
669
783
  `cannot create the detached tag worktree at ${postPublish.tagCommit}: ${err?.message ?? err}`,
@@ -694,13 +808,13 @@ export async function distributeRelease(options) {
694
808
  phase: 'materialize',
695
809
  status: 'failed',
696
810
  exitCode: hookResult.exitCode,
697
- stdoutTail: tail(hookResult.stdout),
698
- stderrTail: tail(hookResult.stderr),
811
+ stdoutTail: boundedOutputTail(hookResult.stdout),
812
+ stderrTail: boundedOutputTail(hookResult.stderr),
699
813
  });
700
814
  await failBlocked(new ReleaseError(
701
815
  POST_PUBLISH_VERIFY_FAILED,
702
816
  `materialize hook exited with code ${hookResult.exitCode}; payload cannot be trusted`,
703
- { exitCode: hookResult.exitCode, stdoutTail: tail(hookResult.stdout), stderrTail: tail(hookResult.stderr) },
817
+ { exitCode: hookResult.exitCode, stdoutTail: boundedOutputTail(hookResult.stdout), stderrTail: boundedOutputTail(hookResult.stderr) },
704
818
  ));
705
819
  }
706
820
 
@@ -712,12 +826,12 @@ export async function distributeRelease(options) {
712
826
  phase: 'materialize',
713
827
  status: 'failed',
714
828
  reason: 'report-missing',
715
- stdoutTail: tail(hookResult.stdout),
829
+ stdoutTail: boundedOutputTail(hookResult.stdout),
716
830
  });
717
831
  await failBlocked(new ReleaseError(
718
832
  POST_PUBLISH_VERIFY_FAILED,
719
833
  'materialize report missing: no JSON object found on stdout (requireReport.parse = stdout-first-json)',
720
- { stdoutTail: tail(hookResult.stdout) },
834
+ { stdoutTail: boundedOutputTail(hookResult.stdout) },
721
835
  ));
722
836
  }
723
837
  const equals = materialize.requireReport.equals ?? {};
@@ -747,12 +861,12 @@ export async function distributeRelease(options) {
747
861
  phase: 'materialize',
748
862
  status: 'failed',
749
863
  reason: 'marker-missing',
750
- stdoutTail: tail(hookResult.stdout),
864
+ stdoutTail: boundedOutputTail(hookResult.stdout),
751
865
  });
752
866
  await failBlocked(new ReleaseError(
753
867
  POST_PUBLISH_VERIFY_FAILED,
754
868
  `materialize output marker "${materialize.outputMarker}" not found on stdout; payload directory unbound`,
755
- { stdoutTail: tail(hookResult.stdout) },
869
+ { stdoutTail: boundedOutputTail(hookResult.stdout) },
756
870
  ));
757
871
  }
758
872
  const payloadReal = await assertContainedDirectory(
@@ -766,6 +880,25 @@ export async function distributeRelease(options) {
766
880
  // Declared postPublish steps, in order (fail-closed).
767
881
  // =======================================================================
768
882
  for (const step of postPublish.steps ?? []) {
883
+ if (dryRun === true) {
884
+ // R1 dry-run contract: steps are arbitrary project code with
885
+ // potentially remote side effects; they never execute in a rehearsal.
886
+ await evidence.append({ phase: 'postpublish-step', step: step.name, status: 'skipped', reason: 'DRY_RUN' });
887
+ continue;
888
+ }
889
+ if (pendingHookApprovals.length > 0) {
890
+ // A requiresApproval hook is still unapproved: the step pipeline must
891
+ // not execute before the checkpoint approval exists; the approved
892
+ // reconcile rerun re-executes the steps (idempotence-by-default).
893
+ await evidence.append({
894
+ phase: 'postpublish-step',
895
+ step: step.name,
896
+ status: 'skipped',
897
+ reason: 'AWAITING_CHECKPOINT_APPROVAL',
898
+ pendingHookApprovals: pendingHookApprovals.map((hook) => hook.id),
899
+ });
900
+ continue;
901
+ }
769
902
  await evidence.append({ phase: 'postpublish-step', step: step.name, status: 'started' });
770
903
  const stepResult = await hookRunner(
771
904
  {
@@ -782,8 +915,8 @@ export async function distributeRelease(options) {
782
915
  step: step.name,
783
916
  status: 'failed',
784
917
  exitCode: stepResult.exitCode,
785
- stdoutTail: tail(stepResult.stdout),
786
- stderrTail: tail(stepResult.stderr),
918
+ stdoutTail: boundedOutputTail(stepResult.stdout),
919
+ stderrTail: boundedOutputTail(stepResult.stderr),
787
920
  });
788
921
  await failBlocked(new ReleaseError(
789
922
  GATE_FAILED,
@@ -1022,15 +1155,260 @@ export async function distributeRelease(options) {
1022
1155
  }
1023
1156
 
1024
1157
  // =======================================================================
1025
- // Classification: DISTRIBUTED | PARTIAL | BLOCKED (returned, not thrown).
1158
+ // postPublish hooks (distribute phase), executed AFTER the target writes.
1159
+ // Contract (design §2.3): the read-only frozen-plan projection travels via
1160
+ // RELEASE_SKILL_POSTPUBLISH_CONTEXT; hooks run inside the frozen tag
1161
+ // worktree; a failure stops the hook chain; a requiresApproval hook
1162
+ // without a checkpoint approval parks at AWAITING_APPROVAL and never
1163
+ // executes; dry-run executes nothing; postVerify-phase hooks belong to
1164
+ // the independent postVerify run (R3) and are evidenced as deferred.
1165
+ // =======================================================================
1166
+ if (deferredPostVerifyHooks > 0) {
1167
+ await evidence.append({ phase: 'postpublish-hooks', deferredPostVerifyHooks });
1168
+ }
1169
+
1170
+ let hooksStopped = stopped;
1171
+ let awaitingApproval = 0;
1172
+ let hookSuccesses = 0;
1173
+ const hookContextProjection = buildPostPublishContext({
1174
+ plan,
1175
+ runId,
1176
+ sourceRun,
1177
+ payloadDir: payloadReal,
1178
+ phase: 'distribute',
1179
+ });
1180
+
1181
+ // Proposal documents must stay byte-deterministic across redeliveries of
1182
+ // the SAME release event (NO_CHANGE idempotence on reconcile reruns):
1183
+ // they travel with the stable lineage-derived event identity, not the
1184
+ // per-attempt runId.
1185
+ const proposalContextProjection = {
1186
+ ...hookContextProjection,
1187
+ runId: `distribute-${sourceRunId}`,
1188
+ };
1189
+
1190
+ for (const hook of distributeHooks) {
1191
+ const cp = checkpointById.get(hook.id);
1192
+ cp.startedAt = clockFn();
1193
+
1194
+ if (hooksStopped) {
1195
+ cp.status = 'SKIPPED';
1196
+ cp.reason = 'EARLIER_TARGET_FAILED';
1197
+ cp.finishedAt = clockFn();
1198
+ await evidence.append({
1199
+ phase: 'postpublish-hook',
1200
+ hookId: hook.id,
1201
+ status: 'skipped',
1202
+ reason: 'EARLIER_TARGET_FAILED',
1203
+ });
1204
+ continue;
1205
+ }
1206
+
1207
+ if (dryRun === true) {
1208
+ cp.status = 'SKIPPED';
1209
+ cp.reason = 'DRY_RUN';
1210
+ cp.finishedAt = clockFn();
1211
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'skipped', reason: 'DRY_RUN' });
1212
+ continue;
1213
+ }
1214
+
1215
+ if (effectiveHookRequiresApproval(hook) && !approvedHookIds.has(hook.id)) {
1216
+ // No checkpoint approval: the hook must not execute. It parks (does
1217
+ // not stop the chain — later reconcile reruns retry it once approved).
1218
+ cp.status = 'AWAITING_APPROVAL';
1219
+ cp.finishedAt = clockFn();
1220
+ awaitingApproval += 1;
1221
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'awaiting-approval' });
1222
+ continue;
1223
+ }
1224
+
1225
+ // -----------------------------------------------------------------
1226
+ // Preset hooks dispatch through the R4 preset executor (one seam for
1227
+ // every registered preset; fail-closed wording for presets registered
1228
+ // but not yet shipped).
1229
+ // -----------------------------------------------------------------
1230
+ if (hook.preset !== undefined) {
1231
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'started' });
1232
+ let delivery;
1233
+ try {
1234
+ delivery = await executePresetHook({
1235
+ hook,
1236
+ contextProjection: hookContextProjection,
1237
+ proposalContextProjection,
1238
+ commitIdentity: postPublish.commitIdentity,
1239
+ root: worktreePath,
1240
+ evidencePath: join(runDir, 'evidence.jsonl'),
1241
+ payloadDir: hookContextProjection.payloadDir,
1242
+ exec,
1243
+ hookRunner,
1244
+ });
1245
+ } catch (err) {
1246
+ const code = mapToSchemaCode(err?.code);
1247
+ cp.status = 'FAILED';
1248
+ cp.error = { code, message: err?.message ?? String(err) };
1249
+ cp.finishedAt = clockFn();
1250
+ failures += 1;
1251
+ hooksStopped = true;
1252
+ await evidence.append({
1253
+ phase: 'postpublish-hook',
1254
+ hookId: hook.id,
1255
+ status: 'failed',
1256
+ error: err?.message ?? String(err),
1257
+ details: { code },
1258
+ });
1259
+ await snapshot(PARTIAL);
1260
+ continue;
1261
+ }
1262
+
1263
+ if (delivery.status === 'NO_CHANGE') {
1264
+ cp.status = 'NO_CHANGE';
1265
+ cp.mode = 'no-change';
1266
+ cp.finishedAt = clockFn();
1267
+ hookSuccesses += 1;
1268
+ await evidence.append({
1269
+ phase: 'postpublish-hook',
1270
+ hookId: hook.id,
1271
+ status: 'no-change',
1272
+ ...(delivery.manualSyncPrompt ? { manualSyncPrompt: delivery.manualSyncPrompt } : {}),
1273
+ // §2.6 execution realpath evidence (R4 review m-2).
1274
+ ...(delivery.observation?.workspaceRealpath
1275
+ ? { workspaceRealpath: delivery.observation.workspaceRealpath }
1276
+ : {}),
1277
+ ...(delivery.workspaceRealpath ? { workspaceRealpath: delivery.workspaceRealpath } : {}),
1278
+ });
1279
+ await snapshot(PARTIAL);
1280
+ continue;
1281
+ }
1282
+
1283
+ cp.status = 'SUCCEEDED';
1284
+ if (delivery.observation?.mode === 'pushed' && delivery.observation?.pushedCommit) {
1285
+ cp.mode = 'pushed';
1286
+ cp.pushedCommit = delivery.observation.pushedCommit;
1287
+ }
1288
+ cp.finishedAt = clockFn();
1289
+ hookSuccesses += 1;
1290
+ await evidence.append({
1291
+ phase: 'postpublish-hook',
1292
+ hookId: hook.id,
1293
+ status: 'succeeded',
1294
+ preset: hook.preset,
1295
+ mode: delivery.mode ?? delivery.observation?.mode,
1296
+ ...(delivery.observation?.pushedCommit ? { pushedCommit: delivery.observation.pushedCommit } : {}),
1297
+ ...(delivery.manualSyncPrompt ? { manualSyncPrompt: delivery.manualSyncPrompt } : {}),
1298
+ ...(delivery.checklist ? { checklist: delivery.checklist } : {}),
1299
+ ...(delivery.degradedToNotifyHandoff === true ? { degradedToNotifyHandoff: true } : {}),
1300
+ ...(Array.isArray(delivery.observations) ? { targets: delivery.observations } : {}),
1301
+ // §2.6 execution realpath evidence (R4 review m-2) + explicit
1302
+ // cross-check skip note (R4 review m-4).
1303
+ ...(delivery.observation?.workspaceRealpath
1304
+ ? { workspaceRealpath: delivery.observation.workspaceRealpath }
1305
+ : {}),
1306
+ ...(delivery.workspaceRealpath ? { workspaceRealpath: delivery.workspaceRealpath } : {}),
1307
+ ...(delivery.observation?.crossCheck ? { crossCheck: delivery.observation.crossCheck } : {}),
1308
+ });
1309
+ await snapshot(PARTIAL);
1310
+ continue;
1311
+ }
1312
+
1313
+ if (!Array.isArray(hook.command)) {
1314
+ // Fail-closed: a hook with no executable command stops the chain.
1315
+ cp.status = 'FAILED';
1316
+ cp.error = {
1317
+ code: POST_PUBLISH_VERIFY_FAILED,
1318
+ message: `hook "${hook.id}" has no executable command`,
1319
+ };
1320
+ cp.finishedAt = clockFn();
1321
+ failures += 1;
1322
+ hooksStopped = true;
1323
+ await evidence.append({
1324
+ phase: 'postpublish-hook',
1325
+ hookId: hook.id,
1326
+ status: 'failed',
1327
+ reason: 'no-command',
1328
+ });
1329
+ await snapshot(PARTIAL);
1330
+ continue;
1331
+ }
1332
+
1333
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'started' });
1334
+ let hookExecution;
1335
+ try {
1336
+ hookExecution = await hookRunner(
1337
+ {
1338
+ command: hook.command,
1339
+ ...(hook.cwd ? { cwd: hook.cwd } : {}),
1340
+ ...(hook.timeoutMs !== undefined ? { timeoutMs: hook.timeoutMs } : {}),
1341
+ ...(hook.envAllowlist ? { envAllowlist: hook.envAllowlist } : {}),
1342
+ },
1343
+ {
1344
+ root: worktreePath,
1345
+ env: process.env,
1346
+ injectEnv: { [POSTPUBLISH_CONTEXT_ENV]: JSON.stringify(hookContextProjection) },
1347
+ },
1348
+ );
1349
+ } catch (err) {
1350
+ // HOOK_TIMEOUT (or a runner defect): FAILED checkpoint, stop the chain.
1351
+ const code = err?.code === 'HOOK_TIMEOUT' ? 'HOOK_TIMEOUT' : POST_PUBLISH_VERIFY_FAILED;
1352
+ cp.status = 'FAILED';
1353
+ cp.error = { code, message: err?.message ?? String(err) };
1354
+ cp.finishedAt = clockFn();
1355
+ failures += 1;
1356
+ hooksStopped = true;
1357
+ await evidence.append({
1358
+ phase: 'postpublish-hook',
1359
+ hookId: hook.id,
1360
+ status: 'failed',
1361
+ error: err?.message ?? String(err),
1362
+ });
1363
+ await snapshot(PARTIAL);
1364
+ continue;
1365
+ }
1366
+
1367
+ if (hookExecution.exitCode !== 0) {
1368
+ cp.status = 'FAILED';
1369
+ cp.error = {
1370
+ code: POST_PUBLISH_VERIFY_FAILED,
1371
+ message: `postPublish hook "${hook.id}" exited with code ${hookExecution.exitCode}`,
1372
+ };
1373
+ cp.finishedAt = clockFn();
1374
+ failures += 1;
1375
+ hooksStopped = true;
1376
+ await evidence.append({
1377
+ phase: 'postpublish-hook',
1378
+ hookId: hook.id,
1379
+ status: 'failed',
1380
+ exitCode: hookExecution.exitCode,
1381
+ stdoutTail: boundedOutputTail(hookExecution.stdout),
1382
+ stderrTail: boundedOutputTail(hookExecution.stderr),
1383
+ });
1384
+ await snapshot(PARTIAL);
1385
+ continue;
1386
+ }
1387
+
1388
+ cp.status = 'SUCCEEDED';
1389
+ cp.finishedAt = clockFn();
1390
+ hookSuccesses += 1;
1391
+ await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'succeeded' });
1392
+ await snapshot(PARTIAL);
1393
+ }
1394
+
1395
+ // =======================================================================
1396
+ // Classification (returned, not thrown):
1397
+ // - DISTRIBUTED: no failures and no awaiting-approval hooks;
1398
+ // - NEEDS_INPUT: only awaiting-approval checkpoints and zero external
1399
+ // side effects so far (pure input-needed state, never PARTIAL);
1400
+ // - PARTIAL: at least one external success (pushed write or succeeded
1401
+ // hook) alongside failures or awaiting-approval checkpoints;
1402
+ // - BLOCKED: failures with zero external side effects landed.
1026
1403
  // =======================================================================
1404
+ const externalCheckpointSuccesses = pushedWrites + hookSuccesses;
1027
1405
  let overallStatus;
1028
- if (failures === 0) {
1406
+ if (failures === 0 && awaitingApproval === 0) {
1029
1407
  overallStatus = DISTRIBUTED;
1030
- } else if (pushedWrites > 0) {
1031
- overallStatus = PARTIAL;
1408
+ } else if (failures === 0) {
1409
+ overallStatus = externalCheckpointSuccesses > 0 ? PARTIAL : NEEDS_INPUT;
1032
1410
  } else {
1033
- overallStatus = BLOCKED;
1411
+ overallStatus = externalCheckpointSuccesses > 0 ? PARTIAL : BLOCKED;
1034
1412
  }
1035
1413
 
1036
1414
  const finishedAt = clockFn();