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
@@ -2,8 +2,17 @@ import { readFile, lstat, mkdir } from 'node:fs/promises';
2
2
  import { basename, dirname, resolve } from 'node:path';
3
3
  import { publishFileOrReplace } from 'skill-family-harness-node';
4
4
 
5
+ import { MAX_APPROVAL_MS } from '../core/approval.mjs';
5
6
  import { canonicalJson, sha256Hex } from '../core/digest.mjs';
6
- import { effectiveHookRequiresApproval } from '../core/postpublish.mjs';
7
+ import {
8
+ effectiveHookRequiresApproval,
9
+ normalizePostPublishView,
10
+ postPublishActionId,
11
+ } from '../core/postpublish.mjs';
12
+ import {
13
+ derivePostReleaseChecklist,
14
+ unavailablePostReleaseChecklist,
15
+ } from './post-release-local.mjs';
7
16
  import {
8
17
  ReleaseError,
9
18
  GATE_FAILED,
@@ -80,6 +89,7 @@ async function defaultDependencies() {
80
89
  postVerifyRelease: postverifyModule.postVerifyRelease,
81
90
  preflightGitTransports: transportModule.preflightGitTransports,
82
91
  updatePreviousPublicBaselines: metadataModule.updatePreviousPublicBaselines,
92
+ readMaxApprovalMs: () => MAX_APPROVAL_MS,
83
93
  };
84
94
  }
85
95
 
@@ -101,39 +111,142 @@ function publicState(state) {
101
111
  ...(state.approvalSummary ? { approvalSummary: state.approvalSummary } : {}),
102
112
  ...(state.approvalPath ? { approvalPath: state.approvalPath } : {}),
103
113
  ...(state.sourceRunPath ? { sourceRunPath: state.sourceRunPath } : {}),
114
+ ...(state.verifyRunPath ? { verifyRunPath: state.verifyRunPath } : {}),
104
115
  ...(state.distributeRunPath ? { distributeRunPath: state.distributeRunPath } : {}),
105
116
  ...(state.postVerify ? { postVerify: state.postVerify } : {}),
106
117
  ...(state.requirements ? { requirements: state.requirements } : {}),
107
118
  ...(state.manualFollowUps ? { manualFollowUps: state.manualFollowUps } : {}),
108
119
  ...(state.metadataUpdate ? { metadataUpdate: state.metadataUpdate } : {}),
109
- verificationGateAuthorizationIncludedInPlanApproval: true,
110
- postVerifyMetadataUpdateIncludedInPlanApproval: true,
120
+ ...(state.postRelease ? { postRelease: state.postRelease } : {}),
121
+ externalActionsIncludedInPlanApproval: true,
122
+ verificationGatesIncludedInPlanApproval: true,
123
+ postPublishCheckpointApprovalsIncludedInPlanApproval: false,
111
124
  };
112
125
  }
113
126
 
127
+ const HOUR_MS = 60 * 60 * 1000;
128
+
129
+ const ACTION_TARGET_FIELDS = Object.freeze({
130
+ 'push-commit': Object.freeze(['remote', 'branch']),
131
+ 'push-snapshot': Object.freeze(['repo', 'branch', 'branchStrategy', 'commit']),
132
+ 'create-tag': Object.freeze(['repo', 'tag', 'commit']),
133
+ 'npm-publish': Object.freeze(['package', 'version', 'registry', 'tag', 'access', 'provenance']),
134
+ 'github-release': Object.freeze(['repo', 'tag', 'name', 'commit']),
135
+ 'claude-marketplace-install': Object.freeze(['consumer', 'plugin', 'marketplace', 'repo', 'ref', 'version', 'entrySkill']),
136
+ 'codex-marketplace-install': Object.freeze(['consumer', 'plugin', 'marketplace', 'repo', 'ref', 'version', 'entrySkill']),
137
+ 'kimi-marketplace-install': Object.freeze(['consumer', 'plugin', 'repo', 'ref', 'version', 'entrySkill']),
138
+ 'codebuddy-marketplace-install': Object.freeze(['consumer', 'plugin', 'repo', 'ref', 'version', 'entrySkill']),
139
+ 'set-default-branch': Object.freeze(['repo', 'oldBranch', 'newBranch', 'expectedNewBranchCommit']),
140
+ });
141
+
142
+ function deriveApprovalWindowHours(maxApprovalMs) {
143
+ if (
144
+ !Number.isSafeInteger(maxApprovalMs)
145
+ || maxApprovalMs <= 0
146
+ || maxApprovalMs % HOUR_MS !== 0
147
+ ) {
148
+ throw new ReleaseError(
149
+ GATE_FAILED,
150
+ 'core approval window must be a positive whole number of hours',
151
+ { maxApprovalMs },
152
+ );
153
+ }
154
+ return maxApprovalMs / HOUR_MS;
155
+ }
156
+
157
+ function projectActionTarget(action) {
158
+ const parameters = action?.parameters ?? {};
159
+ return Object.fromEntries(
160
+ (ACTION_TARGET_FIELDS[action?.type] ?? [])
161
+ .filter((field) => parameters[field] !== undefined)
162
+ .map((field) => [field, parameters[field]]),
163
+ );
164
+ }
165
+
114
166
  /**
115
167
  * Build a human-readable approval summary from the frozen plan.
116
- * Lists each unit's version and every external action's id/type/unitId.
117
- * Returns an empty summary when the plan file is not yet available (e.g., in tests).
168
+ * Lists every publish target and every checkpoint approval separately. All
169
+ * fields are mechanically projected from the frozen plan; unreadable plans
170
+ * fail closed instead of producing an empty or partial summary.
118
171
  *
119
172
  * @param {string} planPath - Path to the frozen release plan.
120
173
  * @returns {Promise<object>} The approval summary.
121
174
  */
122
- async function buildApprovalSummary(planPath) {
175
+ async function buildApprovalSummary(planPath, maxApprovalMs = MAX_APPROVAL_MS) {
123
176
  try {
124
177
  const plan = JSON.parse(await readFile(planPath, 'utf8'));
125
- const units = (plan.units ?? []).map((unit) => ({
126
- id: unit.id,
127
- targetVersion: unit.targetVersion ?? unit.version,
128
- }));
178
+ const approvalWindowHours = deriveApprovalWindowHours(maxApprovalMs);
129
179
  const actions = (plan.externalActions ?? []).map((action) => ({
130
180
  id: action.id,
131
181
  type: action.type,
132
182
  unitId: action.unitId,
183
+ target: projectActionTarget(action),
133
184
  }));
134
- return { units, actions };
135
- } catch {
136
- return { units: [], actions: [] };
185
+ const units = (plan.units ?? []).map((unit) => {
186
+ const targetVersion = unit.targetVersion ?? unit.version;
187
+ const githubRelease = (plan.externalActions ?? [])
188
+ .find((action) => action.unitId === unit.id && action.type === 'github-release');
189
+ return {
190
+ id: unit.id,
191
+ targetVersion,
192
+ publicRepo: unit.publicRepo,
193
+ branch: unit.frozenSnapshot?.branch
194
+ ?? unit.productionConfig?.branchTemplate
195
+ ?.replaceAll('{tag}', unit.tagTemplate?.replace('{version}', targetVersion) ?? '')
196
+ .replaceAll('{version}', targetVersion)
197
+ .replaceAll('{unit}', unit.id),
198
+ branchStrategy: unit.frozenSnapshot?.branchStrategy ?? unit.productionConfig?.branchStrategy,
199
+ tag: unit.tagTemplate?.replace('{version}', targetVersion),
200
+ npm: (unit.distributions ?? [])
201
+ .filter((distribution) => distribution.type === 'npm')
202
+ .map((distribution) => ({
203
+ package: distribution.package,
204
+ version: targetVersion,
205
+ registry: distribution.registry,
206
+ distTag: distribution.tag,
207
+ access: distribution.access,
208
+ provenance: distribution.provenance,
209
+ })),
210
+ ...(githubRelease ? {
211
+ githubRelease: {
212
+ repo: githubRelease.parameters?.repo ?? githubRelease.parameters?.publicRepo,
213
+ tag: githubRelease.parameters?.tag,
214
+ name: githubRelease.parameters?.name,
215
+ },
216
+ } : {}),
217
+ };
218
+ });
219
+ const postPublishCheckpointApprovals = normalizePostPublishView(plan)
220
+ .flatMap((declaration) => (declaration.hooks ?? [])
221
+ .filter((hook) => effectiveHookRequiresApproval(hook))
222
+ .map((hook) => ({
223
+ unitId: declaration.unitId,
224
+ hookId: hook.id,
225
+ actionId: postPublishActionId({
226
+ planVersion: plan.planVersion,
227
+ unitId: declaration.unitId,
228
+ localId: hook.id,
229
+ }),
230
+ phase: hook.phase ?? 'distribute',
231
+ ...(hook.preset !== undefined ? { preset: hook.preset } : {}),
232
+ ...(hook.config?.delivery !== undefined ? { delivery: hook.config.delivery } : {}),
233
+ requiresApproval: true,
234
+ includedInPlanApproval: false,
235
+ approvalWindowHours,
236
+ binding: { planDigest: plan.digest, hookId: hook.id },
237
+ })));
238
+ return {
239
+ units,
240
+ actions,
241
+ waivers: Array.isArray(plan.waivers) ? plan.waivers : [],
242
+ postPublishCheckpointApprovals,
243
+ };
244
+ } catch (error) {
245
+ throw new ReleaseError(
246
+ GATE_FAILED,
247
+ `cannot build ship approval summary from frozen plan: ${error.message}`,
248
+ { planPath },
249
+ );
137
250
  }
138
251
  }
139
252
 
@@ -146,11 +259,19 @@ async function buildApprovalSummary(planPath) {
146
259
  * or missing record falls back to ALL declared hooks — fail-safe, because a
147
260
  * gated hook in doubt keeps the gate shut.
148
261
  *
262
+ * Checkpoint action ids are unit-scoped for planVersion 3
263
+ * (`unitId/localId`, postPublishActionId) and bare local ids for legacy
264
+ * plans, so the closed-set comparison must derive the id from the owning
265
+ * declaration instead of comparing the bare hook id — otherwise every v3
266
+ * hook looks unclosed and a closed gated hook keeps the gate shut.
267
+ *
149
268
  * @param {object} state - Ship state (postVerify.runPath inspected).
150
- * @param {object[]} postVerifyHooks - Declared phase:postVerify hooks.
269
+ * @param {Array<{hook: object, unitId: string}>} postVerifyHooks - Declared
270
+ * phase:postVerify hooks bound to their owning declaration's unitId.
271
+ * @param {number} planVersion - Frozen plan's planVersion (action-id rule).
151
272
  * @returns {Promise<boolean>}
152
273
  */
153
- async function allUnclosedPostVerifyHooksUngated(state, postVerifyHooks) {
274
+ async function allUnclosedPostVerifyHooksUngated(state, postVerifyHooks, planVersion) {
154
275
  if (!Array.isArray(postVerifyHooks) || postVerifyHooks.length === 0) return false;
155
276
  let candidates = postVerifyHooks;
156
277
  if (state.postVerify?.runPath) {
@@ -163,13 +284,14 @@ async function allUnclosedPostVerifyHooksUngated(state, postVerifyHooks) {
163
284
  && (cp.status === 'succeeded' || cp.status === 'NO_CHANGE'))
164
285
  .map((cp) => cp.actionId),
165
286
  );
166
- candidates = postVerifyHooks.filter((hook) => !closedIds.has(hook.id));
287
+ candidates = postVerifyHooks.filter(({ hook, unitId }) =>
288
+ !closedIds.has(postPublishActionId({ planVersion, unitId, localId: hook.id })));
167
289
  } catch {
168
290
  candidates = postVerifyHooks; // unreadable record: fail safe
169
291
  }
170
292
  }
171
293
  if (candidates.length === 0) return false;
172
- return candidates.every((hook) => effectiveHookRequiresApproval(hook) === false);
294
+ return candidates.every(({ hook }) => effectiveHookRequiresApproval(hook) === false);
173
295
  }
174
296
 
175
297
  /**
@@ -178,9 +300,11 @@ async function allUnclosedPostVerifyHooksUngated(state, postVerifyHooks) {
178
300
  * resumes instead of reconstructing authority from terminal/chat output.
179
301
  *
180
302
  * New flow (v0.4+): ship directly runs configured hooks and verification gates
181
- * without a separate hook authorization step. The only human gate is plan
182
- * approval. Kimi/CodeBuddy installations are non-blocking manual follow-up
183
- * tasks when the plan declares humanConsumersStrategy: 'manualFollowUps'.
303
+ * without a separate hook authorization step. Plan approval is the only
304
+ * normal release-level gate. A postPublish hook whose effective
305
+ * requiresApproval is true still needs its independent checkpoint approval.
306
+ * Kimi/CodeBuddy installations are non-blocking manual follow-up tasks when
307
+ * the plan declares humanConsumersStrategy: 'manualFollowUps'.
184
308
  */
185
309
  export async function advanceShip(options = {}, injected = {}) {
186
310
  const root = resolve(options.root ?? process.cwd());
@@ -249,7 +373,10 @@ export async function advanceShip(options = {}, injected = {}) {
249
373
  transportPreflight = await deps.preflightGitTransports(frozenPlan);
250
374
  process.env.RELEASE_SKILL_GIT_TRANSPORT = transportPreflight.transport;
251
375
  }
252
- const approvalSummary = await buildApprovalSummary(prepared.planPath);
376
+ const maxApprovalMs = typeof deps.readMaxApprovalMs === 'function'
377
+ ? await deps.readMaxApprovalMs()
378
+ : MAX_APPROVAL_MS;
379
+ const approvalSummary = await buildApprovalSummary(prepared.planPath, maxApprovalMs);
253
380
  state = {
254
381
  ...state,
255
382
  status: 'NEEDS_PLAN_APPROVAL',
@@ -344,10 +471,12 @@ export async function advanceShip(options = {}, injected = {}) {
344
471
  if (state.status === 'PUBLISHED' || state.status === 'NEEDS_MANUAL_ATTESTATIONS') {
345
472
  // Step 1: Check if postPublish requires distribution.
346
473
  // Hooks-only declarations (no targets) still route through distribute.
474
+ // §4.3 unified normalization: v3 empty arrays carry no distribute work;
475
+ // legacy absent postPublish resolves to the same empty view.
347
476
  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;
477
+ const hasDistributeWork = normalizePostPublishView(plan).some((declaration) =>
478
+ (declaration.targets?.length ?? 0) > 0 || (declaration.hooks?.length ?? 0) > 0);
479
+ const needsDistribution = hasDistributeWork;
351
480
  if (needsDistribution && deps.distributeRelease) {
352
481
  state.status = 'DISTRIBUTING';
353
482
  await writeJsonAtomic(statePath, state);
@@ -404,9 +533,19 @@ export async function advanceShip(options = {}, injected = {}) {
404
533
  requirements: undefined,
405
534
  manualFollowUps: verified.manualFollowUps ?? undefined,
406
535
  baselineAdvance: verified.baselineAdvance ?? undefined,
536
+ postRelease: undefined,
407
537
  updatedAt: new Date().toISOString(),
408
538
  };
409
539
  await writeJsonAtomic(statePath, state);
540
+ if (verified.status === 'VERIFIED') {
541
+ try {
542
+ state.postRelease = derivePostReleaseChecklist(plan);
543
+ } catch (error) {
544
+ state.postRelease = unavailablePostReleaseChecklist(plan, error);
545
+ }
546
+ state.updatedAt = new Date().toISOString();
547
+ await writeJsonAtomic(statePath, state);
548
+ }
410
549
  } catch (error) {
411
550
  if (error?.code !== CONSUMER_VERIFICATION_DEFERRED) throw error;
412
551
  state = {
@@ -422,7 +561,8 @@ export async function advanceShip(options = {}, injected = {}) {
422
561
  // independent run after the main run is VERIFIED, with the verify run as
423
562
  // lineage source. A PARTIAL postVerify run or a postVerify gate failure
424
563
  // NEVER demotes VERIFIED — the outcome is recorded on the ship state.
425
- const postVerifyHooks = (plan.postPublish?.hooks ?? [])
564
+ const postVerifyHooks = normalizePostPublishView(plan)
565
+ .flatMap((declaration) => declaration.hooks ?? [])
426
566
  .filter((hook) => hook.phase === 'postVerify');
427
567
  if (state.status === 'VERIFIED' && postVerifyHooks.length > 0 && deps.postVerifyRelease) {
428
568
  postVerifyRanThisCall = true;
@@ -476,10 +616,11 @@ export async function advanceShip(options = {}, injected = {}) {
476
616
  && (!state.postVerify || state.postVerify.status !== 'DISTRIBUTED')
477
617
  ) {
478
618
  const reentryPlan = JSON.parse(await readFile(state.planPath, 'utf8'));
479
- const reentryPostVerifyHooks = (reentryPlan.postPublish?.hooks ?? [])
480
- .filter((hook) => hook.phase === 'postVerify');
619
+ const reentryPostVerifyHooks = normalizePostPublishView(reentryPlan)
620
+ .flatMap((declaration) => (declaration.hooks ?? []).map((hook) => ({ hook, unitId: declaration.unitId })))
621
+ .filter(({ hook }) => hook.phase === 'postVerify');
481
622
  const approvallessRetryAllowed = reentryApprovalPaths.length === 0
482
- && await allUnclosedPostVerifyHooksUngated(state, reentryPostVerifyHooks);
623
+ && await allUnclosedPostVerifyHooksUngated(state, reentryPostVerifyHooks, reentryPlan.planVersion);
483
624
  if (reentryPostVerifyHooks.length > 0
484
625
  && (reentryApprovalPaths.length > 0 || approvallessRetryAllowed)) {
485
626
  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(
@@ -5,17 +5,20 @@
5
5
  * In the 0.6.1 cycle two prepares burned the full ~80s test hook before
6
6
  * surfacing drift that a sub-second check could have caught: adapter trees
7
7
  * out of sync with skills-src/, and self-bootstrap fact pins still bound to
8
- * the previous version. This module promotes those two exact checks to
9
- * prepare's earliest stage, alongside the bundle freshness gate:
8
+ * the previous version. This module promotes those checks and the platform
9
+ * manifest freshness check to prepare's earliest stage, alongside the bundle
10
+ * freshness gate:
10
11
  *
11
12
  * - adapter gate: runs `scripts/build-adapters.mjs --check` (drift list,
12
13
  * exit 1 on drift — the same supported check the scripts surface offers);
14
+ * - platform manifest gate: runs
15
+ * `scripts/generate-platform-manifest.mjs --check`;
13
16
  * - fact-pin gate: runs the version fact pins of
14
17
  * `test/release-docs-self-bootstrap.test.mjs` — exactly its hermetic
15
18
  * section 1 (`[self-bootstrap 1*]`: byte-level version assertions plus the
16
19
  * in-process read-only planner), scoped via `--test-name-pattern`.
17
20
  *
18
- * Both promote the CANONICAL check logic — the gate and the full pipeline
21
+ * All three promote the CANONICAL check logic — the gates and the full pipeline
19
22
  * can never disagree about what "in sync" means. The facts gate is scoped to
20
23
  * the suite's hermetic fact-pin section deliberately: the suite's remaining
21
24
  * sections shell out to npm/git and drive fixture prepares, which would make
@@ -88,6 +91,15 @@ const GATES = Object.freeze({
88
91
  'Rebuild the existing adapters with: node scripts/build-adapters.mjs --apply ' +
89
92
  `(or run the one-click derived-artifact sync from the workspace root: ${DERIVED_SYNC_COMMAND}).`,
90
93
  }),
94
+ 'platform-manifest': Object.freeze({
95
+ artifact: 'platform-manifest',
96
+ marker: join('scripts', 'generate-platform-manifest.mjs'),
97
+ argv: (pkgRoot) => [join(pkgRoot, 'scripts', 'generate-platform-manifest.mjs'), '--check'],
98
+ timeoutMs: 120000,
99
+ remediation:
100
+ 'Regenerate the platform manifest with: node scripts/generate-platform-manifest.mjs ' +
101
+ `(or run the one-click derived-artifact sync from the workspace root: ${DERIVED_SYNC_COMMAND}).`,
102
+ }),
91
103
  'self-bootstrap-facts': Object.freeze({
92
104
  artifact: 'self-bootstrap-facts',
93
105
  marker: join('test', 'release-docs-self-bootstrap.test.mjs'),
@@ -118,7 +130,7 @@ async function isFile(path) {
118
130
  /**
119
131
  * Run one derived-artifact check (pure decision, never throws).
120
132
  *
121
- * @param {'adapters' | 'self-bootstrap-facts'} kind - Gate to run.
133
+ * @param {'adapters' | 'platform-manifest' | 'self-bootstrap-facts'} kind - Gate to run.
122
134
  * @param {string} pkgRoot - Absolute package root of the running checkout.
123
135
  * @param {object} [options]
124
136
  * @param {Function} [options.execFileFn] - execFile seam (tests).
@@ -202,6 +214,11 @@ export function checkAdapterFreshness(pkgRoot, options = {}) {
202
214
  return checkDerivedArtifactGate('adapters', pkgRoot, options);
203
215
  }
204
216
 
217
+ /** Platform manifest freshness decision (generate-platform-manifest --check). */
218
+ export function checkPlatformManifestFreshness(pkgRoot, options = {}) {
219
+ return checkDerivedArtifactGate('platform-manifest', pkgRoot, options);
220
+ }
221
+
205
222
  /** Self-bootstrap fact-pin decision (single-file test). */
206
223
  export function checkSelfBootstrapFacts(pkgRoot, options = {}) {
207
224
  return checkDerivedArtifactGate('self-bootstrap-facts', pkgRoot, options);
@@ -215,7 +232,9 @@ async function assertGate(kind, pkgRoot, options = {}) {
215
232
  const gate = GATES[kind];
216
233
  const subject = kind === 'adapters'
217
234
  ? 'adapters/ is out of sync with its sources (build-adapters --check reported drift)'
218
- : 'the release-docs-self-bootstrap fact pins are stale (the hermetic fact-pin check failed)';
235
+ : kind === 'platform-manifest'
236
+ ? 'platform-manifest.json is out of sync with the current package tree (generate-platform-manifest --check reported drift)'
237
+ : 'the release-docs-self-bootstrap fact pins are stale (the hermetic fact-pin check failed)';
219
238
  throw new ReleaseError(
220
239
  DERIVED_ARTIFACT_STALE,
221
240
  `${subject}. ${DERIVED_ARTIFACT_PREGATE_NOTE} ${gate.remediation}`,
@@ -243,6 +262,14 @@ export function assertAdapterFreshness(pkgRoot, options = {}) {
243
262
  return assertGate('adapters', pkgRoot, options);
244
263
  }
245
264
 
265
+ /**
266
+ * Fail-closed platform manifest pre-gate used by prepare's earliest stage.
267
+ * Not-applicable layouts return quietly; drift throws DERIVED_ARTIFACT_STALE.
268
+ */
269
+ export function assertPlatformManifestFreshness(pkgRoot, options = {}) {
270
+ return assertGate('platform-manifest', pkgRoot, options);
271
+ }
272
+
246
273
  /**
247
274
  * Fail-closed self-bootstrap fact-pin pre-gate used by prepare's earliest
248
275
  * stage. Not-applicable layouts return quietly; drift throws
@@ -43,22 +43,6 @@ const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
43
43
  // Validation
44
44
  // ---------------------------------------------------------------------------
45
45
 
46
- /**
47
- * A release plan binds at most one postPublish declaration. Both prepare
48
- * and adoption assessment consume this pure rule and retain their own
49
- * error codes and presentation. Declaration contents are validated by
50
- * the existing postPublish validator, not by this cross-unit check.
51
- *
52
- * @param {Array<Object>} units - config.releaseUnits.
53
- * @returns {null|{unitIds: string[]}} Sorted conflicting unit IDs, or null.
54
- */
55
- export function findPostPublishUnitConflict(units) {
56
- const declaring = (units ?? [])
57
- .filter((unit) => unit && typeof unit === 'object' && unit.postPublish !== undefined);
58
- if (declaring.length <= 1) return null;
59
- return { unitIds: declaring.map((unit) => unit.id).sort() };
60
- }
61
-
62
46
  /**
63
47
  * Validate the shape of a hook descriptor against the project contract.
64
48
  *
package/src/core/plan.mjs CHANGED
@@ -202,7 +202,8 @@ export function validatePlan(plan) {
202
202
  }
203
203
 
204
204
  /**
205
- * planVersion 2 digest scope (design: t1-2-digest-decoupling.md §4.1).
205
+ * planVersion 2/3 digest scope (design: t1-2-digest-decoupling.md §4.1;
206
+ * v3 inherits the v2 scope per 多发布单元 postPublish v3 §4.2).
206
207
  *
207
208
  * The record layer -- top-level `digest`, lifecycle `status`, `createdAt`,
208
209
  * the whole `baseline` object, and each action's runtime `status` -- is
@@ -210,8 +211,10 @@ export function validatePlan(plan) {
210
211
  * but is excluded from the digest. Everything else (planVersion, units with
211
212
  * every frozenSnapshot artifact identity field, externalActions identity,
212
213
  * production, configDigest, verificationGates, snapshotDigest) binds.
214
+ * For v3 the frozen `postPublish` array and every item binding field stay in
215
+ * the binding layer: any order or binding change moves the digest.
213
216
  *
214
- * This is the ONLY place the v2 record layer is defined.
217
+ * This is the ONLY place the v2/v3 record layer is defined.
215
218
  *
216
219
  * @param {object} plan - A plan object.
217
220
  * @returns {object} The binding-layer projection of the plan.
@@ -242,9 +245,12 @@ function stripRecordLayerV2(plan) {
242
245
  *
243
246
  * Digest scope forks on `plan.planVersion` (centralized here, never spread
244
247
  * elsewhere):
245
- * - planVersion 2: record-layer fields (status/createdAt/baseline/action
248
+ * - planVersion 2/3: record-layer fields (status/createdAt/baseline/action
246
249
  * status/digest) are stripped before hashing, so re-preparing over the
247
- * same sources yields the same digest. See `stripRecordLayerV2`.
250
+ * same sources yields the same digest. See `stripRecordLayerV2`. v3
251
+ * inherits the v2 scope exactly (multi-release-unit postPublish v3 §4.2);
252
+ * versions beyond {2,3} are NOT admitted and stay fail-closed on the
253
+ * legacy v1 path rather than being silently bound to v2 semantics.
248
254
  * - planVersion 1 or absent (legacy): only the top-level `digest` field is
249
255
  * stripped. Byte-for-byte legacy semantics -- this path must never change.
250
256
  *
@@ -258,7 +264,7 @@ export function computePlanDigest(plan) {
258
264
  // 而是经 digest.mjs 的宽松输入域包装(contracts 权威序列化 + 本地归一化)
259
265
  // 后由 Foundation digestBytes 摘要 —— 对纯 JSON 计划与 digestDocument 字节
260
266
  // 完全一致,对含 undefined 的计划保持迁移前字节语义。
261
- if (plan.planVersion === 2) {
267
+ if (plan.planVersion === 2 || plan.planVersion === 3) {
262
268
  return sha256Hex(canonicalJson(stripRecordLayerV2(plan)));
263
269
  }
264
270
  // Strip the digest field if present so the hash is self-consistent.
@@ -341,14 +347,15 @@ export async function writePlanImmutable(planPath, plan) {
341
347
  // EEXIST 直接抛冲突,无幂等分支;wrapper 补齐,fail-closed 无双路径)。
342
348
  const existing = await readFile(planPath, 'utf8').catch(() => null);
343
349
  if (existing === json) return { planPath, planDigest };
344
- // planVersion 2 (design: t1-2-digest-decoupling.md §4.1): record-layer
345
- // fields (status/createdAt/baseline) are excluded from the digest, so
350
+ // planVersion 2/3 (design: t1-2-digest-decoupling.md §4.1; v3 inherits
351
+ // the scope per 多发布单元 postPublish v3 §4.2): record-layer fields
352
+ // (status/createdAt/baseline) are excluded from the digest, so
346
353
  // re-preparing over identical binding content yields the SAME digest
347
354
  // with legitimately different bytes. Reuse the existing authority IFF
348
355
  // it is genuinely self-consistent (its embedded digest and recomputed
349
356
  // digest both equal this digest) -- the authority is never replaced.
350
357
  // Any other divergence (tampering, malformed file) still fails closed.
351
- if (plan.planVersion === 2 && existing !== null) {
358
+ if ((plan.planVersion === 2 || plan.planVersion === 3) && existing !== null) {
352
359
  let existingPlan = null;
353
360
  try {
354
361
  existingPlan = JSON.parse(existing);
@@ -386,7 +393,7 @@ export async function writePlanImmutable(planPath, plan) {
386
393
  /**
387
394
  * Canonical normalized freeze timestamp shape (`YYYY-MM-DDTHH:MM:SS+00:00`).
388
395
  * Mirrors the frozenSnapshot.commitTimestamp pattern in the release-plan
389
- * schema; used by the planVersion 2 completeness gate, which requires a
396
+ * schema; used by the planVersion 2/3 completeness gate, which requires a
390
397
  * canonical normalized timestamp without re-binding it to plan.createdAt.
391
398
  */
392
399
  const CANONICAL_COMMIT_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/;
@@ -568,8 +575,9 @@ export function validatePlanActionCompleteness(plan, options = {}) {
568
575
  }
569
576
  if (!frozen.commitTimestamp || typeof frozen.commitTimestamp !== 'string') {
570
577
  failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp is missing; legacy production plans without a freeze timestamp are rejected, never silently backfilled`);
571
- } else if (plan.planVersion === 2) {
572
- // planVersion 2 (design: t1-2-digest-decoupling.md §4.2): the freeze
578
+ } else if (plan.planVersion === 2 || plan.planVersion === 3) {
579
+ // planVersion 2/3 (design: t1-2-digest-decoupling.md §4.2; v3 inherits
580
+ // the semantics per 多发布单元 postPublish v3 §4.2): the freeze
573
581
  // timestamp is derived from the headCommit committer date, so it no
574
582
  // longer equals plan.createdAt (record-layer real clock). It must
575
583
  // still be a canonical normalized timestamp -- the same pattern the