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
@@ -2,6 +2,7 @@ import { readFile, lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises'
2
2
  import { dirname, resolve } from 'node:path';
3
3
 
4
4
  import { canonicalJson, sha256Hex } from '../core/digest.mjs';
5
+ import { effectiveHookRequiresApproval } from '../core/postpublish.mjs';
5
6
  import {
6
7
  ReleaseError,
7
8
  GATE_FAILED,
@@ -61,6 +62,7 @@ async function defaultDependencies() {
61
62
  reconcileModule,
62
63
  verifyModule,
63
64
  distributeModule,
65
+ postverifyModule,
64
66
  transportModule,
65
67
  metadataModule,
66
68
  ] = await Promise.all([
@@ -71,6 +73,7 @@ async function defaultDependencies() {
71
73
  import('./reconcile.mjs'),
72
74
  import('./verify.mjs'),
73
75
  import('./distribute.mjs'),
76
+ import('./postverify.mjs'),
74
77
  import('../core/git-transport.mjs'),
75
78
  import('../core/release-metadata.mjs'),
76
79
  ]);
@@ -82,6 +85,7 @@ async function defaultDependencies() {
82
85
  reconcileRelease: reconcileModule.reconcileRelease,
83
86
  verifyRelease: verifyModule.verifyRelease,
84
87
  distributeRelease: distributeModule.distributeRelease,
88
+ postVerifyRelease: postverifyModule.postVerifyRelease,
85
89
  preflightGitTransports: transportModule.preflightGitTransports,
86
90
  updatePreviousPublicBaselines: metadataModule.updatePreviousPublicBaselines,
87
91
  };
@@ -106,6 +110,7 @@ function publicState(state) {
106
110
  ...(state.approvalPath ? { approvalPath: state.approvalPath } : {}),
107
111
  ...(state.sourceRunPath ? { sourceRunPath: state.sourceRunPath } : {}),
108
112
  ...(state.distributeRunPath ? { distributeRunPath: state.distributeRunPath } : {}),
113
+ ...(state.postVerify ? { postVerify: state.postVerify } : {}),
109
114
  ...(state.requirements ? { requirements: state.requirements } : {}),
110
115
  ...(state.manualFollowUps ? { manualFollowUps: state.manualFollowUps } : {}),
111
116
  ...(state.metadataUpdate ? { metadataUpdate: state.metadataUpdate } : {}),
@@ -140,6 +145,41 @@ async function buildApprovalSummary(planPath) {
140
145
  }
141
146
  }
142
147
 
148
+ /**
149
+ * Re-entry gate helper (R4 review followup 8): true when EVERY unclosed
150
+ * postVerify hook is ungated (effective requiresApproval false), so a failed
151
+ * non-gated hook (e.g. notify-handoff) can be retried without any
152
+ * --hook-approval file. The unclosed set comes from the last postVerify run
153
+ * record when readable (checkpoints not succeeded/NO_CHANGE); an unreadable
154
+ * or missing record falls back to ALL declared hooks — fail-safe, because a
155
+ * gated hook in doubt keeps the gate shut.
156
+ *
157
+ * @param {object} state - Ship state (postVerify.runPath inspected).
158
+ * @param {object[]} postVerifyHooks - Declared phase:postVerify hooks.
159
+ * @returns {Promise<boolean>}
160
+ */
161
+ async function allUnclosedPostVerifyHooksUngated(state, postVerifyHooks) {
162
+ if (!Array.isArray(postVerifyHooks) || postVerifyHooks.length === 0) return false;
163
+ let candidates = postVerifyHooks;
164
+ if (state.postVerify?.runPath) {
165
+ try {
166
+ const record = JSON.parse(await readFile(state.postVerify.runPath, 'utf8'));
167
+ const checkpoints = Array.isArray(record.checkpoints) ? record.checkpoints : [];
168
+ const closedIds = new Set(
169
+ checkpoints
170
+ .filter((cp) => cp?.actionType === 'postpublish-hook'
171
+ && (cp.status === 'succeeded' || cp.status === 'NO_CHANGE'))
172
+ .map((cp) => cp.actionId),
173
+ );
174
+ candidates = postVerifyHooks.filter((hook) => !closedIds.has(hook.id));
175
+ } catch {
176
+ candidates = postVerifyHooks; // unreadable record: fail safe
177
+ }
178
+ }
179
+ if (candidates.length === 0) return false;
180
+ return candidates.every((hook) => effectiveHookRequiresApproval(hook) === false);
181
+ }
182
+
143
183
  /**
144
184
  * Advance one durable production release. Re-running is safe: the state file
145
185
  * carries the immutable plan, approval and source-run paths so the command
@@ -304,11 +344,18 @@ export async function advanceShip(options = {}, injected = {}) {
304
344
  await writeJsonAtomic(statePath, state);
305
345
  }
306
346
 
347
+ // Double-run guard (R4 review followup 5): when the PUBLISHED block's
348
+ // step 3 executes postVerify inside this very call, the re-entry block
349
+ // after the lifecycle must NOT immediately re-run it.
350
+ let postVerifyRanThisCall = false;
351
+
307
352
  if (state.status === 'PUBLISHED' || state.status === 'NEEDS_MANUAL_ATTESTATIONS') {
308
- // Step 1: Check if postPublish requires distribution
353
+ // Step 1: Check if postPublish requires distribution.
354
+ // Hooks-only declarations (no targets) still route through distribute.
309
355
  const plan = JSON.parse(await readFile(state.planPath, 'utf8'));
310
- const needsDistribution = plan.postPublish && plan.postPublish.targets && plan.postPublish.targets.length > 0;
311
-
356
+ const hasDistributeWork = (plan.postPublish?.targets?.length ?? 0) > 0
357
+ || (plan.postPublish?.hooks?.length ?? 0) > 0;
358
+ const needsDistribution = Boolean(plan.postPublish) && hasDistributeWork;
312
359
  if (needsDistribution && deps.distributeRelease) {
313
360
  state.status = 'DISTRIBUTING';
314
361
  await writeJsonAtomic(statePath, state);
@@ -321,17 +368,23 @@ export async function advanceShip(options = {}, injected = {}) {
321
368
  root,
322
369
  dryRun: false,
323
370
  planPath: state.planPath,
371
+ // Checkpoint approvals for requiresApproval distribute-phase hooks
372
+ // (review major-1: same seam the distribute CLI already uses).
373
+ ...(options.postpublishApprovalPaths ? { postpublishApprovalPaths: options.postpublishApprovalPaths } : {}),
324
374
  });
325
375
 
326
376
  state = {
327
377
  ...state,
328
378
  status: distributed.status,
329
- distributeRunPath: distributed.distributeRunPath,
379
+ distributeRunPath: distributed.distributeRunPath ?? distributed.runPath,
330
380
  updatedAt: new Date().toISOString(),
331
381
  };
332
382
  await writeJsonAtomic(statePath, state);
333
383
 
334
- if (!distributed.checkpoints || distributed.checkpoints.some((cp) => cp.status === 'failed')) {
384
+ // In-memory saga checkpoints carry UPPERCASE statuses ('FAILED');
385
+ // the persisted run record is the lowercase projection. Comparing
386
+ // against 'failed' here was dead code until v0.6.3 (review note-5).
387
+ if (!distributed.checkpoints || distributed.checkpoints.some((cp) => cp.status === 'FAILED')) {
335
388
  state.status = 'PARTIAL';
336
389
  await writeJsonAtomic(statePath, state);
337
390
  return publicState(state);
@@ -372,6 +425,100 @@ export async function advanceShip(options = {}, injected = {}) {
372
425
  };
373
426
  await writeJsonAtomic(statePath, state);
374
427
  }
428
+
429
+ // Step 3: postVerify phase (design §2.4). phase:postVerify hooks run in an
430
+ // independent run after the main run is VERIFIED, with the verify run as
431
+ // lineage source. A PARTIAL postVerify run or a postVerify gate failure
432
+ // NEVER demotes VERIFIED — the outcome is recorded on the ship state.
433
+ const postVerifyHooks = (plan.postPublish?.hooks ?? [])
434
+ .filter((hook) => hook.phase === 'postVerify');
435
+ if (state.status === 'VERIFIED' && postVerifyHooks.length > 0 && deps.postVerifyRelease) {
436
+ postVerifyRanThisCall = true;
437
+ let postVerifyOutcome;
438
+ try {
439
+ const postVerified = await deps.postVerifyRelease({
440
+ planPath: state.planPath,
441
+ approvalPath: state.approvalPath,
442
+ sourceRunPath: state.verifyRunPath,
443
+ root,
444
+ postpublishApprovalPaths: options.postpublishApprovalPaths,
445
+ });
446
+ postVerifyOutcome = {
447
+ status: postVerified.status,
448
+ runPath: postVerified.runPath,
449
+ };
450
+ } catch (error) {
451
+ postVerifyOutcome = {
452
+ status: 'FAILED',
453
+ error: {
454
+ code: error?.code ?? GATE_FAILED,
455
+ message: error?.message ?? String(error),
456
+ },
457
+ };
458
+ }
459
+ state = {
460
+ ...state,
461
+ postVerify: postVerifyOutcome,
462
+ updatedAt: new Date().toISOString(),
463
+ };
464
+ await writeJsonAtomic(statePath, state);
465
+ }
466
+ }
467
+
468
+ // postVerify re-entry (design §2.4; review major-1). Once the main run is
469
+ // VERIFIED, ship no longer enters the PUBLISHED block, so a postVerify
470
+ // outcome parked at AWAITING_APPROVAL (run status NEEDS_INPUT) could never
471
+ // complete through the CLI. The gate opens when a checkpoint approval is
472
+ // provided OR every unclosed postVerify hook is ungated (R4 review
473
+ // followup 8: a failed requiresApproval:false hook retries without any
474
+ // approval file; a gated hook still unclosed keeps the gate shut).
475
+ // Re-entry re-runs the phase — rerun IS the reconcile: approved hooks
476
+ // execute, already-delivered hooks stay idempotent, the verify run remains
477
+ // the lineage source, and the main VERIFIED status never changes. When the
478
+ // gate stays shut this block is a no-op: zero postVerify work, no writes.
479
+ const reentryApprovalPaths = options.postpublishApprovalPaths ?? [];
480
+ if (
481
+ state.status === 'VERIFIED'
482
+ && deps.postVerifyRelease
483
+ && !postVerifyRanThisCall
484
+ && (!state.postVerify || state.postVerify.status !== 'DISTRIBUTED')
485
+ ) {
486
+ const reentryPlan = JSON.parse(await readFile(state.planPath, 'utf8'));
487
+ const reentryPostVerifyHooks = (reentryPlan.postPublish?.hooks ?? [])
488
+ .filter((hook) => hook.phase === 'postVerify');
489
+ const approvallessRetryAllowed = reentryApprovalPaths.length === 0
490
+ && await allUnclosedPostVerifyHooksUngated(state, reentryPostVerifyHooks);
491
+ if (reentryPostVerifyHooks.length > 0
492
+ && (reentryApprovalPaths.length > 0 || approvallessRetryAllowed)) {
493
+ let postVerifyOutcome;
494
+ try {
495
+ const postVerified = await deps.postVerifyRelease({
496
+ planPath: state.planPath,
497
+ approvalPath: state.approvalPath,
498
+ sourceRunPath: state.verifyRunPath,
499
+ root,
500
+ postpublishApprovalPaths: reentryApprovalPaths,
501
+ });
502
+ postVerifyOutcome = {
503
+ status: postVerified.status,
504
+ runPath: postVerified.runPath,
505
+ };
506
+ } catch (error) {
507
+ postVerifyOutcome = {
508
+ status: 'FAILED',
509
+ error: {
510
+ code: error?.code ?? GATE_FAILED,
511
+ message: error?.message ?? String(error),
512
+ },
513
+ };
514
+ }
515
+ state = {
516
+ ...state,
517
+ postVerify: postVerifyOutcome,
518
+ updatedAt: new Date().toISOString(),
519
+ };
520
+ await writeJsonAtomic(statePath, state);
521
+ }
375
522
  }
376
523
 
377
524
  return publicState(state);
@@ -25,6 +25,7 @@ import { validatePlan, computePlanDigest, validatePlanActionCompleteness } from
25
25
  import { createEvidenceWriter } from '../core/evidence.mjs';
26
26
  import {
27
27
  loadRun,
28
+ resolveRunPath,
28
29
  validateRunPlanDigest,
29
30
  validateRunCheckpointMapping,
30
31
  validateRunLineage,
@@ -822,6 +823,47 @@ async function discoverDistributeRuns({ planPath, plan }) {
822
823
  return candidates;
823
824
  }
824
825
 
826
+ /**
827
+ * Evaluate one distribute run against the verify distribute gate
828
+ * (v0.6.3 R1, design §2.4/§2.7).
829
+ *
830
+ * Semantics:
831
+ * - A DISTRIBUTED run passes outright (no warning).
832
+ * - A PARTIAL run passes only when EVERY checkpoint that is not
833
+ * succeeded/skipped is a FAILED `postpublish-hook` checkpoint whose hook
834
+ * declaration in the frozen plan explicitly downgrades with
835
+ * `blocksVerified: false` (default true). Each such downgrade is reported
836
+ * as a warning exemption — prominently evidenced, never silent.
837
+ * - AWAITING_APPROVAL checkpoints never pass (the checkpoint approval is
838
+ * still missing), and target/mirror checkpoints are never exemptable.
839
+ * - Any other run status (BLOCKED / NEEDS_INPUT / ...) never passes, per the
840
+ * governance rule that NEEDS_INPUT/BLOCKED cannot silently become VERIFIED.
841
+ *
842
+ * @param {Object} run - A distribute run record (checkpoints + status).
843
+ * @param {Object} plan - The frozen release plan (postPublish.hooks source).
844
+ * @returns {{ pass: boolean, warned: boolean, exemptions: Object[] }}
845
+ */
846
+ export function evaluateDistributeGateRun(run, plan) {
847
+ if (!run || typeof run !== 'object') return { pass: false, warned: false, exemptions: [] };
848
+ if (run.status === 'DISTRIBUTED') return { pass: true, warned: false, exemptions: [] };
849
+ if (run.status !== 'PARTIAL') return { pass: false, warned: false, exemptions: [] };
850
+
851
+ const hooksById = new Map(((plan?.postPublish?.hooks ?? [])).map((hook) => [hook.id, hook]));
852
+ const exemptions = [];
853
+ for (const checkpoint of run.checkpoints ?? []) {
854
+ if (checkpoint.status === 'succeeded' || checkpoint.status === 'skipped') continue;
855
+ const hook = checkpoint.actionType === 'postpublish-hook'
856
+ ? hooksById.get(checkpoint.actionId)
857
+ : undefined;
858
+ if (hook && checkpoint.status === 'failed' && hook.blocksVerified === false) {
859
+ exemptions.push({ actionId: checkpoint.actionId, status: checkpoint.status });
860
+ continue;
861
+ }
862
+ return { pass: false, warned: false, exemptions: [] };
863
+ }
864
+ return { pass: true, warned: exemptions.length > 0, exemptions };
865
+ }
866
+
825
867
  // ---------------------------------------------------------------------------
826
868
  // Public API
827
869
  // ---------------------------------------------------------------------------
@@ -831,7 +873,8 @@ async function discoverDistributeRuns({ planPath, plan }) {
831
873
  *
832
874
  * @param {Object} options
833
875
  * @param {string} options.planPath - Absolute path to the frozen release plan.
834
- * @param {string} options.sourceRunPath - Absolute path to the source run.
876
+ * @param {string} options.sourceRunPath - Absolute path to the source run
877
+ * file, or to its containing run directory (resolved to release-run.json; O6).
835
878
  * @param {Object} options.adapterRegistry - Adapter registry for verification.
836
879
  * @param {string} [options.root] - Project root for source access.
837
880
  * @param {string} [options.runDir] - Evidence directory.
@@ -870,6 +913,11 @@ export async function verifyRelease(options) {
870
913
  );
871
914
  }
872
915
 
916
+ // O6: --run accepts a run directory as well as the run file. Resolve a
917
+ // directory to its release-run.json before any load/lineage/authority step;
918
+ // a file input passes through unchanged (compat).
919
+ const resolvedSourceRunPath = await resolveRunPath(sourceRunPath);
920
+
873
921
  // Load and validate the plan before creating any evidence directory. A
874
922
  // production plan grants authority only to a fresh direct child of its
875
923
  // sibling .release-skill/runs directory.
@@ -936,14 +984,14 @@ export async function verifyRelease(options) {
936
984
  // =======================================================================
937
985
  await evidence.append({ phase: 'verify', step: 'source-run-load', status: 'started' });
938
986
 
939
- const sourceRun = await loadRun(sourceRunPath, {
987
+ const sourceRun = await loadRun(resolvedSourceRunPath, {
940
988
  requireDigest: Boolean(plan.production),
941
989
  ...(plan.production ? { authorityPlanPath: planPath } : {}),
942
990
  });
943
991
  await validateRunLineage(sourceRun, {
944
992
  plan,
945
993
  planPath,
946
- runPath: sourceRunPath,
994
+ runPath: resolvedSourceRunPath,
947
995
  production: Boolean(plan.production),
948
996
  });
949
997
 
@@ -966,29 +1014,58 @@ export async function verifyRelease(options) {
966
1014
  }
967
1015
 
968
1016
  // =======================================================================
969
- // Step 2b: Check for postPublish distribution requirement
1017
+ // Step 2b: Check for postPublish distribution requirement.
1018
+ // v0.6.3 R1: the gate triggers on targets OR hooks declarations, and a
1019
+ // PARTIAL distribute run passes only through the blocksVerified:false
1020
+ // exemption path (evaluateDistributeGateRun) — warned, never silent.
970
1021
  // =======================================================================
971
- if (plan.postPublish && plan.postPublish.targets && plan.postPublish.targets.length > 0) {
1022
+ const declaredPostPublishTargets = plan.postPublish?.targets ?? [];
1023
+ const declaredPostPublishHooks = plan.postPublish?.hooks ?? [];
1024
+ if (plan.postPublish
1025
+ && (declaredPostPublishTargets.length > 0 || declaredPostPublishHooks.length > 0)) {
972
1026
  await evidence.append({ phase: 'verify', step: 'distribute-run-discovery', status: 'started' });
973
-
974
- const distributeCandidates = await discoverDistributeRuns({ planPath, plan });
975
- const distRunPath = (distributeCandidates ?? []).find((c) => c.status === 'DISTRIBUTED')?.runPath || null;
976
-
1027
+
1028
+ const distributeCandidates = await discoverDistributeRuns({ planPath, plan }) ?? [];
1029
+
1030
+ let gateRun = distributeCandidates.find((c) => c.status === 'DISTRIBUTED') ?? null;
1031
+ let gateVerdict = gateRun ? { pass: true, warned: false, exemptions: [] } : null;
1032
+ if (!gateRun) {
1033
+ for (const candidate of distributeCandidates) {
1034
+ const verdict = evaluateDistributeGateRun(candidate, plan);
1035
+ if (verdict.pass) {
1036
+ gateRun = candidate;
1037
+ gateVerdict = verdict;
1038
+ break;
1039
+ }
1040
+ }
1041
+ }
1042
+
977
1043
  await evidence.append({
978
1044
  phase: 'verify',
979
1045
  step: 'distribute-run-discovery',
980
1046
  status: 'checked',
981
- foundCandidate: !!distRunPath,
982
- distributeRunPath: distRunPath,
1047
+ foundCandidate: Boolean(gateRun),
1048
+ distributeRunPath: gateRun?.runPath ?? null,
983
1049
  });
984
-
985
- if (!distRunPath) {
1050
+
1051
+ if (!gateRun) {
986
1052
  throw new ReleaseError(
987
1053
  GATE_FAILED,
988
- `distribution required by plan.postPublish but no DISTRIBUTED run found; run release-skill distribute --plan ${planPath} --root ${root}`,
1054
+ `distribution required by plan.postPublish but no DISTRIBUTED (or blocksVerified-exempted) distribute run found; run release-skill distribute --plan ${planPath} --root ${root}`,
989
1055
  { requiredDistribution: true, evidenceEvent: 'distribute-run-missing' },
990
1056
  );
991
1057
  }
1058
+
1059
+ if (gateVerdict.warned) {
1060
+ await evidence.append({
1061
+ phase: 'verify',
1062
+ step: 'distribute-gate',
1063
+ status: 'warning',
1064
+ warning: 'blocksVerified:false postPublish hook failure exempted; VERIFIED proceeds with a downgrade warning',
1065
+ exemptions: gateVerdict.exemptions,
1066
+ distributeRunPath: gateRun.runPath ?? null,
1067
+ });
1068
+ }
992
1069
  }
993
1070
 
994
1071
  if (plan.production) {
@@ -1772,7 +1849,7 @@ export async function verifyRelease(options) {
1772
1849
  } : {}),
1773
1850
  sourceRunId: sourceRun.runId,
1774
1851
  sourceRunDigest,
1775
- sourceRunPath,
1852
+ sourceRunPath: resolvedSourceRunPath,
1776
1853
  status: VERIFIED,
1777
1854
  checkpoints: actions.map((a) => {
1778
1855
  const check = adapterChecks.find((c) => c.actionId === a.id);
@@ -58,6 +58,94 @@ export function computeApprovalDigest(rawApproval) {
58
58
  : JSON.stringify(rawApproval, null, 2));
59
59
  }
60
60
 
61
+ /** Maximum approval window: 24 hours. */
62
+ export const MAX_APPROVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
63
+
64
+ /** Clock-skew tolerance for approvedAt: 5 minutes. */
65
+ export const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes
66
+
67
+ /**
68
+ * Validate the time-window semantics shared by every approval record kind
69
+ * (plan-level approvals and v0.6.3 R1 checkpoint-level postPublish hook
70
+ * approvals): well-formed timestamps, expiresAt after approvedAt, a maximum
71
+ * 24h window, approvedAt not in the future beyond 5-minute clock skew, and
72
+ * (unless requireUnexpired === false) an unexpired window at `clock`.
73
+ *
74
+ * @param {{ approvedAt?: string, expiresAt?: string }} approval
75
+ * @param {Object} [options]
76
+ * @param {() => string} [options.clock] Clock function returning ISO-8601 strings.
77
+ * @param {boolean} [options.requireUnexpired] Default true.
78
+ *
79
+ * @throws {ReleaseError} GATE_FAILED on any violation.
80
+ */
81
+ export function validateApprovalTimeWindow(approval, options = {}) {
82
+ const clockFn = typeof options.clock === 'function' ? options.clock : () => new Date().toISOString();
83
+
84
+ const approvedAtDate = new Date(approval.approvedAt);
85
+ if (Number.isNaN(approvedAtDate.getTime())) {
86
+ throw new ReleaseError(
87
+ GATE_FAILED,
88
+ `invalid approvedAt: "${approval.approvedAt}"`,
89
+ { approvedAt: approval.approvedAt },
90
+ );
91
+ }
92
+
93
+ const expiresAtDate = new Date(approval.expiresAt);
94
+ if (Number.isNaN(expiresAtDate.getTime())) {
95
+ throw new ReleaseError(
96
+ GATE_FAILED,
97
+ `invalid expiresAt: "${approval.expiresAt}"`,
98
+ { expiresAt: approval.expiresAt },
99
+ );
100
+ }
101
+
102
+ if (expiresAtDate.getTime() <= approvedAtDate.getTime()) {
103
+ throw new ReleaseError(
104
+ GATE_FAILED,
105
+ `expiresAt (${approval.expiresAt}) must be after approvedAt (${approval.approvedAt})`,
106
+ { approvedAt: approval.approvedAt, expiresAt: approval.expiresAt },
107
+ );
108
+ }
109
+
110
+ // --- Max 24h approval window ---
111
+ const approvalDurationMs = expiresAtDate.getTime() - approvedAtDate.getTime();
112
+ if (approvalDurationMs > MAX_APPROVAL_MS) {
113
+ throw new ReleaseError(
114
+ GATE_FAILED,
115
+ `approval duration ${Math.round(approvalDurationMs / 3600000)}h exceeds maximum 24h`,
116
+ { approvedAt: approval.approvedAt, expiresAt: approval.expiresAt, durationHours: approvalDurationMs / 3600000 },
117
+ );
118
+ }
119
+
120
+ // --- Reject future approvedAt (beyond 5-minute clock skew tolerance) ---
121
+ const now = clockFn();
122
+ const nowDate = new Date(now);
123
+ if (Number.isNaN(nowDate.getTime())) {
124
+ throw new ReleaseError(
125
+ GATE_FAILED,
126
+ `invalid clock value: "${now}"`,
127
+ { clock: now },
128
+ );
129
+ }
130
+ if (approvedAtDate.getTime() > nowDate.getTime() + CLOCK_SKEW_TOLERANCE_MS) {
131
+ throw new ReleaseError(
132
+ GATE_FAILED,
133
+ `approvedAt (${approval.approvedAt}) is in the future (current time: ${now})`,
134
+ { approvedAt: approval.approvedAt, now },
135
+ );
136
+ }
137
+
138
+ // --- Expiry (publish/reconcile require current approval; verify may only
139
+ // revalidate the immutable approval identity after publication) ---
140
+ if (options.requireUnexpired !== false && nowDate > expiresAtDate) {
141
+ throw new ReleaseError(
142
+ GATE_FAILED,
143
+ `approval expired at ${approval.expiresAt}, current time is ${now}`,
144
+ { expiresAt: approval.expiresAt, now },
145
+ );
146
+ }
147
+ }
148
+
61
149
  export function assertImmutableApprovalAuthority(approvalPath, plan, rawApproval) {
62
150
  if (!plan?.production) return;
63
151
  const planDigest = computePlanDigest(plan);
@@ -89,8 +177,6 @@ export function assertImmutableApprovalAuthority(approvalPath, plan, rawApproval
89
177
  * @throws {ReleaseError} GATE_FAILED if any validation check fails.
90
178
  */
91
179
  export function validateApproval(plan, approval, options = {}) {
92
- const clockFn = typeof options.clock === 'function' ? options.clock : () => new Date().toISOString();
93
-
94
180
  // --- Required fields ---
95
181
  if (!approval || typeof approval !== 'object') {
96
182
  throw new ReleaseError(
@@ -290,71 +376,10 @@ export function validateApproval(plan, approval, options = {}) {
290
376
  }
291
377
  }
292
378
 
293
- // --- Time validation ---
294
- const approvedAtDate = new Date(approval.approvedAt);
295
- if (Number.isNaN(approvedAtDate.getTime())) {
296
- throw new ReleaseError(
297
- GATE_FAILED,
298
- `invalid approvedAt: "${approval.approvedAt}"`,
299
- { approvedAt: approval.approvedAt },
300
- );
301
- }
302
-
303
- const expiresAtDate = new Date(approval.expiresAt);
304
- if (Number.isNaN(expiresAtDate.getTime())) {
305
- throw new ReleaseError(
306
- GATE_FAILED,
307
- `invalid expiresAt: "${approval.expiresAt}"`,
308
- { expiresAt: approval.expiresAt },
309
- );
310
- }
311
-
312
- if (expiresAtDate.getTime() <= approvedAtDate.getTime()) {
313
- throw new ReleaseError(
314
- GATE_FAILED,
315
- `expiresAt (${approval.expiresAt}) must be after approvedAt (${approval.approvedAt})`,
316
- { approvedAt: approval.approvedAt, expiresAt: approval.expiresAt },
317
- );
318
- }
319
-
320
- // --- Max 24h approval window ---
321
- const MAX_APPROVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
322
- const approvalDurationMs = expiresAtDate.getTime() - approvedAtDate.getTime();
323
- if (approvalDurationMs > MAX_APPROVAL_MS) {
324
- throw new ReleaseError(
325
- GATE_FAILED,
326
- `approval duration ${Math.round(approvalDurationMs / 3600000)}h exceeds maximum 24h`,
327
- { approvedAt: approval.approvedAt, expiresAt: approval.expiresAt, durationHours: approvalDurationMs / 3600000 },
328
- );
329
- }
330
-
331
- // --- Reject future approvedAt (beyond 5-minute clock skew tolerance) ---
332
- const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes
333
- const now = clockFn();
334
- const nowDate = new Date(now);
335
- if (Number.isNaN(nowDate.getTime())) {
336
- throw new ReleaseError(
337
- GATE_FAILED,
338
- `invalid clock value: "${now}"`,
339
- { clock: now },
340
- );
341
- }
342
- if (approvedAtDate.getTime() > nowDate.getTime() + CLOCK_SKEW_TOLERANCE_MS) {
343
- throw new ReleaseError(
344
- GATE_FAILED,
345
- `approvedAt (${approval.approvedAt}) is in the future (current time: ${now})`,
346
- { approvedAt: approval.approvedAt, now },
347
- );
348
- }
349
-
350
- // --- Expiry (publish/reconcile require current approval; verify may only
351
- // revalidate the immutable approval identity after publication) ---
352
- if (options.requireUnexpired !== false && nowDate > expiresAtDate) {
353
- throw new ReleaseError(
354
- GATE_FAILED,
355
- `approval expired at ${approval.expiresAt}, current time is ${now}`,
356
- { expiresAt: approval.expiresAt, now },
357
- );
358
- }
379
+ // --- Time validation (shared authority: 24h window, 5-minute skew, expiry) ---
380
+ validateApprovalTimeWindow(approval, {
381
+ clock: options.clock,
382
+ requireUnexpired: options.requireUnexpired,
383
+ });
359
384
 
360
385
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Bounded hook-output tails (v0.6.3 R1 tail unification).
3
+ *
4
+ * Single authority for the "last 50 lines, capped at 8 KiB" tail semantics
5
+ * used everywhere a captured child-output stream is persisted as evidence:
6
+ * prepare hook failures (original home) and distribute postPublish hook
7
+ * failures (R1; previously a 4000-character slice). Keeping both call sites
8
+ * on one implementation means triage output can never diverge between the
9
+ * prepare and distribute phases.
10
+ *
11
+ * @module core/bounded-output
12
+ */
13
+
14
+ /** Maximum number of output lines preserved in a hook-failure tail. */
15
+ export const HOOK_OUTPUT_TAIL_MAX_LINES = 50;
16
+ /** Maximum bytes preserved in a hook-failure tail. */
17
+ export const HOOK_OUTPUT_TAIL_MAX_BYTES = 8 * 1024;
18
+
19
+ /**
20
+ * Bound a captured child-output stream to the tail that matters for triage:
21
+ * the last 50 lines, further capped at 8 KB — whichever is smaller.
22
+ *
23
+ * @param {string} [text] - Captured stdout/stderr text.
24
+ * @returns {string} The bounded tail ('' for empty/absent input).
25
+ */
26
+ export function boundedOutputTail(text) {
27
+ if (typeof text !== 'string' || text.length === 0) return '';
28
+ let lines = text.split('\n');
29
+ // A trailing newline produces an empty final element; drop it so the line
30
+ // budget counts real output lines.
31
+ if (lines.length > 1 && lines[lines.length - 1] === '') {
32
+ lines = lines.slice(0, -1);
33
+ }
34
+ let tail = lines.slice(-HOOK_OUTPUT_TAIL_MAX_LINES);
35
+ let joined = tail.join('\n');
36
+ while (tail.length > 1 && Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
37
+ tail = tail.slice(1);
38
+ joined = tail.join('\n');
39
+ }
40
+ if (Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
41
+ // A single line exceeds the byte cap: keep the trailing bytes.
42
+ const buf = Buffer.from(joined, 'utf8');
43
+ joined = buf.subarray(buf.length - HOOK_OUTPUT_TAIL_MAX_BYTES).toString('utf8');
44
+ }
45
+ return joined;
46
+ }