release-skill 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +65 -7
  7. package/INSTALL.md +14 -2
  8. package/INSTALL.zh-CN.md +12 -2
  9. package/README.md +51 -31
  10. package/README.zh-CN.md +33 -20
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/error-codes.json +1 -1
  14. package/adapters/claude/bin/foundation-resource-binding.json +1 -1
  15. package/adapters/claude/bin/prebuild-manifest.json +8 -4
  16. package/adapters/claude/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  17. package/adapters/claude/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  18. package/adapters/claude/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  19. package/adapters/claude/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  20. package/adapters/claude/bin/registry.json +22 -1
  21. package/adapters/claude/bin/release-skill-local-finish.mjs +4 -0
  22. package/adapters/claude/bin/release-skill.bundle.mjs +7152 -4231
  23. package/adapters/claude/bin/rules.json +1 -1
  24. package/adapters/claude/schemas/release-plan.schema.json +43 -5
  25. package/adapters/claude/schemas/release-run.schema.json +35 -1
  26. package/adapters/claude/skills/release-finish/SKILL.md +65 -0
  27. package/adapters/claude/skills/release-verify/SKILL.md +6 -1
  28. package/adapters/claude/src/schemas/filesystem-tree-observation.schema.json +55 -0
  29. package/adapters/claude/src/schemas/host-verification-result.schema.json +2 -2
  30. package/adapters/claude/src/schemas/plugin-verification-request.schema.json +374 -0
  31. package/adapters/claude/src/schemas/plugin-verification-result.schema.json +799 -0
  32. package/adapters/claude/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  33. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  34. package/adapters/codex/bin/error-codes.json +1 -1
  35. package/adapters/codex/bin/foundation-resource-binding.json +1 -1
  36. package/adapters/codex/bin/prebuild-manifest.json +8 -4
  37. package/adapters/codex/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  38. package/adapters/codex/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  39. package/adapters/codex/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  40. package/adapters/codex/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  41. package/adapters/codex/bin/registry.json +22 -1
  42. package/adapters/codex/bin/release-skill-local-finish.mjs +4 -0
  43. package/adapters/codex/bin/release-skill.bundle.mjs +7152 -4231
  44. package/adapters/codex/bin/rules.json +1 -1
  45. package/adapters/codex/schemas/release-plan.schema.json +43 -5
  46. package/adapters/codex/schemas/release-run.schema.json +35 -1
  47. package/adapters/codex/skills/release-finish/SKILL.md +72 -0
  48. package/adapters/codex/skills/release-verify/SKILL.md +6 -1
  49. package/adapters/codex/src/schemas/filesystem-tree-observation.schema.json +55 -0
  50. package/adapters/codex/src/schemas/host-verification-result.schema.json +2 -2
  51. package/adapters/codex/src/schemas/plugin-verification-request.schema.json +374 -0
  52. package/adapters/codex/src/schemas/plugin-verification-result.schema.json +799 -0
  53. package/adapters/codex/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  54. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  55. package/adapters/kimi/bin/error-codes.json +1 -1
  56. package/adapters/kimi/bin/foundation-resource-binding.json +1 -1
  57. package/adapters/kimi/bin/prebuild-manifest.json +8 -4
  58. package/adapters/kimi/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  59. package/adapters/kimi/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  60. package/adapters/kimi/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  61. package/adapters/kimi/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  62. package/adapters/kimi/bin/registry.json +22 -1
  63. package/adapters/kimi/bin/release-skill-local-finish.mjs +4 -0
  64. package/adapters/kimi/bin/release-skill.bundle.mjs +7152 -4231
  65. package/adapters/kimi/bin/rules.json +1 -1
  66. package/adapters/kimi/schemas/release-plan.schema.json +43 -5
  67. package/adapters/kimi/schemas/release-run.schema.json +35 -1
  68. package/adapters/kimi/skills/release-finish/SKILL.md +72 -0
  69. package/adapters/kimi/skills/release-verify/SKILL.md +6 -1
  70. package/adapters/kimi/src/schemas/filesystem-tree-observation.schema.json +55 -0
  71. package/adapters/kimi/src/schemas/host-verification-result.schema.json +2 -2
  72. package/adapters/kimi/src/schemas/plugin-verification-request.schema.json +374 -0
  73. package/adapters/kimi/src/schemas/plugin-verification-result.schema.json +799 -0
  74. package/adapters/kimi/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  75. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  76. package/adapters/workbuddy/bin/error-codes.json +1 -1
  77. package/adapters/workbuddy/bin/foundation-resource-binding.json +1 -1
  78. package/adapters/workbuddy/bin/prebuild-manifest.json +8 -4
  79. package/adapters/workbuddy/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  80. package/adapters/workbuddy/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  81. package/adapters/workbuddy/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  82. package/adapters/workbuddy/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  83. package/adapters/workbuddy/bin/registry.json +22 -1
  84. package/adapters/workbuddy/bin/release-skill-local-finish.mjs +4 -0
  85. package/adapters/workbuddy/bin/release-skill.bundle.mjs +7152 -4231
  86. package/adapters/workbuddy/bin/rules.json +1 -1
  87. package/adapters/workbuddy/schemas/release-plan.schema.json +43 -5
  88. package/adapters/workbuddy/schemas/release-run.schema.json +35 -1
  89. package/adapters/workbuddy/skills/release-finish/SKILL.md +65 -0
  90. package/adapters/workbuddy/skills/release-verify/SKILL.md +6 -1
  91. package/adapters/workbuddy/src/schemas/filesystem-tree-observation.schema.json +55 -0
  92. package/adapters/workbuddy/src/schemas/host-verification-result.schema.json +2 -2
  93. package/adapters/workbuddy/src/schemas/plugin-verification-request.schema.json +374 -0
  94. package/adapters/workbuddy/src/schemas/plugin-verification-result.schema.json +799 -0
  95. package/adapters/workbuddy/src/schemas/watchdog-termination-envelope.schema.json +16 -0
  96. package/bin/error-codes.json +1 -1
  97. package/bin/foundation-resource-binding.json +1 -1
  98. package/bin/prebuild-manifest.json +8 -4
  99. package/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
  100. package/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
  101. package/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
  102. package/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
  103. package/bin/registry.json +22 -1
  104. package/bin/release-skill-cli.mjs +126 -1
  105. package/bin/release-skill-local-finish.mjs +4 -0
  106. package/bin/release-skill.bundle.mjs +7152 -4231
  107. package/bin/rules.json +1 -1
  108. package/package.json +4 -4
  109. package/platform-manifest.json +30 -4
  110. package/schemas/release-plan.schema.json +43 -5
  111. package/schemas/release-run.schema.json +35 -1
  112. package/skills/release-finish/SKILL.md +65 -0
  113. package/skills/release-verify/SKILL.md +6 -1
  114. package/skills-src/release-finish/SKILL.md +65 -0
  115. package/skills-src/release-verify/SKILL.md +6 -1
  116. package/src/commands/approve.mjs +14 -2
  117. package/src/commands/distribute.mjs +562 -321
  118. package/src/commands/post-release-local.mjs +611 -0
  119. package/src/commands/postverify.mjs +262 -155
  120. package/src/commands/prepare.mjs +83 -57
  121. package/src/commands/publish.mjs +11 -10
  122. package/src/commands/reconcile.mjs +11 -10
  123. package/src/commands/setup.mjs +26 -13
  124. package/src/commands/ship.mjs +45 -12
  125. package/src/commands/verify.mjs +81 -18
  126. package/src/core/adoption-assessment.mjs +0 -3
  127. package/src/core/approval.mjs +11 -8
  128. package/src/core/foundation-plugin-verification.mjs +247 -0
  129. package/src/core/hooks.mjs +0 -16
  130. package/src/core/plan.mjs +19 -11
  131. package/src/core/postpublish-approval.mjs +12 -2
  132. package/src/core/postpublish-bundle.mjs +51 -13
  133. package/src/core/postpublish.mjs +146 -6
  134. package/src/core/preset-executor.mjs +53 -0
  135. package/src/core/proposal-inbox.mjs +91 -27
  136. package/src/core/recovery.mjs +27 -12
  137. package/src/core/skill-resource-closure.mjs +8 -4
  138. package/src/producers/build-adapters.mjs +10 -1
  139. package/src/producers/foundation-resource-projection.mjs +3 -3
  140. package/src/schemas/filesystem-tree-observation.schema.json +55 -0
  141. package/src/schemas/host-verification-result.schema.json +2 -2
  142. package/src/schemas/plugin-verification-request.schema.json +374 -0
  143. package/src/schemas/plugin-verification-result.schema.json +799 -0
  144. package/src/schemas/watchdog-termination-envelope.schema.json +16 -0
@@ -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,
@@ -58,6 +58,7 @@ import {
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
60
  import { assertAdapterFreshness, assertSelfBootstrapFacts } from '../core/derived-artifact-gates.mjs';
61
+ import { isFoundationPluginVerificationEligible } from '../core/foundation-plugin-verification.mjs';
61
62
  import { PKG_ROOT } from '../core/pkg-root.mjs';
62
63
  import { writeFrozenMarker, FROZEN_MARKER_FILENAME } from '../core/frozen-marker.mjs';
63
64
  import {
@@ -80,6 +81,7 @@ import {
80
81
  validatePostPublishDeclaration,
81
82
  normalizePostPublishDeclaration,
82
83
  orderNormalizedHooks,
84
+ validatePostPublishHookIdUniqueness,
83
85
  PAYLOAD_SOURCE_TAG_WORKTREE,
84
86
  } from '../core/postpublish.mjs';
85
87
  import { freezeExecutionBundle, bundleRootForAuthorityDir } from '../core/postpublish-bundle.mjs';
@@ -3091,38 +3093,31 @@ export async function prepareRelease(options) {
3091
3093
 
3092
3094
  // --- Step 1c: postPublish distribution declaration gate (R1/R2) ---
3093
3095
  // The per-unit postPublish block drives the post-publish distribute
3094
- // command. Validate it here, before any hook, baseline, snapshot, remote
3095
- // check, or plan write, so an unsafe declaration fails closed with zero
3096
- // side effects. This is the runtime re-check on top of the config JSON
3097
- // schema: plans frozen by older schema versions must not be able to
3098
- // smuggle shell strings, option-like executables, or secret-ish env
3099
- // keys through. R2: preset references resolve against the built-in
3100
- // preset registry (per-preset config validation + requiresApproval
3101
- // grading), and targets normalize onto preset hooks — the normalized
3102
- // table is a deterministic projection of the digest-bound declaration,
3103
- // so any list change changes the plan digest and voids approvals. A
3104
- // plan binds exactly one declaration; multiple units declaring
3105
- // postPublish is a hard gate failure.
3096
+ // command. Validate every declaration here, before any hook, baseline,
3097
+ // snapshot, remote check, or plan write, so an unsafe declaration fails
3098
+ // closed with zero side effects. This is the runtime re-check on top of
3099
+ // the config JSON schema: plans frozen by older schema versions must not
3100
+ // be able to smuggle shell strings, option-like executables, or
3101
+ // secret-ish env keys through. R2: preset references resolve against the
3102
+ // built-in preset registry (per-preset config validation +
3103
+ // requiresApproval grading), and targets normalize onto preset hooks —
3104
+ // the normalized table is a deterministic projection of the digest-bound
3105
+ // declaration, so any list change changes the plan digest and voids
3106
+ // approvals. Multiple units may each declare postPublish (multi-release-
3107
+ // unit v3): every declaration is validated individually, in plan.units
3108
+ // order, and the whole project's EXPLICIT hooks[].id must be unique
3109
+ // across units (target and internal probe local ids may repeat; the
3110
+ // (planDigest, hookId) approval contract needs globally unique hook ids).
3106
3111
  const postPublishDeclarations = configUnits
3107
3112
  .map((unit, index) => ({ unit, index }))
3108
3113
  .filter(({ unit }) => unit.postPublish !== undefined);
3109
- if (findPostPublishUnitConflict(configUnits)) {
3110
- throw new ReleaseError(
3111
- GATE_FAILED,
3112
- `multiple units declare postPublish (${postPublishDeclarations.map(({ unit }) => unit.id).join(', ')}); a release plan binds exactly one postPublish declaration`,
3113
- { unitIds: postPublishDeclarations.map(({ unit }) => unit.id) },
3114
- );
3115
- }
3116
- let postPublishDeclaration = null;
3117
- if (postPublishDeclarations.length === 1) {
3118
- const { unit, index } = postPublishDeclarations[0];
3114
+ for (const { unit } of postPublishDeclarations) {
3119
3115
  validatePostPublishDeclaration(unit.postPublish, { unitId: unit.id });
3120
3116
  // Normalized hook table (design §2.2): validate the dependency
3121
3117
  // topology at freeze time too, so a cyclic/dangling declaration can
3122
3118
  // never be frozen for distribute to trip over.
3123
3119
  const normalizedDeclaration = normalizePostPublishDeclaration(unit.postPublish);
3124
3120
  const orderedNormalizedHooks = orderNormalizedHooks(normalizedDeclaration.hooks);
3125
- postPublishDeclaration = { unit, index };
3126
3121
  await evidence.append({
3127
3122
  phase: 'postpublish-declaration',
3128
3123
  status: 'validated',
@@ -3133,6 +3128,19 @@ export async function prepareRelease(options) {
3133
3128
  preGates: normalizedDeclaration.preGates.map((gate) => gate.gate),
3134
3129
  });
3135
3130
  }
3131
+ // Whole-project explicit hooks[].id uniqueness (design §9.2 rule 3;
3132
+ // rework R-02): the (planDigest, hookId) approval contract binds every
3133
+ // explicit hook id plan-wide, so a duplicate across units must fail
3134
+ // before any plan write and before any project hook runs. The single
3135
+ // array-level authority lives in core/postpublish.mjs
3136
+ // validatePostPublishHookIdUniqueness — this entry only normalizes its
3137
+ // input to the declaration array view (config blocks carry no unitId;
3138
+ // the owning unit id is bound here, exactly as the frozen plan does) and
3139
+ // calls it. Per-declaration duplicates are already rejected by
3140
+ // validatePostPublishDeclaration.
3141
+ validatePostPublishHookIdUniqueness(
3142
+ postPublishDeclarations.map(({ unit }) => ({ ...unit.postPublish, unitId: unit.id })),
3143
+ );
3136
3144
 
3137
3145
  // --- Step 2: Hook authorization gate ---
3138
3146
  // Hooks are user-configured arbitrary local processes without filesystem
@@ -3840,20 +3848,21 @@ export async function prepareRelease(options) {
3840
3848
  });
3841
3849
  }
3842
3850
 
3843
- // New prepares emit planVersion 2 (design: t1-2-digest-decoupling.md
3844
- // §4.2/§7). Production freeze timestamps are derived deterministically
3845
- // from the baseline headCommit's committer date, before the first frozen
3846
- // Git object exists. This single canonical value becomes
3847
- // GIT_AUTHOR_DATE/GIT_COMMITTER_DATE for every unit's frozen commit and
3848
- // every unit's frozenSnapshot.commitTimestamp; identical sources freeze
3849
- // byte-identical Git objects on every re-prepare. The wall-clock sample
3850
- // is still validated here (fail closed before any Git write) and becomes
3851
- // plan.createdAt -- record-layer real clock behind the 24h approval
3852
- // window, no longer equal to the freeze timestamp for v2 plans. The v1
3853
- // legacy path used this same sample as the freeze timestamp itself
3854
- // (commitTimestamp == createdAt). publish, retry, and reconcile consume
3855
- // the frozen value from the plan and never re-read the wall clock or
3856
- // re-derive it.
3851
+ // New prepares emit planVersion 3 (multi-release-unit postPublish v3;
3852
+ // v3 inherits the v2 record-layer freeze-timestamp semantics from
3853
+ // t1-2-digest-decoupling.md §4.2/§7). Production freeze timestamps are
3854
+ // derived deterministically from the baseline headCommit's committer
3855
+ // date, before the first frozen Git object exists. This single canonical
3856
+ // value becomes GIT_AUTHOR_DATE/GIT_COMMITTER_DATE for every unit's
3857
+ // frozen commit and every unit's frozenSnapshot.commitTimestamp;
3858
+ // identical sources freeze byte-identical Git objects on every
3859
+ // re-prepare. The wall-clock sample is still validated here (fail closed
3860
+ // before any Git write) and becomes plan.createdAt -- record-layer real
3861
+ // clock behind the 24h approval window, no longer equal to the freeze
3862
+ // timestamp for v2/v3 plans. The v1 legacy path used this same sample as
3863
+ // the freeze timestamp itself (commitTimestamp == createdAt). publish,
3864
+ // retry, and reconcile consume the frozen value from the plan and never
3865
+ // re-read the wall clock or re-derive it.
3857
3866
  const createdAtTimestamp = production
3858
3867
  ? normalizeGitTimestamp(clock ? clock() : new Date().toISOString(), 'plan createdAt timestamp')
3859
3868
  : null;
@@ -4235,22 +4244,36 @@ export async function prepareRelease(options) {
4235
4244
  });
4236
4245
  }
4237
4246
 
4238
- // Detect human consumer platforms (Kimi/CodeBuddy) in the plan.
4239
- // When present, new plans mark them as non-blocking manual follow-up tasks.
4240
- const hasHumanConsumerActions = externalActions.some(
4247
+ // Detect human consumer platforms (Kimi/CodeBuddy) in the plan. Foundation
4248
+ // 0.13 can observe their complete local payload only when production has
4249
+ // frozen a bundled-family source. Standalone marketplace sources remain on
4250
+ // the legacy manual-follow-up path because Foundation exposes no public
4251
+ // Kimi/WorkBuddy public-channel driver in this release.
4252
+ const humanConsumerActions = externalActions.filter(
4241
4253
  (a) => a.type === 'kimi-marketplace-install' || a.type === 'codebuddy-marketplace-install',
4242
4254
  );
4243
-
4244
- // --- Fold the validated postPublish declaration into the frozen plan ---
4245
- // Bindings: tag (tagTemplate rendered at the resolved target version —
4246
- // the same computation create-tag will use), tagCommit (the frozen
4247
- // production asset commit the tag will point at; only production
4255
+ const humanConsumersStrategy = humanConsumerActions.length === 0
4256
+ ? null
4257
+ : humanConsumerActions.some((action) => (
4258
+ isFoundationPluginVerificationEligible({ action, units })
4259
+ ))
4260
+ ? 'foundationPayloadThenManualFollowUps'
4261
+ : 'manualFollowUps';
4262
+
4263
+ // --- Fold the validated postPublish declarations into the frozen plan ---
4264
+ // Multi-release-unit v3 contract: the frozen field is ALWAYS an array,
4265
+ // one entry per declaring unit in plan.units order (zero declarations
4266
+ // freeze the empty array, which never enters post-release execution).
4267
+ // Bindings per entry: tag (tagTemplate rendered at the resolved target
4268
+ // version — the same computation create-tag will use), tagCommit (the
4269
+ // frozen production asset commit the tag will point at; only production
4248
4270
  // prepares can freeze it — distribute fails closed when it is absent),
4249
4271
  // unitId (declaring unit), and payloadSource "tag-worktree" (R1 timing
4250
4272
  // contract: payload may only come from the detached worktree at
4251
- // tagCommit, never from workspace state). planVersion 2 record-layer
4252
- // stripping does not strip this block, so every declaration detail is
4253
- // bound into the plan digest.
4273
+ // tagCommit, never from workspace state). The array and every entry
4274
+ // participate in the plan digest through the existing digest mechanism
4275
+ // (no per-entry summaries, maps, or second manifest), so declaration
4276
+ // order and any bound field change change the plan digest.
4254
4277
  //
4255
4278
  // F-01 / T1 private execution bundle: parent-workspace files that the
4256
4279
  // post-publish commands need but the frozen tag does not contain are
@@ -4260,9 +4283,8 @@ export async function prepareRelease(options) {
4260
4283
  // plan is the bundle's only source of truth (no parallel manifest, no
4261
4284
  // second bundle digest): the raw executionFiles list folds into the
4262
4285
  // closure and is NOT duplicated into the frozen block.
4263
- let frozenPostPublish = null;
4264
- if (postPublishDeclaration) {
4265
- const { unit, index } = postPublishDeclaration;
4286
+ const frozenPostPublish = [];
4287
+ for (const { unit, index } of postPublishDeclarations) {
4266
4288
  const { tag } = resolveProductionBranch(unit, resolvedVersions[index]);
4267
4289
  const declaredExecutionFiles = unit.postPublish.executionFiles ?? [];
4268
4290
  const frozenTagPaths = productionAssets
@@ -4289,18 +4311,22 @@ export async function prepareRelease(options) {
4289
4311
  bundleRoot: relative(realRoot, bundleRootForAuthorityDir(releaseDir)),
4290
4312
  });
4291
4313
  const { executionFiles: _executionFiles, ...declarationWithoutManifest } = structuredClone(unit.postPublish);
4292
- frozenPostPublish = {
4314
+ frozenPostPublish.push({
4293
4315
  ...declarationWithoutManifest,
4294
4316
  tag,
4295
4317
  ...(productionAssets ? { tagCommit: productionAssets[index].commit } : {}),
4296
4318
  unitId: unit.id,
4297
4319
  payloadSource: PAYLOAD_SOURCE_TAG_WORKTREE,
4298
4320
  executionBundle,
4299
- };
4321
+ });
4300
4322
  }
4301
4323
 
4302
4324
  const plan = {
4303
- planVersion: 2,
4325
+ // New prepares emit planVersion 3 (multi-release-unit postPublish v3):
4326
+ // postPublish is the declaration ARRAY (possibly empty). v3 inherits
4327
+ // the v2 record-layer digest and approval semantics; only the
4328
+ // postPublish shape changes.
4329
+ planVersion: 3,
4304
4330
  status: 'PREPARED',
4305
4331
  // Workflow profile (H5): 'full' for the complete gate set;
4306
4332
  // 'docs'/'config'/'marketplace' for trimmed code-class gates. Both
@@ -4331,7 +4357,7 @@ export async function prepareRelease(options) {
4331
4357
  : {}),
4332
4358
  verificationGates: config.verificationGates ?? [],
4333
4359
  snapshotDigest: overallSnapshotDigest,
4334
- ...(hasHumanConsumerActions ? { humanConsumersStrategy: 'manualFollowUps' } : {}),
4360
+ ...(humanConsumersStrategy ? { humanConsumersStrategy } : {}),
4335
4361
  ...(production ? {
4336
4362
  production: {
4337
4363
  mode: 'github-npm-v1',
@@ -4341,7 +4367,7 @@ export async function prepareRelease(options) {
4341
4367
  units,
4342
4368
  externalActions,
4343
4369
  ...(publicSourceAuthorityReceipt ? { publicSourceAuthorityReceipt } : {}),
4344
- ...(frozenPostPublish ? { postPublish: frozenPostPublish } : {}),
4370
+ postPublish: frozenPostPublish,
4345
4371
  ...(sourceAuthority ? { sourceAuthority } : {}),
4346
4372
  createdAt: production ? createdAtTimestamp : (clock ? clock() : new Date().toISOString()),
4347
4373
  };
@@ -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 的纯校验函数,不调用
@@ -3,7 +3,15 @@ import { basename, dirname, resolve } from 'node:path';
3
3
  import { publishFileOrReplace } from 'skill-family-harness-node';
4
4
 
5
5
  import { canonicalJson, sha256Hex } from '../core/digest.mjs';
6
- import { effectiveHookRequiresApproval } from '../core/postpublish.mjs';
6
+ import {
7
+ effectiveHookRequiresApproval,
8
+ normalizePostPublishView,
9
+ postPublishActionId,
10
+ } from '../core/postpublish.mjs';
11
+ import {
12
+ derivePostReleaseChecklist,
13
+ unavailablePostReleaseChecklist,
14
+ } from './post-release-local.mjs';
7
15
  import {
8
16
  ReleaseError,
9
17
  GATE_FAILED,
@@ -101,11 +109,13 @@ function publicState(state) {
101
109
  ...(state.approvalSummary ? { approvalSummary: state.approvalSummary } : {}),
102
110
  ...(state.approvalPath ? { approvalPath: state.approvalPath } : {}),
103
111
  ...(state.sourceRunPath ? { sourceRunPath: state.sourceRunPath } : {}),
112
+ ...(state.verifyRunPath ? { verifyRunPath: state.verifyRunPath } : {}),
104
113
  ...(state.distributeRunPath ? { distributeRunPath: state.distributeRunPath } : {}),
105
114
  ...(state.postVerify ? { postVerify: state.postVerify } : {}),
106
115
  ...(state.requirements ? { requirements: state.requirements } : {}),
107
116
  ...(state.manualFollowUps ? { manualFollowUps: state.manualFollowUps } : {}),
108
117
  ...(state.metadataUpdate ? { metadataUpdate: state.metadataUpdate } : {}),
118
+ ...(state.postRelease ? { postRelease: state.postRelease } : {}),
109
119
  verificationGateAuthorizationIncludedInPlanApproval: true,
110
120
  postVerifyMetadataUpdateIncludedInPlanApproval: true,
111
121
  };
@@ -146,11 +156,19 @@ async function buildApprovalSummary(planPath) {
146
156
  * or missing record falls back to ALL declared hooks — fail-safe, because a
147
157
  * gated hook in doubt keeps the gate shut.
148
158
  *
159
+ * Checkpoint action ids are unit-scoped for planVersion 3
160
+ * (`unitId/localId`, postPublishActionId) and bare local ids for legacy
161
+ * plans, so the closed-set comparison must derive the id from the owning
162
+ * declaration instead of comparing the bare hook id — otherwise every v3
163
+ * hook looks unclosed and a closed gated hook keeps the gate shut.
164
+ *
149
165
  * @param {object} state - Ship state (postVerify.runPath inspected).
150
- * @param {object[]} postVerifyHooks - Declared phase:postVerify hooks.
166
+ * @param {Array<{hook: object, unitId: string}>} postVerifyHooks - Declared
167
+ * phase:postVerify hooks bound to their owning declaration's unitId.
168
+ * @param {number} planVersion - Frozen plan's planVersion (action-id rule).
151
169
  * @returns {Promise<boolean>}
152
170
  */
153
- async function allUnclosedPostVerifyHooksUngated(state, postVerifyHooks) {
171
+ async function allUnclosedPostVerifyHooksUngated(state, postVerifyHooks, planVersion) {
154
172
  if (!Array.isArray(postVerifyHooks) || postVerifyHooks.length === 0) return false;
155
173
  let candidates = postVerifyHooks;
156
174
  if (state.postVerify?.runPath) {
@@ -163,13 +181,14 @@ async function allUnclosedPostVerifyHooksUngated(state, postVerifyHooks) {
163
181
  && (cp.status === 'succeeded' || cp.status === 'NO_CHANGE'))
164
182
  .map((cp) => cp.actionId),
165
183
  );
166
- candidates = postVerifyHooks.filter((hook) => !closedIds.has(hook.id));
184
+ candidates = postVerifyHooks.filter(({ hook, unitId }) =>
185
+ !closedIds.has(postPublishActionId({ planVersion, unitId, localId: hook.id })));
167
186
  } catch {
168
187
  candidates = postVerifyHooks; // unreadable record: fail safe
169
188
  }
170
189
  }
171
190
  if (candidates.length === 0) return false;
172
- return candidates.every((hook) => effectiveHookRequiresApproval(hook) === false);
191
+ return candidates.every(({ hook }) => effectiveHookRequiresApproval(hook) === false);
173
192
  }
174
193
 
175
194
  /**
@@ -344,10 +363,12 @@ export async function advanceShip(options = {}, injected = {}) {
344
363
  if (state.status === 'PUBLISHED' || state.status === 'NEEDS_MANUAL_ATTESTATIONS') {
345
364
  // Step 1: Check if postPublish requires distribution.
346
365
  // Hooks-only declarations (no targets) still route through distribute.
366
+ // §4.3 unified normalization: v3 empty arrays carry no distribute work;
367
+ // legacy absent postPublish resolves to the same empty view.
347
368
  const plan = JSON.parse(await readFile(state.planPath, 'utf8'));
348
- const hasDistributeWork = (plan.postPublish?.targets?.length ?? 0) > 0
349
- || (plan.postPublish?.hooks?.length ?? 0) > 0;
350
- const needsDistribution = Boolean(plan.postPublish) && hasDistributeWork;
369
+ const hasDistributeWork = normalizePostPublishView(plan).some((declaration) =>
370
+ (declaration.targets?.length ?? 0) > 0 || (declaration.hooks?.length ?? 0) > 0);
371
+ const needsDistribution = hasDistributeWork;
351
372
  if (needsDistribution && deps.distributeRelease) {
352
373
  state.status = 'DISTRIBUTING';
353
374
  await writeJsonAtomic(statePath, state);
@@ -404,9 +425,19 @@ export async function advanceShip(options = {}, injected = {}) {
404
425
  requirements: undefined,
405
426
  manualFollowUps: verified.manualFollowUps ?? undefined,
406
427
  baselineAdvance: verified.baselineAdvance ?? undefined,
428
+ postRelease: undefined,
407
429
  updatedAt: new Date().toISOString(),
408
430
  };
409
431
  await writeJsonAtomic(statePath, state);
432
+ if (verified.status === 'VERIFIED') {
433
+ try {
434
+ state.postRelease = derivePostReleaseChecklist(plan);
435
+ } catch (error) {
436
+ state.postRelease = unavailablePostReleaseChecklist(plan, error);
437
+ }
438
+ state.updatedAt = new Date().toISOString();
439
+ await writeJsonAtomic(statePath, state);
440
+ }
410
441
  } catch (error) {
411
442
  if (error?.code !== CONSUMER_VERIFICATION_DEFERRED) throw error;
412
443
  state = {
@@ -422,7 +453,8 @@ export async function advanceShip(options = {}, injected = {}) {
422
453
  // independent run after the main run is VERIFIED, with the verify run as
423
454
  // lineage source. A PARTIAL postVerify run or a postVerify gate failure
424
455
  // NEVER demotes VERIFIED — the outcome is recorded on the ship state.
425
- const postVerifyHooks = (plan.postPublish?.hooks ?? [])
456
+ const postVerifyHooks = normalizePostPublishView(plan)
457
+ .flatMap((declaration) => declaration.hooks ?? [])
426
458
  .filter((hook) => hook.phase === 'postVerify');
427
459
  if (state.status === 'VERIFIED' && postVerifyHooks.length > 0 && deps.postVerifyRelease) {
428
460
  postVerifyRanThisCall = true;
@@ -476,10 +508,11 @@ export async function advanceShip(options = {}, injected = {}) {
476
508
  && (!state.postVerify || state.postVerify.status !== 'DISTRIBUTED')
477
509
  ) {
478
510
  const reentryPlan = JSON.parse(await readFile(state.planPath, 'utf8'));
479
- const reentryPostVerifyHooks = (reentryPlan.postPublish?.hooks ?? [])
480
- .filter((hook) => hook.phase === 'postVerify');
511
+ const reentryPostVerifyHooks = normalizePostPublishView(reentryPlan)
512
+ .flatMap((declaration) => (declaration.hooks ?? []).map((hook) => ({ hook, unitId: declaration.unitId })))
513
+ .filter(({ hook }) => hook.phase === 'postVerify');
481
514
  const approvallessRetryAllowed = reentryApprovalPaths.length === 0
482
- && await allUnclosedPostVerifyHooksUngated(state, reentryPostVerifyHooks);
515
+ && await allUnclosedPostVerifyHooksUngated(state, reentryPostVerifyHooks, reentryPlan.planVersion);
483
516
  if (reentryPostVerifyHooks.length > 0
484
517
  && (reentryApprovalPaths.length > 0 || approvallessRetryAllowed)) {
485
518
  let postVerifyOutcome;