release-skill 0.8.1 → 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 (66) 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 +31 -0
  7. package/INSTALL.md +14 -2
  8. package/INSTALL.zh-CN.md +12 -2
  9. package/README.md +28 -16
  10. package/README.zh-CN.md +23 -14
  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/release-skill-local-finish.mjs +4 -0
  14. package/adapters/claude/bin/release-skill.bundle.mjs +2416 -1320
  15. package/adapters/claude/schemas/release-plan.schema.json +40 -3
  16. package/adapters/claude/skills/release-finish/SKILL.md +65 -0
  17. package/adapters/claude/skills/release-verify/SKILL.md +6 -1
  18. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  19. package/adapters/codex/bin/release-skill-local-finish.mjs +4 -0
  20. package/adapters/codex/bin/release-skill.bundle.mjs +2416 -1320
  21. package/adapters/codex/schemas/release-plan.schema.json +40 -3
  22. package/adapters/codex/skills/release-finish/SKILL.md +72 -0
  23. package/adapters/codex/skills/release-verify/SKILL.md +6 -1
  24. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  25. package/adapters/kimi/bin/release-skill-local-finish.mjs +4 -0
  26. package/adapters/kimi/bin/release-skill.bundle.mjs +2416 -1320
  27. package/adapters/kimi/schemas/release-plan.schema.json +40 -3
  28. package/adapters/kimi/skills/release-finish/SKILL.md +72 -0
  29. package/adapters/kimi/skills/release-verify/SKILL.md +6 -1
  30. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  31. package/adapters/workbuddy/bin/release-skill-local-finish.mjs +4 -0
  32. package/adapters/workbuddy/bin/release-skill.bundle.mjs +2416 -1320
  33. package/adapters/workbuddy/schemas/release-plan.schema.json +40 -3
  34. package/adapters/workbuddy/skills/release-finish/SKILL.md +65 -0
  35. package/adapters/workbuddy/skills/release-verify/SKILL.md +6 -1
  36. package/bin/release-skill-cli.mjs +126 -1
  37. package/bin/release-skill-local-finish.mjs +4 -0
  38. package/bin/release-skill.bundle.mjs +2416 -1320
  39. package/package.json +1 -1
  40. package/platform-manifest.json +30 -4
  41. package/schemas/release-plan.schema.json +40 -3
  42. package/skills/release-finish/SKILL.md +65 -0
  43. package/skills/release-verify/SKILL.md +6 -1
  44. package/skills-src/release-finish/SKILL.md +65 -0
  45. package/skills-src/release-verify/SKILL.md +6 -1
  46. package/src/commands/approve.mjs +14 -2
  47. package/src/commands/distribute.mjs +562 -321
  48. package/src/commands/post-release-local.mjs +611 -0
  49. package/src/commands/postverify.mjs +262 -155
  50. package/src/commands/prepare.mjs +67 -52
  51. package/src/commands/publish.mjs +11 -10
  52. package/src/commands/reconcile.mjs +11 -10
  53. package/src/commands/setup.mjs +26 -13
  54. package/src/commands/ship.mjs +45 -12
  55. package/src/commands/verify.mjs +19 -8
  56. package/src/core/adoption-assessment.mjs +0 -3
  57. package/src/core/approval.mjs +11 -8
  58. package/src/core/hooks.mjs +0 -16
  59. package/src/core/plan.mjs +19 -11
  60. package/src/core/postpublish-approval.mjs +12 -2
  61. package/src/core/postpublish-bundle.mjs +51 -13
  62. package/src/core/postpublish.mjs +146 -6
  63. package/src/core/preset-executor.mjs +53 -0
  64. package/src/core/proposal-inbox.mjs +91 -27
  65. package/src/core/recovery.mjs +27 -12
  66. package/src/producers/build-adapters.mjs +10 -1
@@ -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,
@@ -81,6 +81,7 @@ import {
81
81
  validatePostPublishDeclaration,
82
82
  normalizePostPublishDeclaration,
83
83
  orderNormalizedHooks,
84
+ validatePostPublishHookIdUniqueness,
84
85
  PAYLOAD_SOURCE_TAG_WORKTREE,
85
86
  } from '../core/postpublish.mjs';
86
87
  import { freezeExecutionBundle, bundleRootForAuthorityDir } from '../core/postpublish-bundle.mjs';
@@ -3092,38 +3093,31 @@ export async function prepareRelease(options) {
3092
3093
 
3093
3094
  // --- Step 1c: postPublish distribution declaration gate (R1/R2) ---
3094
3095
  // 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.
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).
3107
3111
  const postPublishDeclarations = configUnits
3108
3112
  .map((unit, index) => ({ unit, index }))
3109
3113
  .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];
3114
+ for (const { unit } of postPublishDeclarations) {
3120
3115
  validatePostPublishDeclaration(unit.postPublish, { unitId: unit.id });
3121
3116
  // Normalized hook table (design §2.2): validate the dependency
3122
3117
  // topology at freeze time too, so a cyclic/dangling declaration can
3123
3118
  // never be frozen for distribute to trip over.
3124
3119
  const normalizedDeclaration = normalizePostPublishDeclaration(unit.postPublish);
3125
3120
  const orderedNormalizedHooks = orderNormalizedHooks(normalizedDeclaration.hooks);
3126
- postPublishDeclaration = { unit, index };
3127
3121
  await evidence.append({
3128
3122
  phase: 'postpublish-declaration',
3129
3123
  status: 'validated',
@@ -3134,6 +3128,19 @@ export async function prepareRelease(options) {
3134
3128
  preGates: normalizedDeclaration.preGates.map((gate) => gate.gate),
3135
3129
  });
3136
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
+ );
3137
3144
 
3138
3145
  // --- Step 2: Hook authorization gate ---
3139
3146
  // Hooks are user-configured arbitrary local processes without filesystem
@@ -3841,20 +3848,21 @@ export async function prepareRelease(options) {
3841
3848
  });
3842
3849
  }
3843
3850
 
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.
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.
3858
3866
  const createdAtTimestamp = production
3859
3867
  ? normalizeGitTimestamp(clock ? clock() : new Date().toISOString(), 'plan createdAt timestamp')
3860
3868
  : null;
@@ -4252,16 +4260,20 @@ export async function prepareRelease(options) {
4252
4260
  ? 'foundationPayloadThenManualFollowUps'
4253
4261
  : 'manualFollowUps';
4254
4262
 
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
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
4259
4270
  // prepares can freeze it — distribute fails closed when it is absent),
4260
4271
  // unitId (declaring unit), and payloadSource "tag-worktree" (R1 timing
4261
4272
  // 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.
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.
4265
4277
  //
4266
4278
  // F-01 / T1 private execution bundle: parent-workspace files that the
4267
4279
  // post-publish commands need but the frozen tag does not contain are
@@ -4271,9 +4283,8 @@ export async function prepareRelease(options) {
4271
4283
  // plan is the bundle's only source of truth (no parallel manifest, no
4272
4284
  // second bundle digest): the raw executionFiles list folds into the
4273
4285
  // closure and is NOT duplicated into the frozen block.
4274
- let frozenPostPublish = null;
4275
- if (postPublishDeclaration) {
4276
- const { unit, index } = postPublishDeclaration;
4286
+ const frozenPostPublish = [];
4287
+ for (const { unit, index } of postPublishDeclarations) {
4277
4288
  const { tag } = resolveProductionBranch(unit, resolvedVersions[index]);
4278
4289
  const declaredExecutionFiles = unit.postPublish.executionFiles ?? [];
4279
4290
  const frozenTagPaths = productionAssets
@@ -4300,18 +4311,22 @@ export async function prepareRelease(options) {
4300
4311
  bundleRoot: relative(realRoot, bundleRootForAuthorityDir(releaseDir)),
4301
4312
  });
4302
4313
  const { executionFiles: _executionFiles, ...declarationWithoutManifest } = structuredClone(unit.postPublish);
4303
- frozenPostPublish = {
4314
+ frozenPostPublish.push({
4304
4315
  ...declarationWithoutManifest,
4305
4316
  tag,
4306
4317
  ...(productionAssets ? { tagCommit: productionAssets[index].commit } : {}),
4307
4318
  unitId: unit.id,
4308
4319
  payloadSource: PAYLOAD_SOURCE_TAG_WORKTREE,
4309
4320
  executionBundle,
4310
- };
4321
+ });
4311
4322
  }
4312
4323
 
4313
4324
  const plan = {
4314
- 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,
4315
4330
  status: 'PREPARED',
4316
4331
  // Workflow profile (H5): 'full' for the complete gate set;
4317
4332
  // 'docs'/'config'/'marketplace' for trimmed code-class gates. Both
@@ -4352,7 +4367,7 @@ export async function prepareRelease(options) {
4352
4367
  units,
4353
4368
  externalActions,
4354
4369
  ...(publicSourceAuthorityReceipt ? { publicSourceAuthorityReceipt } : {}),
4355
- ...(frozenPostPublish ? { postPublish: frozenPostPublish } : {}),
4370
+ postPublish: frozenPostPublish,
4356
4371
  ...(sourceAuthority ? { sourceAuthority } : {}),
4357
4372
  createdAt: production ? createdAtTimestamp : (clock ? clock() : new Date().toISOString()),
4358
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;
@@ -23,6 +23,7 @@ import { resolveContained } from 'skill-family-harness-node';
23
23
  const execFile = promisify(execFileCb);
24
24
 
25
25
  import { validatePlan, computePlanDigest, validatePlanActionCompleteness } from '../core/plan.mjs';
26
+ import { normalizePostPublishView, postPublishActionId } from '../core/postpublish.mjs';
26
27
  import { createEvidenceWriter } from '../core/evidence.mjs';
27
28
  import { readRunRecovery } from '../core/recovery.mjs';
28
29
  import {
@@ -888,7 +889,7 @@ async function discoverDistributeRuns({ planPath, plan }) {
888
889
  * governance rule that NEEDS_INPUT/BLOCKED cannot silently become VERIFIED.
889
890
  *
890
891
  * @param {Object} run - A distribute run record (checkpoints + status).
891
- * @param {Object} plan - The frozen release plan (postPublish.hooks source).
892
+ * @param {Object} plan - The frozen release plan (normalized postPublish declaration source).
892
893
  * @returns {{ pass: boolean, warned: boolean, exemptions: Object[] }}
893
894
  */
894
895
  export function evaluateDistributeGateRun(run, plan) {
@@ -896,12 +897,19 @@ export function evaluateDistributeGateRun(run, plan) {
896
897
  if (run.status === 'DISTRIBUTED') return { pass: true, warned: false, exemptions: [] };
897
898
  if (run.status !== 'PARTIAL') return { pass: false, warned: false, exemptions: [] };
898
899
 
899
- const hooksById = new Map(((plan?.postPublish?.hooks ?? [])).map((hook) => [hook.id, hook]));
900
+ // §4.3 unified normalization: hooks resolve across all declarations and
901
+ // are keyed by the SAME action-id derivation the distribute run used, so
902
+ // v3 namespaced checkpoint ids (unitId/hookId) map back to their hook.
903
+ const hooksByActionId = new Map((plan ? normalizePostPublishView(plan) : [])
904
+ .flatMap((declaration) => (declaration.hooks ?? []).map((hook) => [
905
+ postPublishActionId({ planVersion: plan?.planVersion, unitId: declaration.unitId, localId: hook.id }),
906
+ hook,
907
+ ])));
900
908
  const exemptions = [];
901
909
  for (const checkpoint of run.checkpoints ?? []) {
902
910
  if (checkpoint.status === 'succeeded' || checkpoint.status === 'skipped') continue;
903
911
  const hook = checkpoint.actionType === 'postpublish-hook'
904
- ? hooksById.get(checkpoint.actionId)
912
+ ? hooksByActionId.get(checkpoint.actionId)
905
913
  : undefined;
906
914
  if (hook && checkpoint.status === 'failed' && hook.blocksVerified === false) {
907
915
  exemptions.push({ actionId: checkpoint.actionId, status: checkpoint.status });
@@ -929,7 +937,7 @@ export function evaluateDistributeGateRun(run, plan) {
929
937
  * @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
930
938
  * @param {Object} [options.previousVerifyRun] - 上一次验证成功的 verify run 记录,用于安装契约摘要免验。
931
939
  *
932
- * @returns {Promise<{ planPath: string, status: string, adapterChecks: Object[], smokeTest: Object }>}
940
+ * @returns {Promise<{ planPath: string, runPath: string, status: string, adapterChecks: Object[], smokeTest: Object }>}
933
941
  *
934
942
  * @throws {ReleaseError} GATE_FAILED on safety gate failures.
935
943
  * @throws {ReleaseError} POST_PUBLISH_VERIFY_FAILED if any verification fails.
@@ -1068,10 +1076,12 @@ export async function verifyRelease(options) {
1068
1076
  // PARTIAL distribute run passes only through the blocksVerified:false
1069
1077
  // exemption path (evaluateDistributeGateRun) — warned, never silent.
1070
1078
  // =======================================================================
1071
- const declaredPostPublishTargets = plan.postPublish?.targets ?? [];
1072
- const declaredPostPublishHooks = plan.postPublish?.hooks ?? [];
1073
- if (plan.postPublish
1074
- && (declaredPostPublishTargets.length > 0 || declaredPostPublishHooks.length > 0)) {
1079
+ // §4.3 unified normalization: v3 empty arrays mean no distribution
1080
+ // requirement; legacy absent postPublish resolves to the same empty view.
1081
+ const postPublishDeclarations = normalizePostPublishView(plan);
1082
+ const requiresDistribution = postPublishDeclarations.some((declaration) =>
1083
+ (declaration.targets?.length ?? 0) > 0 || (declaration.hooks?.length ?? 0) > 0);
1084
+ if (requiresDistribution) {
1075
1085
  await evidence.append({ phase: 'verify', step: 'distribute-run-discovery', status: 'started' });
1076
1086
 
1077
1087
  const distributeCandidates = await discoverDistributeRuns({ planPath, plan }) ?? [];
@@ -2176,6 +2186,7 @@ export async function verifyRelease(options) {
2176
2186
 
2177
2187
  return {
2178
2188
  planPath,
2189
+ runPath: verifyRunPath,
2179
2190
  status: VERIFIED,
2180
2191
  adapterChecks,
2181
2192
  recoveryActionCode: null,
@@ -40,9 +40,6 @@
40
40
  import { canonicalJson, sha256Hex } from './digest.mjs';
41
41
  import { resolveProducerVersion } from './evidence.mjs';
42
42
 
43
- // Keep the assessment entry point compatible without duplicating the rule.
44
- export { findPostPublishUnitConflict } from './hooks.mjs';
45
-
46
43
  /** Four adoption statuses; never reuses ASSESSED (design §3.2). */
47
44
  export const ASSESSMENT_STATUS = Object.freeze({
48
45
  NOT_CONFIGURED: 'NOT_CONFIGURED',
@@ -12,7 +12,8 @@
12
12
  * - approvedAt is not in the future (beyond 5-minute clock skew tolerance)
13
13
  *
14
14
  * The planVersion fork (design: t1-2-digest-decoupling.md §4.3) is
15
- * centralized here: for planVersion 2 plans the baseline is record-layer
15
+ * centralized here: for planVersion 2/3 plans (v3 inherits the v2 approval
16
+ * boundary per 多发布单元 postPublish v3 §4.2) the baseline is record-layer
16
17
  * data -- gitTreeHash/workspaceDigest equality and the production workspace
17
18
  * digest algorithm double-check are NOT invalidation conditions (artifact
18
19
  * integrity is sealed by the frozen-artifact re-verification at publish).
@@ -186,11 +187,13 @@ export function validateApproval(plan, approval, options = {}) {
186
187
  );
187
188
  }
188
189
 
189
- // planVersion fork (centralized here; see module header): v2 plans treat
190
- // the baseline as optional record-layer data.
191
- const planV2 = plan?.planVersion === 2;
190
+ // planVersion fork (centralized here; see module header): planVersion 2/3
191
+ // plans treat the baseline as optional record-layer data (v3 inherits the
192
+ // v2 approval boundary per 多发布单元 postPublish v3 §4.2). Versions beyond
193
+ // {2,3} stay on the legacy v1 path and are never silently admitted.
194
+ const recordLayerBaseline = plan?.planVersion === 2 || plan?.planVersion === 3;
192
195
 
193
- if (planV2) {
196
+ if (recordLayerBaseline) {
194
197
  if (!approval.planDigest || !approval.expiresAt) {
195
198
  throw new ReleaseError(
196
199
  GATE_FAILED,
@@ -206,7 +209,7 @@ export function validateApproval(plan, approval, options = {}) {
206
209
  );
207
210
  }
208
211
 
209
- if (!planV2) {
212
+ if (!recordLayerBaseline) {
210
213
  if (plan.production?.mode === 'github-npm-v1') {
211
214
  if (plan.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
212
215
  throw new ReleaseError(
@@ -249,9 +252,9 @@ export function validateApproval(plan, approval, options = {}) {
249
252
  }
250
253
 
251
254
  // --- baseline equality checks (v1 plans only) ---
252
- // For planVersion 2 plans the baseline is record-layer data: it stays in
255
+ // For planVersion 2/3 plans the baseline is record-layer data: it stays in
253
256
  // the plan/approval files for audit but is not an invalidation condition.
254
- if (!planV2) {
257
+ if (!recordLayerBaseline) {
255
258
  // --- baseline.gitTreeHash match ---
256
259
  if (approval.baseline.gitTreeHash !== plan.baseline?.gitTreeHash) {
257
260
  throw new ReleaseError(