release-skill 0.6.2 → 0.6.3

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 (73) 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 +23 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/INSTALL.md +47 -2
  9. package/INSTALL.zh-CN.md +29 -2
  10. package/README.md +126 -9
  11. package/README.zh-CN.md +108 -9
  12. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  13. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  14. package/adapters/claude/bin/release-skill.bundle.mjs +6408 -1654
  15. package/adapters/claude/schemas/.render-manifest.json +10 -6
  16. package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
  17. package/adapters/claude/schemas/release-plan.schema.json +65 -1
  18. package/adapters/claude/schemas/release-project.schema.json +73 -1
  19. package/adapters/claude/schemas/release-run.schema.json +11 -6
  20. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  21. package/adapters/codex/bin/release-skill.bundle.mjs +6408 -1654
  22. package/adapters/codex/schemas/.render-manifest.json +10 -6
  23. package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
  24. package/adapters/codex/schemas/release-plan.schema.json +65 -1
  25. package/adapters/codex/schemas/release-project.schema.json +73 -1
  26. package/adapters/codex/schemas/release-run.schema.json +11 -6
  27. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  28. package/adapters/kimi/bin/release-skill.bundle.mjs +6408 -1654
  29. package/adapters/kimi/schemas/.render-manifest.json +10 -6
  30. package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
  31. package/adapters/kimi/schemas/release-plan.schema.json +65 -1
  32. package/adapters/kimi/schemas/release-project.schema.json +73 -1
  33. package/adapters/kimi/schemas/release-run.schema.json +11 -6
  34. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  35. package/adapters/workbuddy/bin/release-skill.bundle.mjs +6408 -1654
  36. package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
  37. package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
  38. package/adapters/workbuddy/schemas/release-plan.schema.json +65 -1
  39. package/adapters/workbuddy/schemas/release-project.schema.json +73 -1
  40. package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
  41. package/bin/release-skill-cli.mjs +181 -3
  42. package/bin/release-skill.bundle.mjs +6408 -1654
  43. package/package.json +2 -1
  44. package/platform-manifest.json +4 -4
  45. package/references/.render-manifest.json +5 -5
  46. package/references/01-state-machine.md +22 -2
  47. package/schemas/.render-manifest.json +10 -6
  48. package/schemas/postpublish-approval-record.schema.json +47 -0
  49. package/schemas/release-plan.schema.json +65 -1
  50. package/schemas/release-project.schema.json +73 -1
  51. package/schemas/release-run.schema.json +11 -6
  52. package/src/commands/approve.mjs +167 -1
  53. package/src/commands/distribute.mjs +411 -33
  54. package/src/commands/postverify.mjs +734 -0
  55. package/src/commands/prepare.mjs +280 -42
  56. package/src/commands/setup.mjs +715 -0
  57. package/src/commands/ship.mjs +152 -5
  58. package/src/commands/verify.mjs +92 -15
  59. package/src/core/approval.mjs +93 -68
  60. package/src/core/bounded-output.mjs +46 -0
  61. package/src/core/derived-artifact-gates.mjs +258 -0
  62. package/src/core/docs-refresh-preset.mjs +167 -0
  63. package/src/core/errors.mjs +4 -0
  64. package/src/core/hooks.mjs +28 -0
  65. package/src/core/marketplace-registry-entry.mjs +174 -0
  66. package/src/core/notify-handoff.mjs +76 -0
  67. package/src/core/postpublish-approval.mjs +110 -0
  68. package/src/core/postpublish.mjs +424 -7
  69. package/src/core/preset-executor.mjs +156 -0
  70. package/src/core/preset-gitwrite.mjs +463 -0
  71. package/src/core/presets.mjs +706 -0
  72. package/src/core/proposal-inbox.mjs +630 -0
  73. package/src/core/run.mjs +91 -6
@@ -0,0 +1,76 @@
1
+ /**
2
+ * notify-handoff preset: the zero-write floor (v0.6.3 R4, design §2.5).
3
+ *
4
+ * Every downstream scenario degrades to at least this behavior: NO writes of
5
+ * any kind — the §2.3 frozen context is rendered into a DETERMINISTIC
6
+ * downstream sync checklist (version/tag/sha/tree/evidence path/suggested
7
+ * actions), which the command layer writes into the run evidence and echoes.
8
+ * Zero configuration, usable by any project, requiresApproval defaults false.
9
+ *
10
+ * The renderer is pure: identical inputs produce byte-identical checklists
11
+ * (snapshot-locked by test/postpublish-notify-handoff.test.mjs). payloadDir
12
+ * and any other local-only artifact never enter the checklist.
13
+ *
14
+ * @module core/notify-handoff
15
+ */
16
+
17
+ /** Context fields rendered into the checklist, in deterministic order. */
18
+ const CHECKLIST_FACT_FIELDS = ['unitId', 'version', 'tag', 'commit', 'tree', 'manifestDigest', 'planDigest', 'runId', 'publishedAt'];
19
+
20
+ /**
21
+ * Render the deterministic downstream sync checklist for the notify-handoff
22
+ * preset. Pure function — no I/O, no writes.
23
+ *
24
+ * @param {object} contextProjection - The §2.3 context projection.
25
+ * @param {object} [options]
26
+ * @param {string} [options.evidencePath] - This run's evidence path (rendered
27
+ * as the `evidence:` line when provided).
28
+ * @returns {string[]} Checklist lines (deterministic order and wording).
29
+ */
30
+ export function renderNotifyHandoffChecklist(contextProjection, options = {}) {
31
+ const context = contextProjection ?? {};
32
+ const lines = [
33
+ 'notify-handoff downstream sync checklist (zero-write floor; no automated write was performed)',
34
+ ];
35
+ for (const field of CHECKLIST_FACT_FIELDS) {
36
+ const value = context[field];
37
+ if (typeof value === 'string' && value.length > 0) {
38
+ lines.push(`- ${field}: ${value}`);
39
+ }
40
+ }
41
+ const verifyEvidence = context.verifyEvidence;
42
+ if (verifyEvidence && typeof verifyEvidence === 'object') {
43
+ lines.push(`- verifyEvidence: runId=${verifyEvidence.runId ?? ''} status=${verifyEvidence.status ?? ''} finishedAt=${verifyEvidence.finishedAt ?? ''}`);
44
+ }
45
+ if (typeof options.evidencePath === 'string' && options.evidencePath.length > 0) {
46
+ lines.push(`- evidence: ${options.evidencePath}`);
47
+ }
48
+ lines.push('suggested actions:');
49
+ lines.push('- manually sync the frozen release facts above into every downstream consumer (marketplace entries, docs sites, hub registries)');
50
+ lines.push('- downstream updates require human action or the downstream governance workflow; this hook wrote nothing');
51
+ return lines;
52
+ }
53
+
54
+ /**
55
+ * Execute one notify-handoff preset hook: render the checklist. The command
56
+ * layer evidences/echoes it; this module performs zero writes.
57
+ *
58
+ * @param {object} params
59
+ * @param {object} params.contextProjection - The §2.3 context projection.
60
+ * @param {string} [params.evidencePath] - This run's evidence path.
61
+ * @returns {Promise<{ status: 'EXECUTED', mode: 'notify-handoff',
62
+ * checklist: string[], manualSyncPrompt: string, observation: object }>}
63
+ */
64
+ export async function executeNotifyHandoffHook(params) {
65
+ const { contextProjection, evidencePath } = params ?? {};
66
+ const checklist = renderNotifyHandoffChecklist(contextProjection, {
67
+ ...(evidencePath !== undefined ? { evidencePath } : {}),
68
+ });
69
+ return {
70
+ status: 'EXECUTED',
71
+ mode: 'notify-handoff',
72
+ checklist,
73
+ manualSyncPrompt: checklist.join('\n'),
74
+ observation: { mode: 'notify-handoff' },
75
+ };
76
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Checkpoint-level approval for requiresApproval postPublish hooks
3
+ * (v0.6.3 R1, design §2.7 ruling 2).
4
+ *
5
+ * A `requiresApproval: true` hook needs its own approval record binding
6
+ * (planDigest, hookId). The plan-level approval-record schema is NOT
7
+ * extended (its top level is additionalProperties: false); this module owns
8
+ * the separate postpublish-approval-record schema and its validation.
9
+ *
10
+ * Time semantics (24h max window, 5-minute clock-skew tolerance, expiry) are
11
+ * delegated wholesale to core/approval.mjs `validateApprovalTimeWindow` so
12
+ * both approval kinds can never drift apart. Hook config changes change the
13
+ * plan digest, so approvals invalidate naturally with the plan; `runId` is
14
+ * audit-only and never participates in binding.
15
+ *
16
+ * @module core/postpublish-approval
17
+ */
18
+
19
+ import Ajv from 'ajv';
20
+ import addFormats from 'ajv-formats';
21
+
22
+ import { ReleaseError, GATE_FAILED } from './errors.mjs';
23
+ import { computePlanDigest } from './plan.mjs';
24
+ import { validateApprovalTimeWindow } from './approval.mjs';
25
+ import { resolvePresetRequiresApproval } from './presets.mjs';
26
+ import { readTrustedPackageResource } from './trusted-resource.mjs';
27
+
28
+ const postpublishApprovalSchema = JSON.parse((await readTrustedPackageResource(
29
+ 'schemas/postpublish-approval-record.schema.json',
30
+ )).toString('utf8'));
31
+ const postpublishApprovalAjv = new Ajv({ allErrors: true, strict: false });
32
+ addFormats(postpublishApprovalAjv);
33
+ const validatePostPublishApprovalSchema = postpublishApprovalAjv.compile(postpublishApprovalSchema);
34
+
35
+ /**
36
+ * Schema-validate a postpublish checkpoint approval record.
37
+ *
38
+ * @param {object} approval
39
+ * @throws {ReleaseError} GATE_FAILED when the record violates the schema.
40
+ */
41
+ export function validatePostPublishApprovalRecordSchema(approval) {
42
+ if (validatePostPublishApprovalSchema(approval)) return;
43
+ const errors = validatePostPublishApprovalSchema.errors ?? [];
44
+ throw new ReleaseError(
45
+ GATE_FAILED,
46
+ `postpublish approval record schema validation failed: ${errors.map((error) => `${error.instancePath || '/'}: ${error.message}`).join('; ')}`,
47
+ { validationErrors: errors },
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Validate a checkpoint approval against the frozen plan.
53
+ *
54
+ * Bindings enforced (all fail-closed with GATE_FAILED):
55
+ * - record shape (postpublish-approval-record schema; additionalProperties
56
+ * is false, so plan-level fields like approvedActions are rejected);
57
+ * - planDigest equals the computed plan digest;
58
+ * - hookId names a hook declared in the frozen plan's postPublish.hooks;
59
+ * - that hook actually declares requiresApproval: true;
60
+ * - the shared approval time window (24h max, 5-minute skew, unexpired).
61
+ *
62
+ * @param {object} plan - Frozen plan (schema-valid, digest verified by caller).
63
+ * @param {object} approval - Parsed postpublish approval record.
64
+ * @param {object} [options]
65
+ * @param {() => string} [options.clock] - Clock function returning ISO-8601.
66
+ * @param {boolean} [options.requireUnexpired] - Default true.
67
+ * @returns {object} The approved hook declaration.
68
+ * @throws {ReleaseError} GATE_FAILED on any violation.
69
+ */
70
+ export function validatePostPublishApproval(plan, approval, options = {}) {
71
+ validatePostPublishApprovalRecordSchema(approval);
72
+
73
+ const actualDigest = computePlanDigest(plan);
74
+ if (approval.planDigest !== actualDigest) {
75
+ throw new ReleaseError(
76
+ GATE_FAILED,
77
+ `postpublish approval planDigest mismatch: approval says ${String(approval.planDigest).slice(0, 16)}..., plan is ${actualDigest.slice(0, 16)}...`,
78
+ { approvalPlanDigest: approval.planDigest, planDigest: actualDigest },
79
+ );
80
+ }
81
+
82
+ const hooks = plan.postPublish?.hooks ?? [];
83
+ const hook = hooks.find((entry) => entry.id === approval.hookId);
84
+ if (!hook) {
85
+ throw new ReleaseError(
86
+ GATE_FAILED,
87
+ `postpublish approval names hook "${approval.hookId}" which is not declared in the frozen plan`,
88
+ { hookId: approval.hookId, declaredHookIds: hooks.map((entry) => entry.id) },
89
+ );
90
+ }
91
+ // Effective requiresApproval (§2.6 grading): preset hooks may inherit the
92
+ // preset-declared default (proposal-inbox git-push -> true) without an
93
+ // explicit declaration; command hooks carry their declared value.
94
+ const effectiveRequiresApproval = hook.requiresApproval
95
+ ?? (hook.preset !== undefined ? resolvePresetRequiresApproval(hook.preset, hook.config) : false);
96
+ if (effectiveRequiresApproval !== true) {
97
+ throw new ReleaseError(
98
+ GATE_FAILED,
99
+ `postpublish approval names hook "${approval.hookId}" which does not require approval (requiresApproval is not true)`,
100
+ { hookId: approval.hookId, requiresApproval: effectiveRequiresApproval ?? false },
101
+ );
102
+ }
103
+
104
+ validateApprovalTimeWindow(approval, {
105
+ clock: options.clock,
106
+ requireUnexpired: options.requireUnexpired,
107
+ });
108
+
109
+ return hook;
110
+ }
@@ -23,7 +23,12 @@
23
23
  * @module core/postpublish
24
24
  */
25
25
 
26
- import { ReleaseError, GATE_FAILED } from './errors.mjs';
26
+ import { ReleaseError, GATE_FAILED, POSTPUBLISH_HOOK_INVALID } from './errors.mjs';
27
+ import {
28
+ postPublishPresetNames,
29
+ validatePresetHook,
30
+ resolvePresetRequiresApproval,
31
+ } from './presets.mjs';
27
32
 
28
33
  /** Secret-ish environment variable denylist (R3 credential hygiene). */
29
34
  export const ENV_ALLOWLIST_DENYLIST = /TOKEN|SECRET|PASSWORD|PASSPHRASE|API_KEY|CREDENTIAL/i;
@@ -178,6 +183,168 @@ function validateTarget(target, index) {
178
183
  }
179
184
  }
180
185
 
186
+ function failHook(message, details = {}) {
187
+ throw new ReleaseError(POSTPUBLISH_HOOK_INVALID, `postPublish hook invalid: ${message}`, details);
188
+ }
189
+
190
+ /**
191
+ * Re-check the command-execution safety fields shared by materialize/steps
192
+ * and postPublish command hooks. Throws POSTPUBLISH_HOOK_INVALID (hooks) or
193
+ * GATE_FAILED (materialize/steps, via `fail`) depending on `failFn`.
194
+ */
195
+ function validateCommandFields(where, hook, failFn) {
196
+ if (!Array.isArray(hook.command) || hook.command.length === 0) {
197
+ failFn(`${where}.command must be a non-empty array (shell strings are never accepted)`);
198
+ }
199
+ for (const element of hook.command) {
200
+ if (typeof element !== 'string' || element.length === 0) {
201
+ failFn(`${where}.command must contain only non-empty strings`);
202
+ }
203
+ if (/[\x00-\x1f\x7f]/.test(element)) {
204
+ failFn(`${where}.command contains control characters`, { element });
205
+ }
206
+ if (element.startsWith('-') && element === hook.command[0]) {
207
+ failFn(`${where}.command executable must not start with "-"`, { executable: element });
208
+ }
209
+ }
210
+ if (hook.cwd !== undefined) {
211
+ if (typeof hook.cwd !== 'string' || hook.cwd.length === 0) {
212
+ failFn(`${where}.cwd must be a non-empty string when provided`);
213
+ }
214
+ if (hook.cwd.startsWith('/') || hook.cwd.startsWith('./') || hook.cwd.includes('..')) {
215
+ failFn(`${where}.cwd must be a relative path inside the execution root`, { cwd: hook.cwd });
216
+ }
217
+ }
218
+ if (hook.timeoutMs !== undefined) {
219
+ if (!Number.isInteger(hook.timeoutMs) || hook.timeoutMs < 1000 || hook.timeoutMs > 7200000) {
220
+ failFn(`${where}.timeoutMs must be an integer in [1000, 7200000]`, { timeoutMs: hook.timeoutMs });
221
+ }
222
+ }
223
+ if (hook.envAllowlist !== undefined) {
224
+ if (!Array.isArray(hook.envAllowlist)) {
225
+ failFn(`${where}.envAllowlist must be an array`);
226
+ }
227
+ const seen = new Set();
228
+ for (const key of hook.envAllowlist) {
229
+ if (typeof key !== 'string' || !ENV_KEY_PATTERN.test(key)) {
230
+ failFn(`${where}.envAllowlist key ${JSON.stringify(key)} must be an uppercase [A-Z_][A-Z0-9_]* identifier`);
231
+ }
232
+ if (ENV_ALLOWLIST_DENYLIST.test(key)) {
233
+ failFn(
234
+ `${where}.envAllowlist key "${key}" matches the secret-ish denylist (TOKEN/SECRET/PASSWORD/PASSPHRASE/API_KEY/CREDENTIAL); distribute never reads or forwards credentials`,
235
+ { key },
236
+ );
237
+ }
238
+ if (seen.has(key)) {
239
+ failFn(`${where}.envAllowlist contains duplicate key "${key}"`);
240
+ }
241
+ seen.add(key);
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Validate one postPublish hooks[] entry (v0.6.3 R1). Every violation throws
248
+ * POSTPUBLISH_HOOK_INVALID: the hooks layer is fail-closed and never repairs.
249
+ *
250
+ * Rules:
251
+ * - id mandatory, /^[a-z0-9][a-z0-9._-]*$/;
252
+ * - preset XOR command: exactly one must be declared;
253
+ * - preset references must exist in `knownPresets` (R2: defaults to the
254
+ * built-in preset registry; an explicit empty list keeps the R1
255
+ * fail-closed stance);
256
+ * - preset configs are validated by the registry (dual addressing,
257
+ * marketplace/staticFiles shapes, secret scan) — POSTPUBLISH_HOOK_INVALID;
258
+ * - requiresApproval may tighten but never relax below the preset default;
259
+ * - command entries: executable+argument array safety (no shell strings);
260
+ * - phase: "distribute" (default) or "postVerify";
261
+ * - requiresApproval/blocksVerified booleans; blocksVerified: false is a
262
+ * preset-only permission — custom command hooks can never weaken the
263
+ * VERIFIED gate;
264
+ * - preset hooks must not declare command-hook execution fields
265
+ * (cwd/timeoutMs/envAllowlist); command hooks must not declare config.
266
+ *
267
+ * @param {object} hook
268
+ * @param {number} index
269
+ * @param {object} options - { knownPresets: string[] }
270
+ */
271
+ function validatePostPublishHookEntry(hook, index, options) {
272
+ const where = `hooks[${index}]`;
273
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
274
+ failHook(`${where} must be a non-null object`);
275
+ }
276
+ if (typeof hook.id !== 'string' || !SAFE_ID_RE.test(hook.id)) {
277
+ failHook(`${where}.id must match /^[a-z0-9][a-z0-9._-]*$/`, { id: hook.id });
278
+ }
279
+
280
+ const hasPreset = hook.preset !== undefined;
281
+ const hasCommand = hook.command !== undefined;
282
+ if (hasPreset && hasCommand) {
283
+ failHook(`${where}: preset and command are mutually exclusive — declare exactly one`, { id: hook.id });
284
+ }
285
+ if (!hasPreset && !hasCommand) {
286
+ failHook(`${where}: declare exactly one of preset or command`, { id: hook.id });
287
+ }
288
+
289
+ if (hook.phase !== undefined && hook.phase !== 'distribute' && hook.phase !== 'postVerify') {
290
+ failHook(`${where}.phase must be "distribute" or "postVerify"`, { phase: hook.phase });
291
+ }
292
+ if (hook.requiresApproval !== undefined && typeof hook.requiresApproval !== 'boolean') {
293
+ failHook(`${where}.requiresApproval must be a boolean`, { requiresApproval: hook.requiresApproval });
294
+ }
295
+ if (hook.blocksVerified !== undefined && typeof hook.blocksVerified !== 'boolean') {
296
+ failHook(`${where}.blocksVerified must be a boolean`, { blocksVerified: hook.blocksVerified });
297
+ }
298
+
299
+ if (hasPreset) {
300
+ if (typeof hook.preset !== 'string' || !SAFE_ID_RE.test(hook.preset)) {
301
+ failHook(`${where}.preset must match /^[a-z0-9][a-z0-9._-]*$/`, { preset: hook.preset });
302
+ }
303
+ // R2: the built-in preset registry is the default authority; callers may
304
+ // still override with an explicit list (an empty list keeps the R1
305
+ // fail-closed stance where every preset reference is rejected).
306
+ const knownPresets = options.knownPresets ?? postPublishPresetNames();
307
+ if (!knownPresets.includes(hook.preset)) {
308
+ failHook(
309
+ `${where}: unknown preset "${hook.preset}" — the preset registry does not contain it (fail-closed)`,
310
+ { preset: hook.preset, knownPresets },
311
+ );
312
+ }
313
+ if (hook.config !== undefined && (typeof hook.config !== 'object' || Array.isArray(hook.config) || hook.config === null)) {
314
+ failHook(`${where}.config must be a plain object when provided`, { id: hook.id });
315
+ }
316
+ for (const field of ['cwd', 'timeoutMs', 'envAllowlist']) {
317
+ if (hook[field] !== undefined) {
318
+ failHook(`${where}: preset hooks must not declare command-hook execution field "${field}"`, { field });
319
+ }
320
+ }
321
+ // Registry-driven config validation (dual addressing, marketplace block,
322
+ // staticFiles, secret scan) — fail-closed per preset (R2, §2.5/§2.6).
323
+ validatePresetHook(hook, where);
324
+ // requiresApproval grading (§2.6): projects may tighten (explicit true)
325
+ // but never relax below the preset-declared default.
326
+ const presetDefault = resolvePresetRequiresApproval(hook.preset, hook.config);
327
+ if (hook.requiresApproval === false && presetDefault === true) {
328
+ failHook(
329
+ `${where}: requiresApproval cannot be relaxed below the preset default (preset "${hook.preset}" defaults to true; declare true to tighten, never false to relax)`,
330
+ { preset: hook.preset, presetDefault },
331
+ );
332
+ }
333
+ } else {
334
+ // Custom command hook.
335
+ validateCommandFields(where, hook, failHook);
336
+ if (hook.blocksVerified === false) {
337
+ failHook(
338
+ `${where}: custom command hooks must not declare blocksVerified: false — only presets may weaken the VERIFIED gate`,
339
+ { id: hook.id },
340
+ );
341
+ }
342
+ if (hook.config !== undefined) {
343
+ failHook(`${where}: config is a preset-only field`, { id: hook.id });
344
+ }
345
+ }
346
+ }
347
+
181
348
  /**
182
349
  * Validate a per-unit postPublish declaration.
183
350
  *
@@ -185,8 +352,13 @@ function validateTarget(target, index) {
185
352
  * the caller's config layer; this function is the runtime re-check).
186
353
  * @param {object} [options]
187
354
  * @param {string} [options.unitId] - Unit id for error context.
355
+ * @param {string[]} [options.knownPresets] - Preset names accepted as known.
356
+ * Defaults to the built-in R2 preset registry (core/presets.mjs); an
357
+ * explicit empty list restores the R1 fail-closed stance where every
358
+ * preset reference is rejected.
188
359
  * @returns {object} The validated declaration (unmodified).
189
- * @throws {ReleaseError} GATE_FAILED on any violation (fail-closed).
360
+ * @throws {ReleaseError} GATE_FAILED on declaration violations;
361
+ * POSTPUBLISH_HOOK_INVALID on hooks[] violations (fail-closed).
190
362
  */
191
363
  export function validatePostPublishDeclaration(postPublish, options = {}) {
192
364
  const unitLabel = options.unitId ? `unit "${options.unitId}" ` : '';
@@ -210,11 +382,16 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
210
382
  }
211
383
  }
212
384
 
213
- if (!Array.isArray(postPublish.targets) || postPublish.targets.length === 0) {
214
- fail(`${unitLabel}targets must be a non-empty array`);
385
+ // targets: optional since v0.6.3 R1 (hooks-only declarations are legal).
386
+ // An explicitly empty array still fails closed (semantic lock: a present
387
+ // targets array must declare at least one target).
388
+ const targets = postPublish.targets ?? [];
389
+ if (postPublish.targets !== undefined
390
+ && (!Array.isArray(postPublish.targets) || postPublish.targets.length === 0)) {
391
+ fail(`${unitLabel}targets must be a non-empty array when present; omit it entirely for a hooks-only declaration`);
215
392
  }
216
393
  const ids = new Set();
217
- postPublish.targets.forEach((target, index) => {
394
+ targets.forEach((target, index) => {
218
395
  validateTarget(target, index);
219
396
  if (ids.has(target.id)) {
220
397
  fail(`${unitLabel}duplicate target id "${target.id}"`);
@@ -223,8 +400,8 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
223
400
  });
224
401
 
225
402
  // dependsOn references must exist and point at payload-mirror targets.
226
- const byId = new Map(postPublish.targets.map((target) => [target.id, target]));
227
- for (const target of postPublish.targets) {
403
+ const byId = new Map(targets.map((target) => [target.id, target]));
404
+ for (const target of targets) {
228
405
  if (target.dependsOn === undefined) continue;
229
406
  const dependency = byId.get(target.dependsOn);
230
407
  if (!dependency) {
@@ -235,6 +412,27 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
235
412
  }
236
413
  }
237
414
 
415
+ // hooks (v0.6.3 R1): per-entry fail-closed validation + unique ids across
416
+ // the normalized target/hook table.
417
+ if (postPublish.hooks !== undefined) {
418
+ if (!Array.isArray(postPublish.hooks)) {
419
+ failHook(`${unitLabel}hooks must be an array`);
420
+ }
421
+ postPublish.hooks.forEach((hook, index) => {
422
+ validatePostPublishHookEntry(hook, index, options);
423
+ });
424
+ const hookIds = new Set();
425
+ for (const hook of postPublish.hooks) {
426
+ if (hookIds.has(hook.id)) {
427
+ failHook(`${unitLabel}duplicate hook id "${hook.id}"`);
428
+ }
429
+ hookIds.add(hook.id);
430
+ if (ids.has(hook.id)) {
431
+ failHook(`${unitLabel}hook id "${hook.id}" conflicts with target id "${hook.id}"; normalized table ids must be unique`);
432
+ }
433
+ }
434
+ }
435
+
238
436
  // commitIdentity is mandatory whenever targets exist (always, here).
239
437
  const identity = postPublish.commitIdentity;
240
438
  if (!identity || typeof identity !== 'object' || Array.isArray(identity)) {
@@ -313,3 +511,222 @@ export function orderTargetsByDependency(targets) {
313
511
  }
314
512
  return ordered;
315
513
  }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // Hook normalization + context contract (v0.6.3 R1, design §2.3)
517
+ // ---------------------------------------------------------------------------
518
+
519
+ /**
520
+ * Environment variable carrying the read-only postPublish hook context.
521
+ * The JSON projection is injected into the hook process environment AFTER
522
+ * envAllowlist filtering (core/hooks.mjs injectEnv), so declarations can
523
+ * neither opt out of it nor smuggle it through their allowlist.
524
+ */
525
+ export const POSTPUBLISH_CONTEXT_ENV = 'RELEASE_SKILL_POSTPUBLISH_CONTEXT';
526
+
527
+ /**
528
+ * Normalize one declared postPublish hook entry: apply the governance
529
+ * defaults (phase distribute, blocksVerified true, requiresApproval false)
530
+ * and tag the entry kind. Pure function; the declaration is not mutated.
531
+ * This normalized shape is the digest-bound display surface shown at
532
+ * checkpoint approval time (review N-6).
533
+ *
534
+ * @param {object} hook - Declared hook entry (validate first for safety).
535
+ * @returns {object} Normalized entry: { id, kind, phase, blocksVerified,
536
+ * requiresApproval, + kind-specific fields (command/cwd/timeoutMs/
537
+ * envAllowlist or preset/config) }.
538
+ */
539
+ export function normalizePostPublishHook(hook) {
540
+ const kind = hook.preset !== undefined ? 'preset' : 'command';
541
+ // requiresApproval grading (§2.6): command hooks default false; preset
542
+ // hooks inherit the preset-declared default (public-write presets true,
543
+ // notify-handoff / proposal-inbox local-file false; validation forbids
544
+ // relaxing below the default, so this resolution can only ever confirm or
545
+ // tighten).
546
+ const defaultRequiresApproval = kind === 'preset'
547
+ ? resolvePresetRequiresApproval(hook.preset, hook.config)
548
+ : false;
549
+ const normalized = {
550
+ id: hook.id,
551
+ kind,
552
+ ...(kind === 'preset' ? { preset: hook.preset } : { command: [...hook.command] }),
553
+ phase: hook.phase ?? 'distribute',
554
+ ...(hook.config !== undefined ? { config: hook.config } : {}),
555
+ ...(kind === 'command' && hook.cwd !== undefined ? { cwd: hook.cwd } : {}),
556
+ ...(hook.timeoutMs !== undefined ? { timeoutMs: hook.timeoutMs } : {}),
557
+ ...(hook.envAllowlist !== undefined ? { envAllowlist: [...hook.envAllowlist] } : {}),
558
+ requiresApproval: hook.requiresApproval ?? defaultRequiresApproval,
559
+ blocksVerified: hook.blocksVerified ?? true,
560
+ };
561
+ return normalized;
562
+ }
563
+
564
+ /**
565
+ * Effective requiresApproval (§2.6 grading) for one declared hook: command
566
+ * hooks carry their declared value; preset hooks inherit the preset-declared
567
+ * default (public-write presets true; proposal-inbox graded by transport;
568
+ * notify-handoff / local-file false) unless explicitly tightened. Single
569
+ * authority shared by distribute, postverify, and the ship re-entry gate.
570
+ *
571
+ * @param {object} hook - Declared hook entry.
572
+ * @returns {boolean}
573
+ */
574
+ export function effectiveHookRequiresApproval(hook) {
575
+ if (hook.requiresApproval !== undefined) return hook.requiresApproval === true;
576
+ if (hook.preset !== undefined) return resolvePresetRequiresApproval(hook.preset, hook.config) === true;
577
+ return false;
578
+ }
579
+
580
+ /**
581
+ * Build the read-only context projection injected into postPublish hooks
582
+ * (design §2.3). Every field comes from the frozen plan or the sealed source
583
+ * run — never from the live workspace.
584
+ *
585
+ * Phase distinction: `verifyEvidence` is carried ONLY for postVerify-phase
586
+ * hooks; for the distribute phase the key is ABSENT (not null), so hooks can
587
+ * distinguish the phases without trusting a mutable value.
588
+ *
589
+ * @param {object} args
590
+ * @param {object} args.plan - Frozen plan (digest + units + postPublish).
591
+ * @param {string} args.runId - Current distribute run id.
592
+ * @param {object} args.sourceRun - Sealed source run (finishedAt = publishedAt).
593
+ * @param {string} args.payloadDir - Materialized payload directory.
594
+ * @param {'distribute'|'postVerify'} args.phase
595
+ * @param {object} [args.verifyEvidence] - Verify evidence (postVerify phase).
596
+ * @returns {object} The context projection.
597
+ */
598
+ export function buildPostPublishContext({ plan, runId, sourceRun, payloadDir, phase, verifyEvidence }) {
599
+ const postPublish = plan.postPublish ?? {};
600
+ const unit = (plan.units ?? []).find((entry) => entry.id === postPublish.unitId);
601
+ const frozenSnapshot = unit?.frozenSnapshot ?? {};
602
+ return {
603
+ planDigest: plan.digest,
604
+ runId,
605
+ unitId: postPublish.unitId,
606
+ version: unit?.targetVersion,
607
+ tag: postPublish.tag,
608
+ commit: postPublish.tagCommit,
609
+ ...(frozenSnapshot.tree !== undefined ? { tree: frozenSnapshot.tree } : {}),
610
+ ...(frozenSnapshot.manifestDigest !== undefined ? { manifestDigest: frozenSnapshot.manifestDigest } : {}),
611
+ publishedAt: sourceRun?.finishedAt,
612
+ payloadDir,
613
+ ...(phase === 'postVerify' && verifyEvidence !== undefined ? { verifyEvidence } : {}),
614
+ };
615
+ }
616
+
617
+ // ---------------------------------------------------------------------------
618
+ // Targets normalization (v0.6.3 R2, design §2.2)
619
+ // ---------------------------------------------------------------------------
620
+
621
+ /** Legacy target kind -> absorbing preset (design §2.5). */
622
+ const TARGET_KIND_PRESET = {
623
+ 'payload-mirror': 'git-mirror',
624
+ 'marketplace-index': 'marketplace-index-render',
625
+ };
626
+
627
+ /**
628
+ * Normalize one legacy targets[] entry into a preset hook entry. Field-level
629
+ * mapping (review N-B2): remoteUrl/branch -> config.target; visibility ->
630
+ * config.visibility (public-write/internal-write semantics preserved);
631
+ * staticFiles -> config.staticFiles; marketplace -> config.marketplace;
632
+ * dependsOn -> hook-level dependency (runtime validates existence + acyclicity,
633
+ * semantics unchanged).
634
+ *
635
+ * @param {object} target - Validated postPublish target entry.
636
+ * @returns {object} Normalized preset hook entry.
637
+ */
638
+ function normalizeTargetToPresetHook(target) {
639
+ const preset = TARGET_KIND_PRESET[target.kind];
640
+ return {
641
+ id: target.id,
642
+ kind: 'preset',
643
+ preset,
644
+ origin: 'target',
645
+ originKind: target.kind,
646
+ phase: 'distribute',
647
+ config: {
648
+ target: { remoteUrl: target.remoteUrl, branch: target.branch },
649
+ visibility: target.visibility,
650
+ ...(target.staticFiles !== undefined
651
+ ? { staticFiles: target.staticFiles.map((file) => ({ from: file.from, to: file.to })) }
652
+ : {}),
653
+ ...(target.marketplace !== undefined
654
+ ? { marketplace: structuredClone(target.marketplace) }
655
+ : {}),
656
+ },
657
+ ...(target.dependsOn !== undefined ? { dependsOn: target.dependsOn } : {}),
658
+ requiresApproval: resolvePresetRequiresApproval(preset, {}),
659
+ blocksVerified: true,
660
+ };
661
+ }
662
+
663
+ /**
664
+ * Normalize a validated postPublish declaration into the unified hook table
665
+ * (design §2.2). Pure and deterministic: the table is a projection of the
666
+ * digest-bound declaration, so any targets/hooks list change changes the plan
667
+ * digest and invalidates existing approvals.
668
+ *
669
+ * Shape:
670
+ * - `preGates`: section-level gates injected ahead of all git-write hooks —
671
+ * assertMainVersionAhead:true becomes `assert-main-version-ahead` (current
672
+ * distribute semantics preserved);
673
+ * - `defaults`: section-level defaults injected into the relevant presets —
674
+ * materialize / steps / commitIdentity;
675
+ * - `hooks`: target-derived preset hooks first (declaration order), then the
676
+ * declared hooks[] entries normalized via normalizePostPublishHook.
677
+ *
678
+ * Validate the declaration BEFORE calling this (id conflicts, dependency
679
+ * references, and preset semantics are fail-closed there, not here).
680
+ *
681
+ * @param {object} postPublish - Validated postPublish declaration.
682
+ * @returns {{ preGates: object[], defaults: object, hooks: object[] }}
683
+ */
684
+ export function normalizePostPublishDeclaration(postPublish) {
685
+ const preGates = postPublish.assertMainVersionAhead === true
686
+ ? [{ gate: 'assert-main-version-ahead', before: 'git-write-hooks' }]
687
+ : [];
688
+ const defaults = {
689
+ materialize: structuredClone(postPublish.materialize),
690
+ steps: structuredClone(postPublish.steps ?? []),
691
+ commitIdentity: structuredClone(postPublish.commitIdentity),
692
+ };
693
+ const targetHooks = (postPublish.targets ?? []).map((target) => normalizeTargetToPresetHook(target));
694
+ const declaredHooks = (postPublish.hooks ?? []).map((hook) => normalizePostPublishHook(hook));
695
+ return { preGates, defaults, hooks: [...targetHooks, ...declaredHooks] };
696
+ }
697
+
698
+ /**
699
+ * Order the normalized hook table so every hook runs after its dependsOn
700
+ * hook, preserving declaration order among ready entries (dependency
701
+ * topology + declaration order). Dangling references and cycles fail closed
702
+ * with GATE_FAILED — references are also validated at declaration time; this
703
+ * guard keeps ordering independently safe.
704
+ *
705
+ * @param {object[]} hooks - Normalized hook table.
706
+ * @returns {object[]} Hooks in execution order.
707
+ */
708
+ export function orderNormalizedHooks(hooks) {
709
+ const byId = new Map(hooks.map((hook) => [hook.id, hook]));
710
+ const ordered = [];
711
+ const placed = new Set();
712
+ while (ordered.length < hooks.length) {
713
+ let progress = false;
714
+ for (const hook of hooks) {
715
+ if (placed.has(hook.id)) continue;
716
+ if (hook.dependsOn !== undefined) {
717
+ if (!byId.has(hook.dependsOn)) {
718
+ fail(`hook "${hook.id}" dependsOn unknown hook "${hook.dependsOn}"`);
719
+ }
720
+ if (!placed.has(hook.dependsOn)) continue;
721
+ }
722
+ ordered.push(hook);
723
+ placed.add(hook.id);
724
+ progress = true;
725
+ }
726
+ if (!progress) {
727
+ const pending = hooks.filter((hook) => !placed.has(hook.id)).map((hook) => hook.id);
728
+ fail(`postPublish hook dependency cycle detected among: ${pending.join(', ')}`);
729
+ }
730
+ }
731
+ return ordered;
732
+ }