release-skill 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) 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 +48 -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 +137 -12
  11. package/README.zh-CN.md +110 -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/error-codes.json +1 -1
  15. package/adapters/claude/bin/kernel-protocol.json +1 -1
  16. package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
  17. package/adapters/claude/bin/license-texts/MIT.txt +21 -0
  18. package/adapters/claude/bin/registry.json +64 -1
  19. package/adapters/claude/bin/release-skill.bundle.mjs +50570 -42208
  20. package/adapters/claude/bin/rules.json +1 -1
  21. package/adapters/claude/schemas/.render-manifest.json +10 -6
  22. package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
  23. package/adapters/claude/schemas/release-plan.schema.json +200 -4
  24. package/adapters/claude/schemas/release-project.schema.json +86 -5
  25. package/adapters/claude/schemas/release-run.schema.json +11 -6
  26. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  27. package/adapters/codex/bin/error-codes.json +1 -1
  28. package/adapters/codex/bin/kernel-protocol.json +1 -1
  29. package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
  30. package/adapters/codex/bin/license-texts/MIT.txt +21 -0
  31. package/adapters/codex/bin/registry.json +64 -1
  32. package/adapters/codex/bin/release-skill.bundle.mjs +50570 -42208
  33. package/adapters/codex/bin/rules.json +1 -1
  34. package/adapters/codex/schemas/.render-manifest.json +10 -6
  35. package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
  36. package/adapters/codex/schemas/release-plan.schema.json +200 -4
  37. package/adapters/codex/schemas/release-project.schema.json +86 -5
  38. package/adapters/codex/schemas/release-run.schema.json +11 -6
  39. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  40. package/adapters/kimi/bin/error-codes.json +1 -1
  41. package/adapters/kimi/bin/kernel-protocol.json +1 -1
  42. package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
  43. package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
  44. package/adapters/kimi/bin/registry.json +64 -1
  45. package/adapters/kimi/bin/release-skill.bundle.mjs +50570 -42208
  46. package/adapters/kimi/bin/rules.json +1 -1
  47. package/adapters/kimi/schemas/.render-manifest.json +10 -6
  48. package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
  49. package/adapters/kimi/schemas/release-plan.schema.json +200 -4
  50. package/adapters/kimi/schemas/release-project.schema.json +86 -5
  51. package/adapters/kimi/schemas/release-run.schema.json +11 -6
  52. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  53. package/adapters/workbuddy/bin/error-codes.json +1 -1
  54. package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
  55. package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
  56. package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
  57. package/adapters/workbuddy/bin/registry.json +64 -1
  58. package/adapters/workbuddy/bin/release-skill.bundle.mjs +50570 -42208
  59. package/adapters/workbuddy/bin/rules.json +1 -1
  60. package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
  61. package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
  62. package/adapters/workbuddy/schemas/release-plan.schema.json +200 -4
  63. package/adapters/workbuddy/schemas/release-project.schema.json +86 -5
  64. package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
  65. package/bin/error-codes.json +1 -1
  66. package/bin/kernel-protocol.json +1 -1
  67. package/bin/license-texts/Apache-2.0.txt +201 -0
  68. package/bin/license-texts/MIT.txt +21 -0
  69. package/bin/registry.json +64 -1
  70. package/bin/release-skill-cli.mjs +187 -9
  71. package/bin/release-skill.bundle.mjs +50570 -42208
  72. package/bin/rules.json +1 -1
  73. package/package.json +6 -4
  74. package/platform-manifest.json +4 -4
  75. package/references/.render-manifest.json +5 -5
  76. package/references/01-state-machine.md +22 -2
  77. package/references/02-project-config.md +1 -1
  78. package/references/06-adapter-contract.md +6 -3
  79. package/schemas/.render-manifest.json +10 -6
  80. package/schemas/postpublish-approval-record.schema.json +47 -0
  81. package/schemas/release-plan.schema.json +200 -4
  82. package/schemas/release-project.schema.json +86 -5
  83. package/schemas/release-run.schema.json +11 -6
  84. package/src/adapters/distribute-git.mjs +17 -11
  85. package/src/commands/approve.mjs +163 -1
  86. package/src/commands/distribute.mjs +649 -89
  87. package/src/commands/postverify.mjs +808 -0
  88. package/src/commands/prepare.mjs +456 -45
  89. package/src/commands/setup.mjs +715 -0
  90. package/src/commands/ship.mjs +152 -5
  91. package/src/commands/verify.mjs +92 -15
  92. package/src/core/approval.mjs +93 -68
  93. package/src/core/bounded-output.mjs +46 -0
  94. package/src/core/derived-artifact-gates.mjs +258 -0
  95. package/src/core/docs-refresh-preset.mjs +171 -0
  96. package/src/core/errors.mjs +4 -0
  97. package/src/core/evidence.mjs +8 -1
  98. package/src/core/git-url-policy.mjs +214 -0
  99. package/src/core/hooks.mjs +28 -0
  100. package/src/core/marketplace-registry-entry.mjs +175 -0
  101. package/src/core/notify-handoff.mjs +76 -0
  102. package/src/core/postpublish-approval.mjs +241 -0
  103. package/src/core/postpublish-bundle.mjs +342 -0
  104. package/src/core/postpublish-projection.mjs +210 -0
  105. package/src/core/postpublish.mjs +449 -25
  106. package/src/core/preset-executor.mjs +175 -0
  107. package/src/core/preset-gitwrite.mjs +484 -0
  108. package/src/core/presets.mjs +754 -0
  109. package/src/core/proposal-inbox.mjs +646 -0
  110. package/src/core/redact.mjs +16 -3
  111. package/src/core/run.mjs +91 -6
  112. package/src/producers/build-adapters.mjs +28 -14
@@ -0,0 +1,754 @@
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 { classifyPathInput } from 'skill-family-harness-node';
61
+
62
+ import { ReleaseError, GATE_FAILED, POSTPUBLISH_HOOK_INVALID } from './errors.mjs';
63
+ import { checkGitRemoteUrl, describeGitRemoteUrlFailure } from './git-url-policy.mjs';
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
+ const remoteUrlVerdict = checkGitRemoteUrl(target.remoteUrl);
164
+ if (!remoteUrlVerdict.ok) {
165
+ failHook(
166
+ `${where}.config.target.remoteUrl ${describeGitRemoteUrlFailure(remoteUrlVerdict.reason)}`,
167
+ { reason: remoteUrlVerdict.reason },
168
+ );
169
+ }
170
+ } else {
171
+ if (typeof target.workspace !== 'string' || target.workspace.length === 0) {
172
+ failHook(`${where}.config.target.workspace must be a non-empty string`);
173
+ }
174
+ assertNoControlChars(`${where}.config.target.workspace`, target.workspace);
175
+ // §2.6: the runtime directory is excluded already at declaration time;
176
+ // relative or absolute paths outside the repository root are allowed
177
+ // (preset-level exception), and the raw value stays plan-digest-bound.
178
+ const segments = target.workspace.split(/[\\/]+/);
179
+ if (segments.includes('.release-skill')) {
180
+ failHook(
181
+ `${where}.config.target.workspace must not point into the .release-skill/ runtime directory`,
182
+ {},
183
+ );
184
+ }
185
+ }
186
+ if (typeof target.branch !== 'string' || !BRANCH_RE.test(target.branch)) {
187
+ failHook(`${where}.config.target.branch is not a safe Git branch name`, { branch: target.branch });
188
+ }
189
+ if (target.branch.includes('..') || target.branch.endsWith('.') || target.branch.endsWith('.lock')) {
190
+ failHook(`${where}.config.target.branch is not a safe Git branch name`, { branch: target.branch });
191
+ }
192
+ }
193
+
194
+ /** Optional config.visibility: keeps the public-write/internal-write split. */
195
+ function validateVisibility(where, config) {
196
+ if (config.visibility !== undefined
197
+ && config.visibility !== 'internal' && config.visibility !== 'public') {
198
+ failHook(`${where}.config.visibility must be "internal" or "public"`, {
199
+ visibility: config.visibility,
200
+ });
201
+ }
202
+ }
203
+
204
+ /** Optional config.staticFiles: identical shape to the legacy target field. */
205
+ function validateStaticFiles(where, config) {
206
+ if (config.staticFiles === undefined) return;
207
+ if (!Array.isArray(config.staticFiles)) {
208
+ failHook(`${where}.config.staticFiles must be an array`);
209
+ }
210
+ for (const [index, file] of config.staticFiles.entries()) {
211
+ if (!file || typeof file !== 'object' || Array.isArray(file)) {
212
+ failHook(`${where}.config.staticFiles[${index}] must be a plain object`);
213
+ }
214
+ assertSafeStaticFilePath(`${where}.config.staticFiles[${index}]`, 'from', file?.from, failHook);
215
+ assertSafeStaticFilePath(`${where}.config.staticFiles[${index}]`, 'to', file?.to, failHook);
216
+ }
217
+ }
218
+
219
+ function validateMarketplaceBlock(where, config) {
220
+ const marketplace = config.marketplace;
221
+ if (!marketplace || typeof marketplace !== 'object' || Array.isArray(marketplace)) {
222
+ failHook(`${where}: marketplace-index-render requires a config.marketplace block`);
223
+ }
224
+ const { form, name, owner } = marketplace;
225
+ if (form !== 'github' && form !== 'url') {
226
+ failHook(`${where}.config.marketplace.form must be "github" or "url"`, { form });
227
+ }
228
+ if (typeof name !== 'string' || name.length === 0) {
229
+ failHook(`${where}.config.marketplace.name must be a non-empty string`);
230
+ }
231
+ if (typeof owner !== 'string' || owner.length === 0) {
232
+ failHook(`${where}.config.marketplace.owner must be a non-empty string`);
233
+ }
234
+ if (marketplace.sourceRepo !== undefined
235
+ && (typeof marketplace.sourceRepo !== 'string'
236
+ || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(marketplace.sourceRepo))) {
237
+ failHook(`${where}.config.marketplace.sourceRepo must be owner/repo when provided`);
238
+ }
239
+ }
240
+
241
+ function requirePlainConfig(where, hook) {
242
+ if (hook.config === undefined || typeof hook.config !== 'object'
243
+ || Array.isArray(hook.config) || hook.config === null) {
244
+ failHook(`${where}: this preset requires a config object`, {});
245
+ }
246
+ return hook.config;
247
+ }
248
+
249
+ /**
250
+ * §2.3 context fields accepted as fieldsFromPlan sources: only frozen plan
251
+ * values may flow into a downstream registry entry.
252
+ */
253
+ export const FIELDS_FROM_PLAN_SOURCES = new Set([
254
+ 'unitId',
255
+ 'version',
256
+ 'tag',
257
+ 'commit',
258
+ 'tree',
259
+ 'manifestDigest',
260
+ 'planDigest',
261
+ 'publishedAt',
262
+ ]);
263
+
264
+ /** Safe registry-entry field name (also blocks prototype-pollution keys). */
265
+ const SAFE_FIELD_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
266
+ const FORBIDDEN_FIELDS = new Set(['__proto__', 'prototype', 'constructor']);
267
+
268
+ /** Shape of config.fieldsFromPlan (marketplace-registry-entry). */
269
+ function validateFieldsFromPlan(where, config) {
270
+ const fieldsFromPlan = config.fieldsFromPlan;
271
+ if (!fieldsFromPlan || typeof fieldsFromPlan !== 'object' || Array.isArray(fieldsFromPlan)) {
272
+ failHook(`${where}.config.fieldsFromPlan must be a plain object mapping entry fields to frozen plan fields`);
273
+ }
274
+ const entries = Object.entries(fieldsFromPlan);
275
+ if (entries.length === 0) {
276
+ failHook(`${where}.config.fieldsFromPlan must map at least one entry field`);
277
+ }
278
+ for (const [entryField, sourceField] of entries) {
279
+ if (!SAFE_FIELD_RE.test(entryField) || FORBIDDEN_FIELDS.has(entryField)) {
280
+ failHook(`${where}.config.fieldsFromPlan field "${entryField}" is not a safe entry field name`, { entryField });
281
+ }
282
+ if (typeof sourceField !== 'string' || !FIELDS_FROM_PLAN_SOURCES.has(sourceField)) {
283
+ failHook(
284
+ `${where}.config.fieldsFromPlan."${entryField}" must map to a frozen plan context field (${[...FIELDS_FROM_PLAN_SOURCES].join('/')})`,
285
+ { entryField, sourceField },
286
+ );
287
+ }
288
+ }
289
+ }
290
+
291
+ /** Secret-ish env denylist for downstream gates (mirrors postpublish.mjs). */
292
+ const GATE_ENV_DENYLIST = /TOKEN|SECRET|PASSWORD|PASSPHRASE|API_KEY|CREDENTIAL/i;
293
+ const GATE_ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
294
+
295
+ /**
296
+ * Downstream gates (marketplace-registry-entry / docs-refresh): the same
297
+ * fail-closed safety rules as command hooks — executable + argument arrays
298
+ * (never shell strings), relative cwd, timeout bounds, envAllowlist with the
299
+ * secret-ish denylist. Gates run inside the downstream worktree via the R1
300
+ * hook runner.
301
+ */
302
+ function validateGates(where, config) {
303
+ if (config.gates === undefined) return;
304
+ if (!Array.isArray(config.gates)) {
305
+ failHook(`${where}.config.gates must be an array`);
306
+ }
307
+ for (const [index, gate] of config.gates.entries()) {
308
+ const gwhere = `${where}.config.gates[${index}]`;
309
+ if (!gate || typeof gate !== 'object' || Array.isArray(gate)) {
310
+ failHook(`${gwhere} must be a plain object`);
311
+ }
312
+ if (!Array.isArray(gate.command) || gate.command.length === 0) {
313
+ failHook(`${gwhere}.command must be a non-empty array (shell strings are never accepted)`);
314
+ }
315
+ for (const element of gate.command) {
316
+ if (typeof element !== 'string' || element.length === 0) {
317
+ failHook(`${gwhere}.command must contain only non-empty strings`);
318
+ }
319
+ assertNoControlChars(`${gwhere}.command`, element);
320
+ if (element.startsWith('-') && element === gate.command[0]) {
321
+ failHook(`${gwhere}.command executable must not start with "-"`, { executable: element });
322
+ }
323
+ }
324
+ if (gate.cwd !== undefined) {
325
+ if (typeof gate.cwd !== 'string' || gate.cwd.length === 0) {
326
+ failHook(`${gwhere}.cwd must be a non-empty string when provided`);
327
+ }
328
+ if (gate.cwd.startsWith('/') || gate.cwd.startsWith('./') || gate.cwd.includes('..')) {
329
+ failHook(`${gwhere}.cwd must be a relative path inside the downstream repository`, { cwd: gate.cwd });
330
+ }
331
+ }
332
+ if (gate.timeoutMs !== undefined) {
333
+ if (!Number.isInteger(gate.timeoutMs) || gate.timeoutMs < 1000 || gate.timeoutMs > 7200000) {
334
+ failHook(`${gwhere}.timeoutMs must be an integer in [1000, 7200000]`, { timeoutMs: gate.timeoutMs });
335
+ }
336
+ }
337
+ if (gate.envAllowlist !== undefined) {
338
+ if (!Array.isArray(gate.envAllowlist)) {
339
+ failHook(`${gwhere}.envAllowlist must be an array`);
340
+ }
341
+ const seen = new Set();
342
+ for (const key of gate.envAllowlist) {
343
+ if (typeof key !== 'string' || !GATE_ENV_KEY_RE.test(key)) {
344
+ failHook(`${gwhere}.envAllowlist key ${JSON.stringify(key)} must be an uppercase [A-Z_][A-Z0-9_]* identifier`);
345
+ }
346
+ if (GATE_ENV_DENYLIST.test(key)) {
347
+ failHook(
348
+ `${gwhere}.envAllowlist key "${key}" matches the secret-ish denylist (TOKEN/SECRET/PASSWORD/PASSPHRASE/API_KEY/CREDENTIAL); gates never receive credentials`,
349
+ { key },
350
+ );
351
+ }
352
+ if (seen.has(key)) {
353
+ failHook(`${gwhere}.envAllowlist contains duplicate key "${key}"`);
354
+ }
355
+ seen.add(key);
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ const REGISTRY = [
362
+ {
363
+ name: 'git-mirror',
364
+ description:
365
+ '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.',
366
+ writeDownstream: true,
367
+ targetOptional: false,
368
+ implemented: true,
369
+ defaultRequiresApproval: true,
370
+ // R4 review M-2: the executor routes this preset through the targets[]
371
+ // pipeline only; a hooks[] declaration would validate but never execute,
372
+ // so declaration validation rejects it fail-closed (targets-form only).
373
+ targetsFormOnly: true,
374
+ legacyTargetKind: 'payload-mirror',
375
+ validateConfig(where, hook) {
376
+ const config = requirePlainConfig(where, hook);
377
+ validateDownstreamTarget(where, config.target, { targetOptional: false });
378
+ validateVisibility(where, config);
379
+ validateStaticFiles(where, config);
380
+ },
381
+ },
382
+ {
383
+ name: 'marketplace-index-render',
384
+ description:
385
+ '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.',
386
+ writeDownstream: true,
387
+ targetOptional: false,
388
+ implemented: true,
389
+ defaultRequiresApproval: true,
390
+ // R4 review M-2: targets-form only (see git-mirror above).
391
+ targetsFormOnly: true,
392
+ legacyTargetKind: 'marketplace-index',
393
+ validateConfig(where, hook) {
394
+ const config = requirePlainConfig(where, hook);
395
+ validateDownstreamTarget(where, config.target, { targetOptional: false });
396
+ validateMarketplaceBlock(where, config);
397
+ validateVisibility(where, config);
398
+ validateStaticFiles(where, config);
399
+ },
400
+ },
401
+ {
402
+ name: 'proposal-inbox',
403
+ description:
404
+ '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.',
405
+ writeDownstream: true,
406
+ targetOptional: true,
407
+ // git-push transport delivered in R3, local-file transport in R4; the
408
+ // description names both shipped behaviors (review minor-2 honesty).
409
+ implemented: true,
410
+ // Graded by transport: git-push is public write (true); local-file and
411
+ // the notify-handoff degradation stay false (§2.6).
412
+ defaultRequiresApproval: false,
413
+ // Followup 7: the registry default is false, but runtime grading is
414
+ // STRICTER per transport — --list-presets must say so plainly. R4 review
415
+ // M-1: grading follows the EFFECTIVE transport (delivery ?? addressing
416
+ // inference), so a remoteUrl-only declaration still grades true.
417
+ requiresApprovalNote:
418
+ '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',
419
+ validateConfig(where, hook) {
420
+ if (hook.config === undefined) return; // target-less degradation is legal.
421
+ if (typeof hook.config !== 'object' || Array.isArray(hook.config) || hook.config === null) {
422
+ failHook(`${where}.config must be a plain object when provided`);
423
+ }
424
+ const config = hook.config;
425
+ if (config.delivery !== undefined
426
+ && config.delivery !== 'local-file' && config.delivery !== 'git-push') {
427
+ failHook(`${where}.config.delivery must be "local-file" or "git-push"`, {
428
+ delivery: config.delivery,
429
+ });
430
+ }
431
+ validateDownstreamTarget(where, config.target, { targetOptional: true });
432
+ // Transport/addressing coherence (R4): git-push needs a remote to push;
433
+ // local-file needs a local checkout to write.
434
+ if (config.target !== undefined && config.delivery !== undefined) {
435
+ if (config.delivery === 'git-push' && typeof config.target.remoteUrl !== 'string') {
436
+ failHook(`${where}: delivery "git-push" requires a config.target.remoteUrl address`, {});
437
+ }
438
+ if (config.delivery === 'local-file' && typeof config.target.workspace !== 'string') {
439
+ failHook(`${where}: delivery "local-file" requires a config.target.workspace address`, {});
440
+ }
441
+ }
442
+ },
443
+ },
444
+ {
445
+ name: 'notify-handoff',
446
+ description:
447
+ '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.',
448
+ writeDownstream: false,
449
+ targetOptional: true,
450
+ implemented: true,
451
+ defaultRequiresApproval: false,
452
+ validateConfig(where, hook) {
453
+ if (hook.config === undefined) return;
454
+ if (typeof hook.config !== 'object' || Array.isArray(hook.config) || hook.config === null) {
455
+ failHook(`${where}.config must be a plain object when provided`);
456
+ }
457
+ if (hook.config.target !== undefined) {
458
+ failHook(
459
+ `${where}: notify-handoff is the zero-write floor and never declares a downstream target`,
460
+ {},
461
+ );
462
+ }
463
+ },
464
+ },
465
+ {
466
+ name: 'marketplace-registry-entry',
467
+ description:
468
+ '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.',
469
+ writeDownstream: true,
470
+ targetOptional: false,
471
+ implemented: true,
472
+ defaultRequiresApproval: true,
473
+ validateConfig(where, hook) {
474
+ const config = requirePlainConfig(where, hook);
475
+ validateDownstreamTarget(where, config.target, { targetOptional: false });
476
+ if (config.registryPath !== undefined) {
477
+ assertSafeStaticFilePath(`${where}.config`, 'registryPath', config.registryPath, failHook);
478
+ }
479
+ if (typeof config.entryKey !== 'string' || config.entryKey.length === 0) {
480
+ failHook(`${where}.config.entryKey must be a non-empty string`);
481
+ }
482
+ assertNoControlChars(`${where}.config.entryKey`, config.entryKey);
483
+ validateFieldsFromPlan(where, config);
484
+ validateGates(where, config);
485
+ },
486
+ },
487
+ {
488
+ name: 'docs-refresh',
489
+ description:
490
+ '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.',
491
+ writeDownstream: true,
492
+ targetOptional: false,
493
+ implemented: true,
494
+ defaultRequiresApproval: true,
495
+ validateConfig(where, hook) {
496
+ const config = requirePlainConfig(where, hook);
497
+ // One declaration, many repositories (design §2.5): every entry shares
498
+ // the dual-addressing rules with the single-target presets.
499
+ if (!Array.isArray(config.repositories) || config.repositories.length === 0) {
500
+ failHook(`${where}.config.repositories must be a non-empty array of downstream targets`);
501
+ }
502
+ for (const [index, repository] of config.repositories.entries()) {
503
+ validateDownstreamTarget(`${where}.config.repositories[${index}]`, repository, { targetOptional: false });
504
+ }
505
+ if (!Array.isArray(config.mappings) || config.mappings.length === 0) {
506
+ failHook(`${where}.config.mappings must be a non-empty array`);
507
+ }
508
+ for (const [index, mapping] of config.mappings.entries()) {
509
+ const mwhere = `${where}.config.mappings[${index}]`;
510
+ if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) {
511
+ failHook(`${mwhere} must be a plain object`);
512
+ }
513
+ assertSafeStaticFilePath(mwhere, 'from', mapping?.from, failHook);
514
+ assertSafeStaticFilePath(mwhere, 'to', mapping?.to, failHook);
515
+ if (mapping.versionMarker !== undefined) {
516
+ if (typeof mapping.versionMarker !== 'string' || mapping.versionMarker.length === 0) {
517
+ failHook(`${mwhere}.versionMarker must be a non-empty string when provided`);
518
+ }
519
+ assertNoControlChars(`${mwhere}.versionMarker`, mapping.versionMarker);
520
+ }
521
+ }
522
+ validateGates(where, config);
523
+ },
524
+ },
525
+ ];
526
+
527
+ /**
528
+ * List every registered preset with its enumerable metadata
529
+ * (`release-skill distribute --list-presets`).
530
+ *
531
+ * @returns {Array<{ name: string, description: string, writeDownstream: boolean,
532
+ * targetOptional: boolean, implemented: boolean, defaultRequiresApproval: boolean,
533
+ * requiresApprovalNote?: string, targetsFormOnly?: boolean }>}
534
+ */
535
+ export function listPostPublishPresets() {
536
+ return REGISTRY.map((entry) => ({
537
+ name: entry.name,
538
+ description: entry.description,
539
+ writeDownstream: entry.writeDownstream,
540
+ targetOptional: entry.targetOptional,
541
+ implemented: entry.implemented,
542
+ defaultRequiresApproval: entry.defaultRequiresApproval,
543
+ ...(entry.requiresApprovalNote !== undefined
544
+ ? { requiresApprovalNote: entry.requiresApprovalNote }
545
+ : {}),
546
+ ...(entry.targetsFormOnly === true ? { targetsFormOnly: true } : {}),
547
+ }));
548
+ }
549
+
550
+ /** Registry preset names (the knownPresets surface for declaration validation). */
551
+ export function postPublishPresetNames() {
552
+ return REGISTRY.map((entry) => entry.name);
553
+ }
554
+
555
+ /** Look up one registry entry by name (undefined when unregistered). */
556
+ export function getPostPublishPreset(name) {
557
+ return REGISTRY.find((entry) => entry.name === name);
558
+ }
559
+
560
+ /**
561
+ * Effective proposal-inbox transport (single authority shared by approval
562
+ * grading and executor routing — the two can never drift apart, R4 review
563
+ * M-1): `config.delivery` pins it; otherwise the addressing infers it
564
+ * (target.remoteUrl -> git-push, target.workspace -> local-file). Returns
565
+ * undefined for the target-less notify-handoff degradation.
566
+ *
567
+ * @param {object} [config] - The proposal-inbox hook config.
568
+ * @returns {'git-push'|'local-file'|undefined}
569
+ */
570
+ export function resolveProposalInboxTransport(config = {}) {
571
+ const target = config?.target;
572
+ const hasTarget = target && typeof target === 'object' && !Array.isArray(target);
573
+ // Target-less degradation wins over a literal delivery: the executor
574
+ // degrades to notify-handoff BEFORE any transport resolution, so a
575
+ // delivery value without a target is inert (zero-write floor, R4 m-1).
576
+ if (!hasTarget) return undefined;
577
+ if (config?.delivery !== undefined) return config.delivery;
578
+ return typeof target.remoteUrl === 'string' ? 'git-push' : 'local-file';
579
+ }
580
+
581
+ /**
582
+ * Resolve the preset-declared requiresApproval default (§2.6 grading).
583
+ * proposal-inbox is transport-graded by the EFFECTIVE transport (declared
584
+ * delivery ?? addressing inference, R4 review M-1): a remoteUrl-only target
585
+ * infers git-push, a public write -> true; a workspace target infers
586
+ * local-file -> false. The target-less notify-handoff degradation is the
587
+ * zero-write floor and always grades false, even when a literal delivery
588
+ * says git-push (R4 review m-1). Unknown presets fail safe as public write
589
+ * (true).
590
+ *
591
+ * @param {string} presetName
592
+ * @param {object} [config] - The hook config (transport grading input).
593
+ * @returns {boolean}
594
+ */
595
+ export function resolvePresetRequiresApproval(presetName, config = {}) {
596
+ const entry = getPostPublishPreset(presetName);
597
+ if (!entry) return true; // Fail safe: unregistered behaves like public write.
598
+ if (presetName === 'proposal-inbox') {
599
+ return resolveProposalInboxTransport(config) === 'git-push';
600
+ }
601
+ return entry.defaultRequiresApproval;
602
+ }
603
+
604
+ /**
605
+ * Validate one preset hook's config through the registry (fail-closed with
606
+ * POSTPUBLISH_HOOK_INVALID). Called by validatePostPublishDeclaration after
607
+ * the preset existence check; secret scanning covers the whole config tree.
608
+ *
609
+ * Only hooks[] entries reach this seam (targets[] entries validate through
610
+ * validateTarget), so targets-form-only presets (R4 review M-2) are rejected
611
+ * here: they execute through the legacy targets[] pipeline, and accepting a
612
+ * hooks[] declaration would validate a hook the executor can never route.
613
+ *
614
+ * @param {object} hook - The declared hook entry (id/preset/config).
615
+ * @param {string} where - Error-context label, e.g. `hooks[0]`.
616
+ */
617
+ export function validatePresetHook(hook, where) {
618
+ const entry = getPostPublishPreset(hook.preset);
619
+ if (!entry) {
620
+ failHook(`${where}: unknown preset "${hook.preset}"`, { preset: hook.preset });
621
+ }
622
+ if (entry.targetsFormOnly === true) {
623
+ failHook(
624
+ `${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)`,
625
+ { preset: hook.preset, legacyTargetKind: entry.legacyTargetKind },
626
+ );
627
+ }
628
+ if (hook.config !== undefined) {
629
+ scanConfigForSecrets(hook.config, where);
630
+ }
631
+ entry.validateConfig(where, hook);
632
+ }
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // Workspace preflight + execution checks (§2.6, three execution rules)
636
+ //
637
+ // F-04 root split: these checks receive `releaseWorkspaceRoot` — the real
638
+ // project root the user releases from. It is the ONLY resolution basis for
639
+ // preset `config.target.workspace` and the ONLY basis for the release-
640
+ // workspace write exclusion. The detached frozen-tag worktree is the
641
+ // execution worktree (materialize/steps/custom command hooks) and must never
642
+ // be passed here: the two roots never fall back onto each other.
643
+ // ---------------------------------------------------------------------------
644
+
645
+ function failWorkspace(message, details = {}) {
646
+ throw new ReleaseError(GATE_FAILED, `preset workspace invalid: ${message}`, details);
647
+ }
648
+
649
+ /**
650
+ * Resolve a declared workspace path against the release workspace root
651
+ * (F-04). Absolute workspaces pass through (preset-level exception); relative
652
+ * workspaces are lexically classified through Foundation `classifyPathInput`
653
+ * (ambiguous cross-platform shapes fail closed; no parallel path regex here)
654
+ * and resolved from `releaseWorkspaceRoot`.
655
+ *
656
+ * @param {string} workspace - Declared workspace path.
657
+ * @param {string} releaseWorkspaceRoot - The real release workspace root.
658
+ * @returns {string} The resolved (not yet realpathed) workspace path.
659
+ */
660
+ function resolvePresetWorkspace(workspace, releaseWorkspaceRoot) {
661
+ if (typeof releaseWorkspaceRoot !== 'string' || releaseWorkspaceRoot.length === 0) {
662
+ failWorkspace(
663
+ 'preset workspace checks require the release workspace root (releaseWorkspaceRoot); the detached execution worktree is never the release workspace',
664
+ {},
665
+ );
666
+ }
667
+ if (isAbsolute(workspace)) return workspace;
668
+ const classification = classifyPathInput(workspace);
669
+ if (!classification.ok) {
670
+ failWorkspace('workspace is not an unambiguous path input', {
671
+ workspace,
672
+ kind: classification.kind,
673
+ });
674
+ }
675
+ return resolve(releaseWorkspaceRoot, workspace);
676
+ }
677
+
678
+ /**
679
+ * Preflight a `config.target.workspace` before any write: the path must
680
+ * exist and be a git worktree (a `.git` entry), otherwise fail closed.
681
+ * Returns the resolved realpath for evidence and the TOCTOU re-check.
682
+ *
683
+ * @param {string} workspace - Declared workspace path (relative to
684
+ * `releaseWorkspaceRoot` or absolute; may leave the release workspace root
685
+ * by preset-level exception).
686
+ * @param {object} options - { releaseWorkspaceRoot: string }
687
+ * @returns {Promise<{ realpath: string }>}
688
+ */
689
+ export async function preflightPresetWorkspace(workspace, { releaseWorkspaceRoot }) {
690
+ const resolved = resolvePresetWorkspace(workspace, releaseWorkspaceRoot);
691
+ let real;
692
+ try {
693
+ real = await realpath(resolved);
694
+ } catch {
695
+ failWorkspace(`path does not resolve to an existing directory: ${workspace}`, { workspace });
696
+ }
697
+ const stats = await stat(real).catch(() => null);
698
+ if (!stats || !stats.isDirectory()) {
699
+ failWorkspace(`path is not a directory: ${workspace}`, { workspace });
700
+ }
701
+ const gitEntry = await stat(resolve(real, '.git')).catch(() => null);
702
+ if (!gitEntry) {
703
+ failWorkspace(`path is not a git worktree (no .git entry): ${workspace}`, { workspace });
704
+ }
705
+ return { realpath: real };
706
+ }
707
+
708
+ /**
709
+ * Execution-time workspace re-checks (§2.6, review N-2), run immediately
710
+ * before the preset writes:
711
+ * 1. the realpath is resolved again and returned for evidence recording;
712
+ * 2. it MUST equal the preflight realpath (TOCTOU: the path may not have
713
+ * been swapped for a symlink since preflight);
714
+ * 3. it must be neither the release workspace itself nor inside the
715
+ * `.release-skill/` runtime directory. The release-workspace comparison
716
+ * uses `releaseWorkspaceRoot` (F-04): comparing against the detached
717
+ * execution worktree would let a preset write into the real project.
718
+ *
719
+ * @param {string} workspace - Declared workspace path.
720
+ * @param {object} options - { releaseWorkspaceRoot, preflightRealpath }
721
+ * @returns {Promise<{ realpath: string }>}
722
+ */
723
+ export async function assertPresetWorkspaceExecution(workspace, { releaseWorkspaceRoot, preflightRealpath }) {
724
+ const resolved = resolvePresetWorkspace(workspace, releaseWorkspaceRoot);
725
+ let real;
726
+ try {
727
+ real = await realpath(resolved);
728
+ } catch {
729
+ failWorkspace(`path no longer resolves at execution time: ${workspace}`, { workspace });
730
+ }
731
+ if (preflightRealpath !== undefined && real !== preflightRealpath) {
732
+ failWorkspace(
733
+ `workspace realpath changed between preflight and execution (TOCTOU guard); refusing to write`,
734
+ { workspace, preflightRealpath, observedRealpath: real },
735
+ );
736
+ }
737
+ let rootReal;
738
+ try {
739
+ rootReal = await realpath(releaseWorkspaceRoot);
740
+ } catch {
741
+ failWorkspace(
742
+ 'release workspace root does not resolve to an existing directory; the release-workspace write exclusion fails closed',
743
+ {},
744
+ );
745
+ }
746
+ if (real === rootReal) {
747
+ failWorkspace('workspace must not be the release workspace itself', { workspace });
748
+ }
749
+ const segments = real.split(/[\\/]+/);
750
+ if (segments.includes('.release-skill')) {
751
+ failWorkspace('workspace must not live inside the .release-skill/ runtime directory', { workspace });
752
+ }
753
+ return { realpath: real };
754
+ }