release-skill 0.6.1 → 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 (77) 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 +40 -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 +129 -9
  11. package/README.zh-CN.md +111 -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 +6596 -1586
  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 +74 -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 +6596 -1586
  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 +74 -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 +6596 -1586
  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 +74 -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 +6596 -1586
  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 +74 -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 +6596 -1586
  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 +74 -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/lineage.mjs +101 -32
  55. package/src/commands/postverify.mjs +734 -0
  56. package/src/commands/prepare.mjs +339 -43
  57. package/src/commands/publish.mjs +10 -1
  58. package/src/commands/setup.mjs +715 -0
  59. package/src/commands/ship.mjs +152 -5
  60. package/src/commands/verify.mjs +92 -15
  61. package/src/core/approval.mjs +93 -68
  62. package/src/core/bounded-output.mjs +46 -0
  63. package/src/core/derived-artifact-gates.mjs +258 -0
  64. package/src/core/docs-refresh-preset.mjs +167 -0
  65. package/src/core/errors.mjs +4 -0
  66. package/src/core/hooks.mjs +28 -0
  67. package/src/core/marketplace-registry-entry.mjs +174 -0
  68. package/src/core/notify-handoff.mjs +76 -0
  69. package/src/core/postpublish-approval.mjs +110 -0
  70. package/src/core/postpublish.mjs +424 -7
  71. package/src/core/preset-executor.mjs +156 -0
  72. package/src/core/preset-gitwrite.mjs +463 -0
  73. package/src/core/presets.mjs +706 -0
  74. package/src/core/proposal-inbox.mjs +630 -0
  75. package/src/core/run.mjs +91 -6
  76. package/src/core/skill-resource-closure.mjs +240 -10
  77. package/src/platforms/registry.mjs +12 -0
@@ -0,0 +1,706 @@
1
+ /**
2
+ * Post-publish preset registry (v0.6.3 R2 registry / R4 complete, design
3
+ * §2.5/§2.6).
4
+ *
5
+ * A preset = a name + a JSON-Schema-style runtime-validated config + a
6
+ * release-skill built-in implementation. The registry ships with the bundle
7
+ * and is enumerable via `release-skill distribute --list-presets`.
8
+ *
9
+ * Entries:
10
+ * - `git-mirror` (implemented): absorbs the legacy `kind: payload-mirror`
11
+ * target; the distribute-git mirror semantics (clone -> wipe -> write ->
12
+ * commit -> push, NO_CHANGE idempotence, same-name tag move ->
13
+ * REMOTE_CONFLICT, host credential helper stance, dry-run).
14
+ * - `marketplace-index-render` (implemented): absorbs the legacy
15
+ * `kind: marketplace-index` target (renderMarketplaceIndex + mirror push).
16
+ * - `proposal-inbox` (implemented): both transports — git-push (R3: clone ->
17
+ * write incoming/<unit>-<version>.json -> commit -> push; ls-remote
18
+ * cross-check) and local-file (R4: same proposal into the local checkout
19
+ * at target.workspace; committed locally, NEVER pushed). config.target is
20
+ * the ONLY optional target among write-downstream presets; absent target
21
+ * degrades to notify-handoff behavior instead of erroring.
22
+ * - `notify-handoff` (implemented, R4): the zero-write floor; renders the
23
+ * frozen context into a deterministic downstream sync checklist in
24
+ * evidence; never accepts a downstream target.
25
+ * - `marketplace-registry-entry` (implemented, R4): direct-edit downstream
26
+ * registry entry update from frozen plan values + downstream gates.
27
+ * - `docs-refresh` (implemented, R4): refresh one or more independent docs
28
+ * repositories from payload mappings + docs build gates.
29
+ *
30
+ * Dual addressing (§2.5, review N-B1): write-downstream presets share
31
+ * `config.target` with `remoteUrl` XOR `workspace`. Both or neither ->
32
+ * POSTPUBLISH_HOOK_INVALID. `workspace` paths may leave the repository root
33
+ * (preset-level exception to the runner's cwd containment rule); declaration
34
+ * time rejects control characters and `.release-skill/` runtime paths, and
35
+ * the raw value is plan-digest-bound (auditable at approval time). The
36
+ * three §2.6 execution checks (realpath evidence, TOCTOU re-check, release
37
+ * workspace / runtime directory exclusion) are provided here for the presets
38
+ * that execute against workspaces.
39
+ *
40
+ * requiresApproval grading (§2.6): defaults are declared per preset — public
41
+ * write presets default true; proposal-inbox local-file and notify-handoff
42
+ * default false. proposal-inbox runtime grading follows the EFFECTIVE
43
+ * transport: `config.delivery ?? addressing inference` (a target.remoteUrl
44
+ * without delivery infers git-push, a public write -> true; a
45
+ * target.workspace infers local-file -> false). The target-less
46
+ * notify-handoff degradation is the zero-write floor and always grades false.
47
+ * Projects may tighten (explicit true) but never relax below the preset
48
+ * default.
49
+ *
50
+ * Secret scanning: preset config string values are scanned with the same
51
+ * fail-closed stance as the snapshot scan — private-key blocks and common
52
+ * token prefixes fail with POSTPUBLISH_HOOK_INVALID (values never echoed).
53
+ *
54
+ * @module core/presets
55
+ */
56
+
57
+ import { realpath, stat } from 'node:fs/promises';
58
+ import { isAbsolute, resolve } from 'node:path';
59
+
60
+ import { ReleaseError, GATE_FAILED, POSTPUBLISH_HOOK_INVALID } from './errors.mjs';
61
+
62
+ /** Remote URL pattern shared with core/postpublish.mjs target validation. */
63
+ const REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
64
+
65
+ /** Branch pattern (leading alphanumeric blocks option-like names). */
66
+ const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
67
+
68
+ /** Safe relative-path shape for staticFiles entries (same as targets). */
69
+ function assertSafeStaticFilePath(where, field, value, failFn) {
70
+ if (typeof value !== 'string' || value.length === 0) {
71
+ failFn(`${where}.${field} must be a non-empty string`);
72
+ }
73
+ if (value.startsWith('/') || value.startsWith('./') || value === '.'
74
+ || value.includes('..') || value.includes('\\') || value.includes(':')) {
75
+ failFn(`${where}.${field} is not a safe relative path`, { value });
76
+ }
77
+ }
78
+
79
+ function failHook(message, details = {}) {
80
+ throw new ReleaseError(POSTPUBLISH_HOOK_INVALID, `postPublish hook invalid: ${message}`, details);
81
+ }
82
+
83
+ function assertNoControlChars(label, value) {
84
+ if (/[\x00-\x1f\x7f]/.test(value)) {
85
+ failHook(`${label} contains control characters`, { label });
86
+ }
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Secret scanning over preset config (§2.6: covers preset config + the
91
+ // normalized hook table, inheriting the fail-closed scan stance)
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /** Conservative secret shapes; matched values are NEVER echoed back. */
95
+ const CONFIG_SECRET_PATTERNS = [
96
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
97
+ /\bghp_[A-Za-z0-9]{20,}\b/,
98
+ /\bgho_[A-Za-z0-9]{20,}\b/,
99
+ /\bghs_[A-Za-z0-9]{20,}\b/,
100
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
101
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
102
+ /\bAKIA[A-Z0-9]{12,}\b/,
103
+ /\bsk-[A-Za-z0-9_-]{20,}\b/,
104
+ ];
105
+
106
+ /** Location labels are safe to report; matched secret values never are. */
107
+ function scanConfigValueForSecrets(pathLabel, value) {
108
+ if (typeof value !== 'string') return;
109
+ for (const pattern of CONFIG_SECRET_PATTERNS) {
110
+ if (pattern.test(value)) {
111
+ failHook(
112
+ `preset config carries a secret-looking value at "${pathLabel}"; credentials belong to the host keychain, never to postPublish config`,
113
+ { location: pathLabel },
114
+ );
115
+ }
116
+ }
117
+ }
118
+
119
+ function scanConfigForSecrets(config, where) {
120
+ const walk = (node, pathLabel) => {
121
+ if (typeof node === 'string') {
122
+ scanConfigValueForSecrets(pathLabel, node);
123
+ return;
124
+ }
125
+ if (Array.isArray(node)) {
126
+ node.forEach((item, index) => walk(item, `${pathLabel}[${index}]`));
127
+ return;
128
+ }
129
+ if (node && typeof node === 'object') {
130
+ for (const [key, value] of Object.entries(node)) {
131
+ walk(value, pathLabel ? `${pathLabel}.${key}` : key);
132
+ }
133
+ }
134
+ };
135
+ walk(config, `${where}.config`);
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Registry
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * Shared config.target validator for write-downstream presets (N-B1):
144
+ * remoteUrl XOR workspace, safe branch, §2.6 workspace declaration rules.
145
+ */
146
+ function validateDownstreamTarget(where, target, { targetOptional }) {
147
+ if (target === undefined) {
148
+ if (targetOptional) return; // proposal-inbox: degrade to notify-handoff.
149
+ failHook(`${where}: write-downstream presets require a config.target declaration`, {});
150
+ }
151
+ if (!target || typeof target !== 'object' || Array.isArray(target)) {
152
+ failHook(`${where}.config.target must be a plain object`);
153
+ }
154
+ const hasRemoteUrl = target.remoteUrl !== undefined;
155
+ const hasWorkspace = target.workspace !== undefined;
156
+ if (hasRemoteUrl === hasWorkspace) {
157
+ failHook(
158
+ `${where}.config.target must declare exactly one of remoteUrl or workspace (dual addressing)`,
159
+ { hasRemoteUrl, hasWorkspace },
160
+ );
161
+ }
162
+ if (hasRemoteUrl) {
163
+ if (typeof target.remoteUrl !== 'string' || !REMOTE_URL_RE.test(target.remoteUrl)) {
164
+ failHook(`${where}.config.target.remoteUrl must be an http(s)/file URL ending in .git`, {
165
+ remoteUrl: target.remoteUrl,
166
+ });
167
+ }
168
+ assertNoControlChars(`${where}.config.target.remoteUrl`, target.remoteUrl);
169
+ } else {
170
+ if (typeof target.workspace !== 'string' || target.workspace.length === 0) {
171
+ failHook(`${where}.config.target.workspace must be a non-empty string`);
172
+ }
173
+ assertNoControlChars(`${where}.config.target.workspace`, target.workspace);
174
+ // §2.6: the runtime directory is excluded already at declaration time;
175
+ // relative or absolute paths outside the repository root are allowed
176
+ // (preset-level exception), and the raw value stays plan-digest-bound.
177
+ const segments = target.workspace.split(/[\\/]+/);
178
+ if (segments.includes('.release-skill')) {
179
+ failHook(
180
+ `${where}.config.target.workspace must not point into the .release-skill/ runtime directory`,
181
+ {},
182
+ );
183
+ }
184
+ }
185
+ if (typeof target.branch !== 'string' || !BRANCH_RE.test(target.branch)) {
186
+ failHook(`${where}.config.target.branch is not a safe Git branch name`, { branch: target.branch });
187
+ }
188
+ if (target.branch.includes('..') || target.branch.endsWith('.') || target.branch.endsWith('.lock')) {
189
+ failHook(`${where}.config.target.branch is not a safe Git branch name`, { branch: target.branch });
190
+ }
191
+ }
192
+
193
+ /** Optional config.visibility: keeps the public-write/internal-write split. */
194
+ function validateVisibility(where, config) {
195
+ if (config.visibility !== undefined
196
+ && config.visibility !== 'internal' && config.visibility !== 'public') {
197
+ failHook(`${where}.config.visibility must be "internal" or "public"`, {
198
+ visibility: config.visibility,
199
+ });
200
+ }
201
+ }
202
+
203
+ /** Optional config.staticFiles: identical shape to the legacy target field. */
204
+ function validateStaticFiles(where, config) {
205
+ if (config.staticFiles === undefined) return;
206
+ if (!Array.isArray(config.staticFiles)) {
207
+ failHook(`${where}.config.staticFiles must be an array`);
208
+ }
209
+ for (const [index, file] of config.staticFiles.entries()) {
210
+ if (!file || typeof file !== 'object' || Array.isArray(file)) {
211
+ failHook(`${where}.config.staticFiles[${index}] must be a plain object`);
212
+ }
213
+ assertSafeStaticFilePath(`${where}.config.staticFiles[${index}]`, 'from', file?.from, failHook);
214
+ assertSafeStaticFilePath(`${where}.config.staticFiles[${index}]`, 'to', file?.to, failHook);
215
+ }
216
+ }
217
+
218
+ function validateMarketplaceBlock(where, config) {
219
+ const marketplace = config.marketplace;
220
+ if (!marketplace || typeof marketplace !== 'object' || Array.isArray(marketplace)) {
221
+ failHook(`${where}: marketplace-index-render requires a config.marketplace block`);
222
+ }
223
+ const { form, name, owner } = marketplace;
224
+ if (form !== 'github' && form !== 'url') {
225
+ failHook(`${where}.config.marketplace.form must be "github" or "url"`, { form });
226
+ }
227
+ if (typeof name !== 'string' || name.length === 0) {
228
+ failHook(`${where}.config.marketplace.name must be a non-empty string`);
229
+ }
230
+ if (typeof owner !== 'string' || owner.length === 0) {
231
+ failHook(`${where}.config.marketplace.owner must be a non-empty string`);
232
+ }
233
+ if (marketplace.sourceRepo !== undefined
234
+ && (typeof marketplace.sourceRepo !== 'string'
235
+ || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(marketplace.sourceRepo))) {
236
+ failHook(`${where}.config.marketplace.sourceRepo must be owner/repo when provided`);
237
+ }
238
+ }
239
+
240
+ function requirePlainConfig(where, hook) {
241
+ if (hook.config === undefined || typeof hook.config !== 'object'
242
+ || Array.isArray(hook.config) || hook.config === null) {
243
+ failHook(`${where}: this preset requires a config object`, {});
244
+ }
245
+ return hook.config;
246
+ }
247
+
248
+ /**
249
+ * §2.3 context fields accepted as fieldsFromPlan sources: only frozen plan
250
+ * values may flow into a downstream registry entry.
251
+ */
252
+ export const FIELDS_FROM_PLAN_SOURCES = new Set([
253
+ 'unitId',
254
+ 'version',
255
+ 'tag',
256
+ 'commit',
257
+ 'tree',
258
+ 'manifestDigest',
259
+ 'planDigest',
260
+ 'publishedAt',
261
+ ]);
262
+
263
+ /** Safe registry-entry field name (also blocks prototype-pollution keys). */
264
+ const SAFE_FIELD_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
265
+ const FORBIDDEN_FIELDS = new Set(['__proto__', 'prototype', 'constructor']);
266
+
267
+ /** Shape of config.fieldsFromPlan (marketplace-registry-entry). */
268
+ function validateFieldsFromPlan(where, config) {
269
+ const fieldsFromPlan = config.fieldsFromPlan;
270
+ if (!fieldsFromPlan || typeof fieldsFromPlan !== 'object' || Array.isArray(fieldsFromPlan)) {
271
+ failHook(`${where}.config.fieldsFromPlan must be a plain object mapping entry fields to frozen plan fields`);
272
+ }
273
+ const entries = Object.entries(fieldsFromPlan);
274
+ if (entries.length === 0) {
275
+ failHook(`${where}.config.fieldsFromPlan must map at least one entry field`);
276
+ }
277
+ for (const [entryField, sourceField] of entries) {
278
+ if (!SAFE_FIELD_RE.test(entryField) || FORBIDDEN_FIELDS.has(entryField)) {
279
+ failHook(`${where}.config.fieldsFromPlan field "${entryField}" is not a safe entry field name`, { entryField });
280
+ }
281
+ if (typeof sourceField !== 'string' || !FIELDS_FROM_PLAN_SOURCES.has(sourceField)) {
282
+ failHook(
283
+ `${where}.config.fieldsFromPlan."${entryField}" must map to a frozen plan context field (${[...FIELDS_FROM_PLAN_SOURCES].join('/')})`,
284
+ { entryField, sourceField },
285
+ );
286
+ }
287
+ }
288
+ }
289
+
290
+ /** Secret-ish env denylist for downstream gates (mirrors postpublish.mjs). */
291
+ const GATE_ENV_DENYLIST = /TOKEN|SECRET|PASSWORD|PASSPHRASE|API_KEY|CREDENTIAL/i;
292
+ const GATE_ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
293
+
294
+ /**
295
+ * Downstream gates (marketplace-registry-entry / docs-refresh): the same
296
+ * fail-closed safety rules as command hooks — executable + argument arrays
297
+ * (never shell strings), relative cwd, timeout bounds, envAllowlist with the
298
+ * secret-ish denylist. Gates run inside the downstream worktree via the R1
299
+ * hook runner.
300
+ */
301
+ function validateGates(where, config) {
302
+ if (config.gates === undefined) return;
303
+ if (!Array.isArray(config.gates)) {
304
+ failHook(`${where}.config.gates must be an array`);
305
+ }
306
+ for (const [index, gate] of config.gates.entries()) {
307
+ const gwhere = `${where}.config.gates[${index}]`;
308
+ if (!gate || typeof gate !== 'object' || Array.isArray(gate)) {
309
+ failHook(`${gwhere} must be a plain object`);
310
+ }
311
+ if (!Array.isArray(gate.command) || gate.command.length === 0) {
312
+ failHook(`${gwhere}.command must be a non-empty array (shell strings are never accepted)`);
313
+ }
314
+ for (const element of gate.command) {
315
+ if (typeof element !== 'string' || element.length === 0) {
316
+ failHook(`${gwhere}.command must contain only non-empty strings`);
317
+ }
318
+ assertNoControlChars(`${gwhere}.command`, element);
319
+ if (element.startsWith('-') && element === gate.command[0]) {
320
+ failHook(`${gwhere}.command executable must not start with "-"`, { executable: element });
321
+ }
322
+ }
323
+ if (gate.cwd !== undefined) {
324
+ if (typeof gate.cwd !== 'string' || gate.cwd.length === 0) {
325
+ failHook(`${gwhere}.cwd must be a non-empty string when provided`);
326
+ }
327
+ if (gate.cwd.startsWith('/') || gate.cwd.startsWith('./') || gate.cwd.includes('..')) {
328
+ failHook(`${gwhere}.cwd must be a relative path inside the downstream repository`, { cwd: gate.cwd });
329
+ }
330
+ }
331
+ if (gate.timeoutMs !== undefined) {
332
+ if (!Number.isInteger(gate.timeoutMs) || gate.timeoutMs < 1000 || gate.timeoutMs > 7200000) {
333
+ failHook(`${gwhere}.timeoutMs must be an integer in [1000, 7200000]`, { timeoutMs: gate.timeoutMs });
334
+ }
335
+ }
336
+ if (gate.envAllowlist !== undefined) {
337
+ if (!Array.isArray(gate.envAllowlist)) {
338
+ failHook(`${gwhere}.envAllowlist must be an array`);
339
+ }
340
+ const seen = new Set();
341
+ for (const key of gate.envAllowlist) {
342
+ if (typeof key !== 'string' || !GATE_ENV_KEY_RE.test(key)) {
343
+ failHook(`${gwhere}.envAllowlist key ${JSON.stringify(key)} must be an uppercase [A-Z_][A-Z0-9_]* identifier`);
344
+ }
345
+ if (GATE_ENV_DENYLIST.test(key)) {
346
+ failHook(
347
+ `${gwhere}.envAllowlist key "${key}" matches the secret-ish denylist (TOKEN/SECRET/PASSWORD/PASSPHRASE/API_KEY/CREDENTIAL); gates never receive credentials`,
348
+ { key },
349
+ );
350
+ }
351
+ if (seen.has(key)) {
352
+ failHook(`${gwhere}.envAllowlist contains duplicate key "${key}"`);
353
+ }
354
+ seen.add(key);
355
+ }
356
+ }
357
+ }
358
+ }
359
+
360
+ const REGISTRY = [
361
+ {
362
+ name: 'git-mirror',
363
+ description:
364
+ 'Mirror the frozen release payload to any git remote (clone -> wipe -> write -> commit -> push; NO_CHANGE idempotent; same-name tag move fails closed). Absorbs the legacy kind: payload-mirror target. Credentials come from the host git credential helper/keychain only. Targets-form only: declare it under postPublish.targets (kind: payload-mirror), not postPublish.hooks.',
365
+ writeDownstream: true,
366
+ targetOptional: false,
367
+ implemented: true,
368
+ defaultRequiresApproval: true,
369
+ // R4 review M-2: the executor routes this preset through the targets[]
370
+ // pipeline only; a hooks[] declaration would validate but never execute,
371
+ // so declaration validation rejects it fail-closed (targets-form only).
372
+ targetsFormOnly: true,
373
+ legacyTargetKind: 'payload-mirror',
374
+ validateConfig(where, hook) {
375
+ const config = requirePlainConfig(where, hook);
376
+ validateDownstreamTarget(where, config.target, { targetOptional: false });
377
+ validateVisibility(where, config);
378
+ validateStaticFiles(where, config);
379
+ },
380
+ },
381
+ {
382
+ name: 'marketplace-index-render',
383
+ description:
384
+ 'Render .claude-plugin/marketplace.json from the frozen plan and push it to a downstream git remote. Absorbs the legacy kind: marketplace-index target (marketplace.form/name/owner/sourceRepo and staticFiles carry over). Targets-form only: declare it under postPublish.targets (kind: marketplace-index), not postPublish.hooks.',
385
+ writeDownstream: true,
386
+ targetOptional: false,
387
+ implemented: true,
388
+ defaultRequiresApproval: true,
389
+ // R4 review M-2: targets-form only (see git-mirror above).
390
+ targetsFormOnly: true,
391
+ legacyTargetKind: 'marketplace-index',
392
+ validateConfig(where, hook) {
393
+ const config = requirePlainConfig(where, hook);
394
+ validateDownstreamTarget(where, config.target, { targetOptional: false });
395
+ validateMarketplaceBlock(where, config);
396
+ validateVisibility(where, config);
397
+ validateStaticFiles(where, config);
398
+ },
399
+ },
400
+ {
401
+ name: 'proposal-inbox',
402
+ description:
403
+ 'Deliver a machine-readable update proposal for autonomous downstream consumption (hub scenarios). Transports: git-push (clone -> write incoming/<unit>-<version>.json -> commit -> push; NO_CHANGE idempotent; ls-remote cross-check) and local-file (write the same proposal into the local checkout at target.workspace and commit it locally; NEVER pushed). config.target is optional: without it the preset degrades to notify-handoff behavior instead of failing.',
404
+ writeDownstream: true,
405
+ targetOptional: true,
406
+ // git-push transport delivered in R3, local-file transport in R4; the
407
+ // description names both shipped behaviors (review minor-2 honesty).
408
+ implemented: true,
409
+ // Graded by transport: git-push is public write (true); local-file and
410
+ // the notify-handoff degradation stay false (§2.6).
411
+ defaultRequiresApproval: false,
412
+ // Followup 7: the registry default is false, but runtime grading is
413
+ // STRICTER per transport — --list-presets must say so plainly. R4 review
414
+ // M-1: grading follows the EFFECTIVE transport (delivery ?? addressing
415
+ // inference), so a remoteUrl-only declaration still grades true.
416
+ requiresApprovalNote:
417
+ 'runtime transport grading is stricter than this default: the effective git-push transport (declared delivery, or a remoteUrl address that infers git-push) runs as requiresApproval true (public write); local-file and the target-less notify-handoff degradation stay false',
418
+ validateConfig(where, hook) {
419
+ if (hook.config === undefined) return; // target-less degradation is legal.
420
+ if (typeof hook.config !== 'object' || Array.isArray(hook.config) || hook.config === null) {
421
+ failHook(`${where}.config must be a plain object when provided`);
422
+ }
423
+ const config = hook.config;
424
+ if (config.delivery !== undefined
425
+ && config.delivery !== 'local-file' && config.delivery !== 'git-push') {
426
+ failHook(`${where}.config.delivery must be "local-file" or "git-push"`, {
427
+ delivery: config.delivery,
428
+ });
429
+ }
430
+ validateDownstreamTarget(where, config.target, { targetOptional: true });
431
+ // Transport/addressing coherence (R4): git-push needs a remote to push;
432
+ // local-file needs a local checkout to write.
433
+ if (config.target !== undefined && config.delivery !== undefined) {
434
+ if (config.delivery === 'git-push' && typeof config.target.remoteUrl !== 'string') {
435
+ failHook(`${where}: delivery "git-push" requires a config.target.remoteUrl address`, {});
436
+ }
437
+ if (config.delivery === 'local-file' && typeof config.target.workspace !== 'string') {
438
+ failHook(`${where}: delivery "local-file" requires a config.target.workspace address`, {});
439
+ }
440
+ }
441
+ },
442
+ },
443
+ {
444
+ name: 'notify-handoff',
445
+ description:
446
+ 'Zero-write floor: renders the frozen context into a deterministic downstream sync checklist (version/tag/sha/tree/evidence path/suggested actions) in evidence and echoes it. No writes anywhere, zero configuration, usable by any project, never takes a downstream target; every downstream scenario degrades to at least this behavior.',
447
+ writeDownstream: false,
448
+ targetOptional: true,
449
+ implemented: true,
450
+ defaultRequiresApproval: false,
451
+ validateConfig(where, hook) {
452
+ if (hook.config === undefined) return;
453
+ if (typeof hook.config !== 'object' || Array.isArray(hook.config) || hook.config === null) {
454
+ failHook(`${where}.config must be a plain object when provided`);
455
+ }
456
+ if (hook.config.target !== undefined) {
457
+ failHook(
458
+ `${where}: notify-handoff is the zero-write floor and never declares a downstream target`,
459
+ {},
460
+ );
461
+ }
462
+ },
463
+ },
464
+ {
465
+ name: 'marketplace-registry-entry',
466
+ description:
467
+ 'Direct-edit marketplace registry entry update for downstreams WITHOUT their own governance/render pipeline: locate the entry by config.entryKey inside config.registryPath, update config.fieldsFromPlan from the frozen plan values, run the declared downstream gates (argument arrays), then push (never --force). NO_CHANGE idempotent; a missing registry file or entry fails closed for human decision. Usually declared phase: postVerify. Hubs with their own governance use proposal-inbox instead.',
468
+ writeDownstream: true,
469
+ targetOptional: false,
470
+ implemented: true,
471
+ defaultRequiresApproval: true,
472
+ validateConfig(where, hook) {
473
+ const config = requirePlainConfig(where, hook);
474
+ validateDownstreamTarget(where, config.target, { targetOptional: false });
475
+ if (config.registryPath !== undefined) {
476
+ assertSafeStaticFilePath(`${where}.config`, 'registryPath', config.registryPath, failHook);
477
+ }
478
+ if (typeof config.entryKey !== 'string' || config.entryKey.length === 0) {
479
+ failHook(`${where}.config.entryKey must be a non-empty string`);
480
+ }
481
+ assertNoControlChars(`${where}.config.entryKey`, config.entryKey);
482
+ validateFieldsFromPlan(where, config);
483
+ validateGates(where, config);
484
+ },
485
+ },
486
+ {
487
+ name: 'docs-refresh',
488
+ description:
489
+ 'Refresh one or more independent docs repositories (GitHub Pages style) from the frozen release payload: config.mappings copy payload files into each repository (an optional per-mapping versionMarker placeholder is replaced with the release version), the declared docs build gates run before each push, and every repository in config.repositories is committed and pushed (never --force). NO_CHANGE idempotent; bound to the distribute phase by its payload requirement.',
490
+ writeDownstream: true,
491
+ targetOptional: false,
492
+ implemented: true,
493
+ defaultRequiresApproval: true,
494
+ validateConfig(where, hook) {
495
+ const config = requirePlainConfig(where, hook);
496
+ // One declaration, many repositories (design §2.5): every entry shares
497
+ // the dual-addressing rules with the single-target presets.
498
+ if (!Array.isArray(config.repositories) || config.repositories.length === 0) {
499
+ failHook(`${where}.config.repositories must be a non-empty array of downstream targets`);
500
+ }
501
+ for (const [index, repository] of config.repositories.entries()) {
502
+ validateDownstreamTarget(`${where}.config.repositories[${index}]`, repository, { targetOptional: false });
503
+ }
504
+ if (!Array.isArray(config.mappings) || config.mappings.length === 0) {
505
+ failHook(`${where}.config.mappings must be a non-empty array`);
506
+ }
507
+ for (const [index, mapping] of config.mappings.entries()) {
508
+ const mwhere = `${where}.config.mappings[${index}]`;
509
+ if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) {
510
+ failHook(`${mwhere} must be a plain object`);
511
+ }
512
+ assertSafeStaticFilePath(mwhere, 'from', mapping?.from, failHook);
513
+ assertSafeStaticFilePath(mwhere, 'to', mapping?.to, failHook);
514
+ if (mapping.versionMarker !== undefined) {
515
+ if (typeof mapping.versionMarker !== 'string' || mapping.versionMarker.length === 0) {
516
+ failHook(`${mwhere}.versionMarker must be a non-empty string when provided`);
517
+ }
518
+ assertNoControlChars(`${mwhere}.versionMarker`, mapping.versionMarker);
519
+ }
520
+ }
521
+ validateGates(where, config);
522
+ },
523
+ },
524
+ ];
525
+
526
+ /**
527
+ * List every registered preset with its enumerable metadata
528
+ * (`release-skill distribute --list-presets`).
529
+ *
530
+ * @returns {Array<{ name: string, description: string, writeDownstream: boolean,
531
+ * targetOptional: boolean, implemented: boolean, defaultRequiresApproval: boolean,
532
+ * requiresApprovalNote?: string, targetsFormOnly?: boolean }>}
533
+ */
534
+ export function listPostPublishPresets() {
535
+ return REGISTRY.map((entry) => ({
536
+ name: entry.name,
537
+ description: entry.description,
538
+ writeDownstream: entry.writeDownstream,
539
+ targetOptional: entry.targetOptional,
540
+ implemented: entry.implemented,
541
+ defaultRequiresApproval: entry.defaultRequiresApproval,
542
+ ...(entry.requiresApprovalNote !== undefined
543
+ ? { requiresApprovalNote: entry.requiresApprovalNote }
544
+ : {}),
545
+ ...(entry.targetsFormOnly === true ? { targetsFormOnly: true } : {}),
546
+ }));
547
+ }
548
+
549
+ /** Registry preset names (the knownPresets surface for declaration validation). */
550
+ export function postPublishPresetNames() {
551
+ return REGISTRY.map((entry) => entry.name);
552
+ }
553
+
554
+ /** Look up one registry entry by name (undefined when unregistered). */
555
+ export function getPostPublishPreset(name) {
556
+ return REGISTRY.find((entry) => entry.name === name);
557
+ }
558
+
559
+ /**
560
+ * Effective proposal-inbox transport (single authority shared by approval
561
+ * grading and executor routing — the two can never drift apart, R4 review
562
+ * M-1): `config.delivery` pins it; otherwise the addressing infers it
563
+ * (target.remoteUrl -> git-push, target.workspace -> local-file). Returns
564
+ * undefined for the target-less notify-handoff degradation.
565
+ *
566
+ * @param {object} [config] - The proposal-inbox hook config.
567
+ * @returns {'git-push'|'local-file'|undefined}
568
+ */
569
+ export function resolveProposalInboxTransport(config = {}) {
570
+ const target = config?.target;
571
+ const hasTarget = target && typeof target === 'object' && !Array.isArray(target);
572
+ // Target-less degradation wins over a literal delivery: the executor
573
+ // degrades to notify-handoff BEFORE any transport resolution, so a
574
+ // delivery value without a target is inert (zero-write floor, R4 m-1).
575
+ if (!hasTarget) return undefined;
576
+ if (config?.delivery !== undefined) return config.delivery;
577
+ return typeof target.remoteUrl === 'string' ? 'git-push' : 'local-file';
578
+ }
579
+
580
+ /**
581
+ * Resolve the preset-declared requiresApproval default (§2.6 grading).
582
+ * proposal-inbox is transport-graded by the EFFECTIVE transport (declared
583
+ * delivery ?? addressing inference, R4 review M-1): a remoteUrl-only target
584
+ * infers git-push, a public write -> true; a workspace target infers
585
+ * local-file -> false. The target-less notify-handoff degradation is the
586
+ * zero-write floor and always grades false, even when a literal delivery
587
+ * says git-push (R4 review m-1). Unknown presets fail safe as public write
588
+ * (true).
589
+ *
590
+ * @param {string} presetName
591
+ * @param {object} [config] - The hook config (transport grading input).
592
+ * @returns {boolean}
593
+ */
594
+ export function resolvePresetRequiresApproval(presetName, config = {}) {
595
+ const entry = getPostPublishPreset(presetName);
596
+ if (!entry) return true; // Fail safe: unregistered behaves like public write.
597
+ if (presetName === 'proposal-inbox') {
598
+ return resolveProposalInboxTransport(config) === 'git-push';
599
+ }
600
+ return entry.defaultRequiresApproval;
601
+ }
602
+
603
+ /**
604
+ * Validate one preset hook's config through the registry (fail-closed with
605
+ * POSTPUBLISH_HOOK_INVALID). Called by validatePostPublishDeclaration after
606
+ * the preset existence check; secret scanning covers the whole config tree.
607
+ *
608
+ * Only hooks[] entries reach this seam (targets[] entries validate through
609
+ * validateTarget), so targets-form-only presets (R4 review M-2) are rejected
610
+ * here: they execute through the legacy targets[] pipeline, and accepting a
611
+ * hooks[] declaration would validate a hook the executor can never route.
612
+ *
613
+ * @param {object} hook - The declared hook entry (id/preset/config).
614
+ * @param {string} where - Error-context label, e.g. `hooks[0]`.
615
+ */
616
+ export function validatePresetHook(hook, where) {
617
+ const entry = getPostPublishPreset(hook.preset);
618
+ if (!entry) {
619
+ failHook(`${where}: unknown preset "${hook.preset}"`, { preset: hook.preset });
620
+ }
621
+ if (entry.targetsFormOnly === true) {
622
+ failHook(
623
+ `${where}: preset "${hook.preset}" is targets-form only — declare it under postPublish.targets as kind "${entry.legacyTargetKind}", not under postPublish.hooks (the hooks[] form of this preset is not executable in this release)`,
624
+ { preset: hook.preset, legacyTargetKind: entry.legacyTargetKind },
625
+ );
626
+ }
627
+ if (hook.config !== undefined) {
628
+ scanConfigForSecrets(hook.config, where);
629
+ }
630
+ entry.validateConfig(where, hook);
631
+ }
632
+
633
+ // ---------------------------------------------------------------------------
634
+ // Workspace preflight + execution checks (§2.6, three execution rules)
635
+ // ---------------------------------------------------------------------------
636
+
637
+ function failWorkspace(message, details = {}) {
638
+ throw new ReleaseError(GATE_FAILED, `preset workspace invalid: ${message}`, details);
639
+ }
640
+
641
+ /**
642
+ * Preflight a `config.target.workspace` before any write: the path must
643
+ * exist and be a git worktree (a `.git` entry), otherwise fail closed.
644
+ * Returns the resolved realpath for evidence and the TOCTOU re-check.
645
+ *
646
+ * @param {string} workspace - Declared workspace path (relative to `root`
647
+ * or absolute; may leave the repository root by preset-level exception).
648
+ * @param {object} options - { root: string }
649
+ * @returns {Promise<{ realpath: string }>}
650
+ */
651
+ export async function preflightPresetWorkspace(workspace, { root }) {
652
+ const resolved = isAbsolute(workspace) ? workspace : resolve(root, workspace);
653
+ let real;
654
+ try {
655
+ real = await realpath(resolved);
656
+ } catch {
657
+ failWorkspace(`path does not resolve to an existing directory: ${workspace}`, { workspace });
658
+ }
659
+ const stats = await stat(real).catch(() => null);
660
+ if (!stats || !stats.isDirectory()) {
661
+ failWorkspace(`path is not a directory: ${workspace}`, { workspace });
662
+ }
663
+ const gitEntry = await stat(resolve(real, '.git')).catch(() => null);
664
+ if (!gitEntry) {
665
+ failWorkspace(`path is not a git worktree (no .git entry): ${workspace}`, { workspace });
666
+ }
667
+ return { realpath: real };
668
+ }
669
+
670
+ /**
671
+ * Execution-time workspace re-checks (§2.6, review N-2), run immediately
672
+ * before the preset writes:
673
+ * 1. the realpath is resolved again and returned for evidence recording;
674
+ * 2. it MUST equal the preflight realpath (TOCTOU: the path may not have
675
+ * been swapped for a symlink since preflight);
676
+ * 3. it must be neither the release workspace itself nor inside the
677
+ * `.release-skill/` runtime directory.
678
+ *
679
+ * @param {string} workspace - Declared workspace path.
680
+ * @param {object} options - { root, preflightRealpath }
681
+ * @returns {Promise<{ realpath: string }>}
682
+ */
683
+ export async function assertPresetWorkspaceExecution(workspace, { root, preflightRealpath }) {
684
+ const resolved = isAbsolute(workspace) ? workspace : resolve(root, workspace);
685
+ let real;
686
+ try {
687
+ real = await realpath(resolved);
688
+ } catch {
689
+ failWorkspace(`path no longer resolves at execution time: ${workspace}`, { workspace });
690
+ }
691
+ if (preflightRealpath !== undefined && real !== preflightRealpath) {
692
+ failWorkspace(
693
+ `workspace realpath changed between preflight and execution (TOCTOU guard); refusing to write`,
694
+ { workspace, preflightRealpath, observedRealpath: real },
695
+ );
696
+ }
697
+ const rootReal = await realpath(root).catch(() => null);
698
+ if (rootReal && real === rootReal) {
699
+ failWorkspace('workspace must not be the release workspace itself', { workspace });
700
+ }
701
+ const segments = real.split(/[\\/]+/);
702
+ if (segments.includes('.release-skill')) {
703
+ failWorkspace('workspace must not live inside the .release-skill/ runtime directory', { workspace });
704
+ }
705
+ return { realpath: real };
706
+ }