release-skill 0.8.1 → 0.9.1

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 (126) 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 +63 -0
  7. package/INSTALL.md +27 -2
  8. package/INSTALL.zh-CN.md +19 -2
  9. package/README.md +41 -22
  10. package/README.zh-CN.md +34 -20
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/consumer-contract-vectors.json +110 -0
  14. package/adapters/claude/bin/error-codes.json +8 -1
  15. package/adapters/claude/bin/foundation-resource-binding.json +1 -1
  16. package/adapters/claude/bin/registry.json +1 -1
  17. package/adapters/claude/bin/release-skill-local-finish.mjs +4 -0
  18. package/adapters/claude/bin/release-skill.bundle.mjs +3955 -2431
  19. package/adapters/claude/bin/rules.json +1 -1
  20. package/adapters/claude/bin/schemas/consumer-contract-vector.schema.json +149 -0
  21. package/adapters/claude/schemas/release-plan.schema.json +40 -3
  22. package/adapters/claude/skills/release-finish/SKILL.md +65 -0
  23. package/adapters/claude/skills/release-help/SKILL.md +3 -2
  24. package/adapters/claude/skills/release-prepare/SKILL.md +2 -2
  25. package/adapters/claude/skills/release-publish/SKILL.md +3 -1
  26. package/adapters/claude/skills/release-verify/SKILL.md +6 -1
  27. package/adapters/claude/src/schemas/migration-manifest.schema.json +271 -7
  28. package/adapters/claude/src/schemas/profile-adoption-declaration.schema.json +8 -0
  29. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  30. package/adapters/codex/bin/consumer-contract-vectors.json +110 -0
  31. package/adapters/codex/bin/error-codes.json +8 -1
  32. package/adapters/codex/bin/foundation-resource-binding.json +1 -1
  33. package/adapters/codex/bin/registry.json +1 -1
  34. package/adapters/codex/bin/release-skill-local-finish.mjs +4 -0
  35. package/adapters/codex/bin/release-skill.bundle.mjs +3955 -2431
  36. package/adapters/codex/bin/rules.json +1 -1
  37. package/adapters/codex/bin/schemas/consumer-contract-vector.schema.json +149 -0
  38. package/adapters/codex/schemas/release-plan.schema.json +40 -3
  39. package/adapters/codex/skills/release-finish/SKILL.md +72 -0
  40. package/adapters/codex/skills/release-help/SKILL.md +3 -2
  41. package/adapters/codex/skills/release-prepare/SKILL.md +2 -2
  42. package/adapters/codex/skills/release-publish/SKILL.md +3 -1
  43. package/adapters/codex/skills/release-verify/SKILL.md +6 -1
  44. package/adapters/codex/src/schemas/migration-manifest.schema.json +271 -7
  45. package/adapters/codex/src/schemas/profile-adoption-declaration.schema.json +8 -0
  46. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  47. package/adapters/kimi/bin/consumer-contract-vectors.json +110 -0
  48. package/adapters/kimi/bin/error-codes.json +8 -1
  49. package/adapters/kimi/bin/foundation-resource-binding.json +1 -1
  50. package/adapters/kimi/bin/registry.json +1 -1
  51. package/adapters/kimi/bin/release-skill-local-finish.mjs +4 -0
  52. package/adapters/kimi/bin/release-skill.bundle.mjs +3955 -2431
  53. package/adapters/kimi/bin/rules.json +1 -1
  54. package/adapters/kimi/bin/schemas/consumer-contract-vector.schema.json +149 -0
  55. package/adapters/kimi/schemas/release-plan.schema.json +40 -3
  56. package/adapters/kimi/skills/release-finish/SKILL.md +72 -0
  57. package/adapters/kimi/skills/release-help/SKILL.md +3 -2
  58. package/adapters/kimi/skills/release-prepare/SKILL.md +2 -2
  59. package/adapters/kimi/skills/release-publish/SKILL.md +3 -1
  60. package/adapters/kimi/skills/release-verify/SKILL.md +6 -1
  61. package/adapters/kimi/src/schemas/migration-manifest.schema.json +271 -7
  62. package/adapters/kimi/src/schemas/profile-adoption-declaration.schema.json +8 -0
  63. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  64. package/adapters/workbuddy/bin/consumer-contract-vectors.json +110 -0
  65. package/adapters/workbuddy/bin/error-codes.json +8 -1
  66. package/adapters/workbuddy/bin/foundation-resource-binding.json +1 -1
  67. package/adapters/workbuddy/bin/registry.json +1 -1
  68. package/adapters/workbuddy/bin/release-skill-local-finish.mjs +4 -0
  69. package/adapters/workbuddy/bin/release-skill.bundle.mjs +3955 -2431
  70. package/adapters/workbuddy/bin/rules.json +1 -1
  71. package/adapters/workbuddy/bin/schemas/consumer-contract-vector.schema.json +149 -0
  72. package/adapters/workbuddy/schemas/release-plan.schema.json +40 -3
  73. package/adapters/workbuddy/skills/release-finish/SKILL.md +65 -0
  74. package/adapters/workbuddy/skills/release-help/SKILL.md +3 -2
  75. package/adapters/workbuddy/skills/release-prepare/SKILL.md +2 -2
  76. package/adapters/workbuddy/skills/release-publish/SKILL.md +3 -1
  77. package/adapters/workbuddy/skills/release-verify/SKILL.md +6 -1
  78. package/adapters/workbuddy/src/schemas/migration-manifest.schema.json +271 -7
  79. package/adapters/workbuddy/src/schemas/profile-adoption-declaration.schema.json +8 -0
  80. package/bin/consumer-contract-vectors.json +110 -0
  81. package/bin/error-codes.json +8 -1
  82. package/bin/foundation-resource-binding.json +1 -1
  83. package/bin/registry.json +1 -1
  84. package/bin/release-skill-cli.mjs +163 -2
  85. package/bin/release-skill-local-finish.mjs +4 -0
  86. package/bin/release-skill.bundle.mjs +3955 -2431
  87. package/bin/rules.json +1 -1
  88. package/bin/schemas/consumer-contract-vector.schema.json +149 -0
  89. package/package.json +4 -4
  90. package/platform-manifest.json +31 -5
  91. package/schemas/release-plan.schema.json +40 -3
  92. package/skills/release-finish/SKILL.md +65 -0
  93. package/skills/release-help/SKILL.md +3 -2
  94. package/skills/release-prepare/SKILL.md +2 -2
  95. package/skills/release-publish/SKILL.md +3 -1
  96. package/skills/release-verify/SKILL.md +6 -1
  97. package/skills-src/release-finish/SKILL.md +65 -0
  98. package/skills-src/release-help/SKILL.md +3 -2
  99. package/skills-src/release-prepare/SKILL.md +2 -2
  100. package/skills-src/release-publish/SKILL.md +3 -1
  101. package/skills-src/release-verify/SKILL.md +6 -1
  102. package/src/commands/approve.mjs +14 -2
  103. package/src/commands/distribute.mjs +562 -321
  104. package/src/commands/post-release-local.mjs +802 -0
  105. package/src/commands/postverify.mjs +262 -155
  106. package/src/commands/prepare.mjs +106 -56
  107. package/src/commands/publish.mjs +11 -10
  108. package/src/commands/reconcile.mjs +11 -10
  109. package/src/commands/setup.mjs +26 -13
  110. package/src/commands/ship.mjs +169 -28
  111. package/src/commands/verify.mjs +19 -8
  112. package/src/core/adoption-assessment.mjs +0 -3
  113. package/src/core/approval.mjs +11 -8
  114. package/src/core/derived-artifact-gates.mjs +32 -5
  115. package/src/core/hooks.mjs +0 -16
  116. package/src/core/plan.mjs +19 -11
  117. package/src/core/postpublish-approval.mjs +12 -2
  118. package/src/core/postpublish-bundle.mjs +51 -13
  119. package/src/core/postpublish.mjs +146 -6
  120. package/src/core/preset-executor.mjs +53 -0
  121. package/src/core/proposal-inbox.mjs +91 -27
  122. package/src/core/recovery.mjs +27 -12
  123. package/src/producers/build-adapters.mjs +10 -1
  124. package/src/producers/foundation-resource-projection.mjs +16 -3
  125. package/src/schemas/migration-manifest.schema.json +271 -7
  126. package/src/schemas/profile-adoption-declaration.schema.json +8 -0
@@ -28,7 +28,7 @@ const execFile = promisify(execFileCb);
28
28
  import { classifyPathInput, writeFileAtomic } from 'skill-family-harness-node';
29
29
  import { loadProjectConfig } from '../core/config.mjs';
30
30
  import { captureBaseline } from '../core/baseline.mjs';
31
- import { findPostPublishUnitConflict, runHook } from '../core/hooks.mjs';
31
+ import { runHook } from '../core/hooks.mjs';
32
32
  import { computeHookCacheKey, readHookCache, writeHookCache } from '../core/hook-cache.mjs';
33
33
  import {
34
34
  assertExpectedPublicSurface,
@@ -57,7 +57,11 @@ import {
57
57
  } from '../snapshot/frozen.mjs';
58
58
  import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT, BUNDLE_STALE } from '../core/errors.mjs';
59
59
  import { assertBundleFreshness } from '../core/bundle-freshness.mjs';
60
- import { assertAdapterFreshness, assertSelfBootstrapFacts } from '../core/derived-artifact-gates.mjs';
60
+ import {
61
+ assertAdapterFreshness,
62
+ assertPlatformManifestFreshness,
63
+ assertSelfBootstrapFacts,
64
+ } from '../core/derived-artifact-gates.mjs';
61
65
  import { isFoundationPluginVerificationEligible } from '../core/foundation-plugin-verification.mjs';
62
66
  import { PKG_ROOT } from '../core/pkg-root.mjs';
63
67
  import { writeFrozenMarker, FROZEN_MARKER_FILENAME } from '../core/frozen-marker.mjs';
@@ -81,6 +85,7 @@ import {
81
85
  validatePostPublishDeclaration,
82
86
  normalizePostPublishDeclaration,
83
87
  orderNormalizedHooks,
88
+ validatePostPublishHookIdUniqueness,
84
89
  PAYLOAD_SOURCE_TAG_WORKTREE,
85
90
  } from '../core/postpublish.mjs';
86
91
  import { freezeExecutionBundle, bundleRootForAuthorityDir } from '../core/postpublish-bundle.mjs';
@@ -3024,8 +3029,9 @@ export async function prepareRelease(options) {
3024
3029
  // --- Step 1b-fast: version-sensitive derived-artifact fast pre-gates (O1) ---
3025
3030
  // 2026-08-18 investigation §3.2: adapter drift and stale self-bootstrap
3026
3031
  // fact pins used to surface only deep inside the ~80s full test hook.
3027
- // Promote the two exact canonical checks (build-adapters --check and the
3028
- // release-docs-self-bootstrap single-file test) to prepare's earliest
3032
+ // Promote the three exact canonical checks (build-adapters --check,
3033
+ // generate-platform-manifest --check, and the release-docs-self-bootstrap
3034
+ // single-file test) to prepare's earliest
3029
3035
  // stage so the same drift fails closed in seconds, before any hook. Like
3030
3036
  // the bundle freshness gate this is artifact-integrity class: workflow
3031
3037
  // trimming never exempts it. Installed layouts record not-applicable.
@@ -3033,7 +3039,8 @@ export async function prepareRelease(options) {
3033
3039
  // the CLI, plain function calls, and the node:test harness — no
3034
3040
  // environment variable may exempt them (a test seam is never a
3035
3041
  // production switch). Tests that need lightweight fixtures inject the
3036
- // adapterFreshnessFn/selfBootstrapFactsFn seams explicitly in-process.
3042
+ // adapterFreshnessFn/platformManifestFreshnessFn/selfBootstrapFactsFn
3043
+ // seams explicitly in-process.
3037
3044
  await evidence.append({ phase: 'adapter-freshness', status: 'started' });
3038
3045
  const adapterFreshnessFn = options.adapterFreshnessFn ?? assertAdapterFreshness;
3039
3046
  let adapterFreshness;
@@ -3062,6 +3069,35 @@ export async function prepareRelease(options) {
3062
3069
  });
3063
3070
  }
3064
3071
 
3072
+ await evidence.append({ phase: 'platform-manifest-freshness', status: 'started' });
3073
+ const platformManifestFreshnessFn = options.platformManifestFreshnessFn
3074
+ ?? assertPlatformManifestFreshness;
3075
+ let platformManifestFreshness;
3076
+ try {
3077
+ platformManifestFreshness = await platformManifestFreshnessFn(PKG_ROOT);
3078
+ } catch (err) {
3079
+ await evidence.append({
3080
+ phase: 'platform-manifest-freshness',
3081
+ status: 'blocking',
3082
+ reason: err.details?.reason ?? null,
3083
+ error: { code: err.code, message: err.message },
3084
+ });
3085
+ throw err;
3086
+ }
3087
+ if (platformManifestFreshness?.applicable === false) {
3088
+ await evidence.append({
3089
+ phase: 'platform-manifest-freshness',
3090
+ status: 'not-applicable',
3091
+ reason: platformManifestFreshness.reason,
3092
+ });
3093
+ } else {
3094
+ await evidence.append({
3095
+ phase: 'platform-manifest-freshness',
3096
+ status: 'completed',
3097
+ durationMs: platformManifestFreshness?.durationMs ?? null,
3098
+ });
3099
+ }
3100
+
3065
3101
  await evidence.append({ phase: 'self-bootstrap-facts', status: 'started' });
3066
3102
  const selfBootstrapFactsFn = options.selfBootstrapFactsFn ?? assertSelfBootstrapFacts;
3067
3103
  let selfBootstrapFacts;
@@ -3092,38 +3128,31 @@ export async function prepareRelease(options) {
3092
3128
 
3093
3129
  // --- Step 1c: postPublish distribution declaration gate (R1/R2) ---
3094
3130
  // The per-unit postPublish block drives the post-publish distribute
3095
- // command. Validate it here, before any hook, baseline, snapshot, remote
3096
- // check, or plan write, so an unsafe declaration fails closed with zero
3097
- // side effects. This is the runtime re-check on top of the config JSON
3098
- // schema: plans frozen by older schema versions must not be able to
3099
- // smuggle shell strings, option-like executables, or secret-ish env
3100
- // keys through. R2: preset references resolve against the built-in
3101
- // preset registry (per-preset config validation + requiresApproval
3102
- // grading), and targets normalize onto preset hooks — the normalized
3103
- // table is a deterministic projection of the digest-bound declaration,
3104
- // so any list change changes the plan digest and voids approvals. A
3105
- // plan binds exactly one declaration; multiple units declaring
3106
- // postPublish is a hard gate failure.
3131
+ // command. Validate every declaration here, before any hook, baseline,
3132
+ // snapshot, remote check, or plan write, so an unsafe declaration fails
3133
+ // closed with zero side effects. This is the runtime re-check on top of
3134
+ // the config JSON schema: plans frozen by older schema versions must not
3135
+ // be able to smuggle shell strings, option-like executables, or
3136
+ // secret-ish env keys through. R2: preset references resolve against the
3137
+ // built-in preset registry (per-preset config validation +
3138
+ // requiresApproval grading), and targets normalize onto preset hooks —
3139
+ // the normalized table is a deterministic projection of the digest-bound
3140
+ // declaration, so any list change changes the plan digest and voids
3141
+ // approvals. Multiple units may each declare postPublish (multi-release-
3142
+ // unit v3): every declaration is validated individually, in plan.units
3143
+ // order, and the whole project's EXPLICIT hooks[].id must be unique
3144
+ // across units (target and internal probe local ids may repeat; the
3145
+ // (planDigest, hookId) approval contract needs globally unique hook ids).
3107
3146
  const postPublishDeclarations = configUnits
3108
3147
  .map((unit, index) => ({ unit, index }))
3109
3148
  .filter(({ unit }) => unit.postPublish !== undefined);
3110
- if (findPostPublishUnitConflict(configUnits)) {
3111
- throw new ReleaseError(
3112
- GATE_FAILED,
3113
- `multiple units declare postPublish (${postPublishDeclarations.map(({ unit }) => unit.id).join(', ')}); a release plan binds exactly one postPublish declaration`,
3114
- { unitIds: postPublishDeclarations.map(({ unit }) => unit.id) },
3115
- );
3116
- }
3117
- let postPublishDeclaration = null;
3118
- if (postPublishDeclarations.length === 1) {
3119
- const { unit, index } = postPublishDeclarations[0];
3149
+ for (const { unit } of postPublishDeclarations) {
3120
3150
  validatePostPublishDeclaration(unit.postPublish, { unitId: unit.id });
3121
3151
  // Normalized hook table (design §2.2): validate the dependency
3122
3152
  // topology at freeze time too, so a cyclic/dangling declaration can
3123
3153
  // never be frozen for distribute to trip over.
3124
3154
  const normalizedDeclaration = normalizePostPublishDeclaration(unit.postPublish);
3125
3155
  const orderedNormalizedHooks = orderNormalizedHooks(normalizedDeclaration.hooks);
3126
- postPublishDeclaration = { unit, index };
3127
3156
  await evidence.append({
3128
3157
  phase: 'postpublish-declaration',
3129
3158
  status: 'validated',
@@ -3134,6 +3163,19 @@ export async function prepareRelease(options) {
3134
3163
  preGates: normalizedDeclaration.preGates.map((gate) => gate.gate),
3135
3164
  });
3136
3165
  }
3166
+ // Whole-project explicit hooks[].id uniqueness (design §9.2 rule 3;
3167
+ // rework R-02): the (planDigest, hookId) approval contract binds every
3168
+ // explicit hook id plan-wide, so a duplicate across units must fail
3169
+ // before any plan write and before any project hook runs. The single
3170
+ // array-level authority lives in core/postpublish.mjs
3171
+ // validatePostPublishHookIdUniqueness — this entry only normalizes its
3172
+ // input to the declaration array view (config blocks carry no unitId;
3173
+ // the owning unit id is bound here, exactly as the frozen plan does) and
3174
+ // calls it. Per-declaration duplicates are already rejected by
3175
+ // validatePostPublishDeclaration.
3176
+ validatePostPublishHookIdUniqueness(
3177
+ postPublishDeclarations.map(({ unit }) => ({ ...unit.postPublish, unitId: unit.id })),
3178
+ );
3137
3179
 
3138
3180
  // --- Step 2: Hook authorization gate ---
3139
3181
  // Hooks are user-configured arbitrary local processes without filesystem
@@ -3841,20 +3883,21 @@ export async function prepareRelease(options) {
3841
3883
  });
3842
3884
  }
3843
3885
 
3844
- // New prepares emit planVersion 2 (design: t1-2-digest-decoupling.md
3845
- // §4.2/§7). Production freeze timestamps are derived deterministically
3846
- // from the baseline headCommit's committer date, before the first frozen
3847
- // Git object exists. This single canonical value becomes
3848
- // GIT_AUTHOR_DATE/GIT_COMMITTER_DATE for every unit's frozen commit and
3849
- // every unit's frozenSnapshot.commitTimestamp; identical sources freeze
3850
- // byte-identical Git objects on every re-prepare. The wall-clock sample
3851
- // is still validated here (fail closed before any Git write) and becomes
3852
- // plan.createdAt -- record-layer real clock behind the 24h approval
3853
- // window, no longer equal to the freeze timestamp for v2 plans. The v1
3854
- // legacy path used this same sample as the freeze timestamp itself
3855
- // (commitTimestamp == createdAt). publish, retry, and reconcile consume
3856
- // the frozen value from the plan and never re-read the wall clock or
3857
- // re-derive it.
3886
+ // New prepares emit planVersion 3 (multi-release-unit postPublish v3;
3887
+ // v3 inherits the v2 record-layer freeze-timestamp semantics from
3888
+ // t1-2-digest-decoupling.md §4.2/§7). Production freeze timestamps are
3889
+ // derived deterministically from the baseline headCommit's committer
3890
+ // date, before the first frozen Git object exists. This single canonical
3891
+ // value becomes GIT_AUTHOR_DATE/GIT_COMMITTER_DATE for every unit's
3892
+ // frozen commit and every unit's frozenSnapshot.commitTimestamp;
3893
+ // identical sources freeze byte-identical Git objects on every
3894
+ // re-prepare. The wall-clock sample is still validated here (fail closed
3895
+ // before any Git write) and becomes plan.createdAt -- record-layer real
3896
+ // clock behind the 24h approval window, no longer equal to the freeze
3897
+ // timestamp for v2/v3 plans. The v1 legacy path used this same sample as
3898
+ // the freeze timestamp itself (commitTimestamp == createdAt). publish,
3899
+ // retry, and reconcile consume the frozen value from the plan and never
3900
+ // re-read the wall clock or re-derive it.
3858
3901
  const createdAtTimestamp = production
3859
3902
  ? normalizeGitTimestamp(clock ? clock() : new Date().toISOString(), 'plan createdAt timestamp')
3860
3903
  : null;
@@ -4252,16 +4295,20 @@ export async function prepareRelease(options) {
4252
4295
  ? 'foundationPayloadThenManualFollowUps'
4253
4296
  : 'manualFollowUps';
4254
4297
 
4255
- // --- Fold the validated postPublish declaration into the frozen plan ---
4256
- // Bindings: tag (tagTemplate rendered at the resolved target version
4257
- // the same computation create-tag will use), tagCommit (the frozen
4258
- // production asset commit the tag will point at; only production
4298
+ // --- Fold the validated postPublish declarations into the frozen plan ---
4299
+ // Multi-release-unit v3 contract: the frozen field is ALWAYS an array,
4300
+ // one entry per declaring unit in plan.units order (zero declarations
4301
+ // freeze the empty array, which never enters post-release execution).
4302
+ // Bindings per entry: tag (tagTemplate rendered at the resolved target
4303
+ // version — the same computation create-tag will use), tagCommit (the
4304
+ // frozen production asset commit the tag will point at; only production
4259
4305
  // prepares can freeze it — distribute fails closed when it is absent),
4260
4306
  // unitId (declaring unit), and payloadSource "tag-worktree" (R1 timing
4261
4307
  // contract: payload may only come from the detached worktree at
4262
- // tagCommit, never from workspace state). planVersion 2 record-layer
4263
- // stripping does not strip this block, so every declaration detail is
4264
- // bound into the plan digest.
4308
+ // tagCommit, never from workspace state). The array and every entry
4309
+ // participate in the plan digest through the existing digest mechanism
4310
+ // (no per-entry summaries, maps, or second manifest), so declaration
4311
+ // order and any bound field change change the plan digest.
4265
4312
  //
4266
4313
  // F-01 / T1 private execution bundle: parent-workspace files that the
4267
4314
  // post-publish commands need but the frozen tag does not contain are
@@ -4271,9 +4318,8 @@ export async function prepareRelease(options) {
4271
4318
  // plan is the bundle's only source of truth (no parallel manifest, no
4272
4319
  // second bundle digest): the raw executionFiles list folds into the
4273
4320
  // closure and is NOT duplicated into the frozen block.
4274
- let frozenPostPublish = null;
4275
- if (postPublishDeclaration) {
4276
- const { unit, index } = postPublishDeclaration;
4321
+ const frozenPostPublish = [];
4322
+ for (const { unit, index } of postPublishDeclarations) {
4277
4323
  const { tag } = resolveProductionBranch(unit, resolvedVersions[index]);
4278
4324
  const declaredExecutionFiles = unit.postPublish.executionFiles ?? [];
4279
4325
  const frozenTagPaths = productionAssets
@@ -4300,18 +4346,22 @@ export async function prepareRelease(options) {
4300
4346
  bundleRoot: relative(realRoot, bundleRootForAuthorityDir(releaseDir)),
4301
4347
  });
4302
4348
  const { executionFiles: _executionFiles, ...declarationWithoutManifest } = structuredClone(unit.postPublish);
4303
- frozenPostPublish = {
4349
+ frozenPostPublish.push({
4304
4350
  ...declarationWithoutManifest,
4305
4351
  tag,
4306
4352
  ...(productionAssets ? { tagCommit: productionAssets[index].commit } : {}),
4307
4353
  unitId: unit.id,
4308
4354
  payloadSource: PAYLOAD_SOURCE_TAG_WORKTREE,
4309
4355
  executionBundle,
4310
- };
4356
+ });
4311
4357
  }
4312
4358
 
4313
4359
  const plan = {
4314
- planVersion: 2,
4360
+ // New prepares emit planVersion 3 (multi-release-unit postPublish v3):
4361
+ // postPublish is the declaration ARRAY (possibly empty). v3 inherits
4362
+ // the v2 record-layer digest and approval semantics; only the
4363
+ // postPublish shape changes.
4364
+ planVersion: 3,
4315
4365
  status: 'PREPARED',
4316
4366
  // Workflow profile (H5): 'full' for the complete gate set;
4317
4367
  // 'docs'/'config'/'marketplace' for trimmed code-class gates. Both
@@ -4352,7 +4402,7 @@ export async function prepareRelease(options) {
4352
4402
  units,
4353
4403
  externalActions,
4354
4404
  ...(publicSourceAuthorityReceipt ? { publicSourceAuthorityReceipt } : {}),
4355
- ...(frozenPostPublish ? { postPublish: frozenPostPublish } : {}),
4405
+ postPublish: frozenPostPublish,
4356
4406
  ...(sourceAuthority ? { sourceAuthority } : {}),
4357
4407
  createdAt: production ? createdAtTimestamp : (clock ? clock() : new Date().toISOString()),
4358
4408
  };
@@ -791,21 +791,22 @@ export async function publishRelease(options) {
791
791
 
792
792
  const currentBaseline = await captureBaselineActual(root);
793
793
 
794
- // planVersion fork (design: t1-2-digest-decoupling.md §4.3): for v2
795
- // plans the baseline is record-layer audit data. Drift is recorded as a
796
- // warning and execution continues -- artifact integrity is sealed by the
797
- // frozen-artifact re-verification above, never by workspace equality.
798
- // v1 plans keep the BASELINE_CHANGED hard failure, byte for byte.
799
- const planV2 = plan.planVersion === 2;
794
+ // planVersion fork (design: t1-2-digest-decoupling.md §4.3): for
795
+ // planVersion 2/3 plans (v3 inherits the v2 semantics per 多发布单元
796
+ // postPublish v3 §4.2) the baseline is record-layer audit data. Drift is
797
+ // recorded as a warning and execution continues -- artifact integrity is
798
+ // sealed by the frozen-artifact re-verification above, never by workspace
799
+ // equality. v1 plans keep the BASELINE_CHANGED hard failure, byte for byte.
800
+ const recordLayerBaseline = plan.planVersion === 2 || plan.planVersion === 3;
800
801
 
801
802
  if (currentBaseline.gitTreeHash !== plan.baseline.gitTreeHash) {
802
- if (planV2) {
803
+ if (recordLayerBaseline) {
803
804
  await evidence.append({
804
805
  phase: 'safety-gate',
805
806
  gate: 'baseline-check',
806
807
  status: 'warning',
807
808
  severity: 'warning',
808
- reason: 'planVersion 2: baseline drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
809
+ reason: 'planVersion 2/3: baseline drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
809
810
  planTreeHash: plan.baseline.gitTreeHash,
810
811
  currentTreeHash: currentBaseline.gitTreeHash,
811
812
  });
@@ -831,13 +832,13 @@ export async function publishRelease(options) {
831
832
  plan.baseline.workspaceDigest &&
832
833
  currentBaseline.workspaceDigest !== plan.baseline.workspaceDigest
833
834
  ) {
834
- if (planV2) {
835
+ if (recordLayerBaseline) {
835
836
  await evidence.append({
836
837
  phase: 'safety-gate',
837
838
  gate: 'baseline-check',
838
839
  status: 'warning',
839
840
  severity: 'warning',
840
- reason: 'planVersion 2: workspace digest drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
841
+ reason: 'planVersion 2/3: workspace digest drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
841
842
  planWorkspaceDigest: plan.baseline.workspaceDigest,
842
843
  currentWorkspaceDigest: currentBaseline.workspaceDigest,
843
844
  });
@@ -357,21 +357,22 @@ export async function reconcileRelease(options) {
357
357
 
358
358
  const currentBaseline = await captureBaselineActual(root);
359
359
 
360
- // planVersion fork (design: t1-2-digest-decoupling.md §4.3): for v2
361
- // plans the baseline is record-layer audit data. Drift is recorded as a
362
- // warning and execution continues -- artifact integrity is sealed by the
363
- // frozen-artifact re-verification, never by workspace equality. v1 plans
364
- // keep the BASELINE_CHANGED hard failure, byte for byte.
365
- const planV2 = plan.planVersion === 2;
360
+ // planVersion fork (design: t1-2-digest-decoupling.md §4.3): for
361
+ // planVersion 2/3 plans (v3 inherits the v2 semantics per 多发布单元
362
+ // postPublish v3 §4.2) the baseline is record-layer audit data. Drift is
363
+ // recorded as a warning and execution continues -- artifact integrity is
364
+ // sealed by the frozen-artifact re-verification, never by workspace
365
+ // equality. v1 plans keep the BASELINE_CHANGED hard failure, byte for byte.
366
+ const recordLayerBaseline = plan.planVersion === 2 || plan.planVersion === 3;
366
367
 
367
368
  if (currentBaseline.gitTreeHash !== plan.baseline.gitTreeHash) {
368
- if (planV2) {
369
+ if (recordLayerBaseline) {
369
370
  await evidence.append({
370
371
  phase: 'safety-gate',
371
372
  gate: 'baseline-check',
372
373
  status: 'warning',
373
374
  severity: 'warning',
374
- reason: 'planVersion 2: baseline drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
375
+ reason: 'planVersion 2/3: baseline drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
375
376
  planTreeHash: plan.baseline.gitTreeHash,
376
377
  currentTreeHash: currentBaseline.gitTreeHash,
377
378
  });
@@ -396,13 +397,13 @@ export async function reconcileRelease(options) {
396
397
  plan.baseline.workspaceDigest &&
397
398
  currentBaseline.workspaceDigest !== plan.baseline.workspaceDigest
398
399
  ) {
399
- if (planV2) {
400
+ if (recordLayerBaseline) {
400
401
  await evidence.append({
401
402
  phase: 'safety-gate',
402
403
  gate: 'baseline-check',
403
404
  status: 'warning',
404
405
  severity: 'warning',
405
- reason: 'planVersion 2: workspace digest drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
406
+ reason: 'planVersion 2/3: workspace digest drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
406
407
  planWorkspaceDigest: plan.baseline.workspaceDigest,
407
408
  currentWorkspaceDigest: currentBaseline.workspaceDigest,
408
409
  });
@@ -34,7 +34,7 @@ import {
34
34
  } from '../core/errors.mjs';
35
35
  import { readTrustedPackageResource } from '../core/trusted-resource.mjs';
36
36
  import { validatePresetHook } from '../core/presets.mjs';
37
- import { validatePostPublishDeclaration } from '../core/postpublish.mjs';
37
+ import { validatePostPublishDeclaration, validatePostPublishHookIdUniqueness } from '../core/postpublish.mjs';
38
38
  import { loadProjectConfig } from '../core/config.mjs';
39
39
  import { resolveProducerVersion } from '../core/evidence.mjs';
40
40
  import { validateHook } from '../core/hooks.mjs';
@@ -46,7 +46,6 @@ import {
46
46
  deriveGateSuggestions,
47
47
  deriveHookDurations,
48
48
  deriveLongHookSuggestions,
49
- findPostPublishUnitConflict,
50
49
  scanGateDeclarationFindings,
51
50
  FINDING_CATEGORY,
52
51
  ASSESSMENT_STATUS,
@@ -3101,17 +3100,31 @@ export async function assessAdoption({ root } = {}) {
3101
3100
  evidence: { configDigest },
3102
3101
  }));
3103
3102
 
3104
- // 裁决 20: 跨单元 postPublish 规则(一次发布计划只绑定一个声明)。
3105
- const postPublishConflict = findPostPublishUnitConflict(declaredUnits);
3106
- if (postPublishConflict) {
3107
- findings.push(createFinding({
3108
- category: FINDING_CATEGORY.MANDATORY_GAP, code: 'POSTPUBLISH_MULTI_UNIT_CONFLICT',
3109
- fieldPath: 'releaseUnits[].postPublish',
3110
- message: `多个发布单元声明 postPublish(${postPublishConflict.unitIds.join(', ')});一次发布计划只绑定一个 postPublish 声明。`,
3111
- evidence: { unitIds: postPublishConflict.unitIds },
3112
- action: '只保留一个发布单元的 postPublish 声明(或将分发目标合并进同一单元)后重新评估。',
3113
- severity: 'blocking',
3114
- }));
3103
+ // 多发布单元 postPublish v3(设计 §9.2/§9.7;返工 R-02):多个发布单元
3104
+ // 各自声明 postPublish 本身不再构成缺口——每个声明的字段级校验已在上面
3105
+ // 逐项完成。仍失败关闭的只有:声明内容无效(POSTPUBLISH_INVALID,逐项
3106
+ // 报告)与整份计划显式 hooks[].id 跨单元重复((planDigest, hookId) 批准
3107
+ // 合同要求全局唯一;target 与内部 probe 的本地 ID 可跨单元重复,检查点
3108
+ // 以 unitId 区分)。唯一校验真源是 core/postpublish.mjs 的
3109
+ // validatePostPublishHookIdUniqueness——本入口只做输入归一(收集各单元的
3110
+ // 声明块)并调用同一函数,再把领域事实转成评估 finding。
3111
+ const declaredPostPublishBlocks = declaredUnits
3112
+ .filter((unit) => unit.postPublish !== undefined)
3113
+ .map((unit) => ({ ...unit.postPublish, unitId: unit.id }));
3114
+ try {
3115
+ validatePostPublishHookIdUniqueness(declaredPostPublishBlocks);
3116
+ } catch (error) {
3117
+ if (error?.details?.hookId !== undefined && Array.isArray(error?.details?.unitIds)) {
3118
+ const [owner, unitId] = error.details.unitIds;
3119
+ findings.push(createFinding({
3120
+ category: FINDING_CATEGORY.MANDATORY_GAP, code: 'POSTPUBLISH_HOOK_ID_DUPLICATE',
3121
+ unitId, fieldPath: 'releaseUnits[].postPublish.hooks[].id',
3122
+ message: `postPublish 显式 hook id "${error.details.hookId}" 同时由发布单元 "${owner}" 与 "${unitId}" 声明;整份计划的显式 hooks[].id 必须唯一(target 与内部 probe 的本地 ID 可跨单元重复)。`,
3123
+ evidence: { hookId: error.details.hookId, unitIds: error.details.unitIds },
3124
+ action: '为重复的显式 hook id 改名(或合并声明)后重新评估。',
3125
+ severity: 'blocking',
3126
+ }));
3127
+ }
3115
3128
  }
3116
3129
 
3117
3130
  // 裁决 20: 评估复用实际 Hook 校验(core/hooks.mjs 的纯校验函数,不调用