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,258 @@
1
+ /**
2
+ * Version-sensitive derived-artifact fast pre-gates (O1, 2026-08-18
3
+ * release-cycle investigation §3.2).
4
+ *
5
+ * In the 0.6.1 cycle two prepares burned the full ~80s test hook before
6
+ * surfacing drift that a sub-second check could have caught: adapter trees
7
+ * out of sync with skills-src/, and self-bootstrap fact pins still bound to
8
+ * the previous version. This module promotes those two exact checks to
9
+ * prepare's earliest stage, alongside the bundle freshness gate:
10
+ *
11
+ * - adapter gate: runs `scripts/build-adapters.mjs --check` (drift list,
12
+ * exit 1 on drift — the same supported check the scripts surface offers);
13
+ * - fact-pin gate: runs the version fact pins of
14
+ * `test/release-docs-self-bootstrap.test.mjs` — exactly its hermetic
15
+ * section 1 (`[self-bootstrap 1*]`: byte-level version assertions plus the
16
+ * in-process read-only planner), scoped via `--test-name-pattern`.
17
+ *
18
+ * Both promote the CANONICAL check logic — the gate and the full pipeline
19
+ * can never disagree about what "in sync" means. The facts gate is scoped to
20
+ * the suite's hermetic fact-pin section deliberately: the suite's remaining
21
+ * sections shell out to npm/git and drive fixture prepares, which would make
22
+ * a "fast pre-gate" slow, recursive, and brittle under toolchain-shimming
23
+ * fixtures (a prepare invoked with a shimmed `npm` would crash the gate
24
+ * child and false-report drift). Those sections remain the full test hooks'
25
+ * job — this gate never replaces them. Every failure message therefore
26
+ * states the boundary explicitly: this is a fast pre-gate and does NOT
27
+ * replace the full test hooks(快速前置,不替代全量测试).
28
+ *
29
+ * Spawn hygiene: the child environment drops NODE_TEST_CONTEXT. When prepare
30
+ * itself runs inside a node:test harness, the runner exports that variable,
31
+ * and an inheriting `node --test` child then prints "run() is being called
32
+ * recursively ... skipping running files" and exits 0 — a false pass this
33
+ * gate must never report as fresh. Production prepare runs in a plain shell
34
+ * where the variable is absent, so the sanitization is a pure hardening.
35
+ *
36
+ * Recursion guard: the facts gate spawns the very suite whose fixture
37
+ * prepares call prepareRelease — an unguarded child would re-enter the gate
38
+ * and recurse without bound (every level waits on its own child until the
39
+ * 300s timeouts cascade). Gate children therefore carry
40
+ * RELEASE_SKILL_FACTS_GATE_ACTIVE; a facts gate running under the marker
41
+ * records not-applicable (reason nested-gate-run) and spawns nothing. Only
42
+ * the facts gate needs the guard: the adapter child (build-adapters --check)
43
+ * never calls prepareRelease. The outermost prepare still enforces both
44
+ * gates; only the verification run itself is exempt.
45
+ *
46
+ * Applicability mirrors bundle-freshness: installed distributions ship
47
+ * neither the build scripts nor the test file, so the gates record
48
+ * not-applicable there; a source checkout is always gated.
49
+ *
50
+ * @module core/derived-artifact-gates
51
+ */
52
+
53
+ import { lstat } from 'node:fs/promises';
54
+ import { join } from 'node:path';
55
+ import { execFile as execFileCb } from 'node:child_process';
56
+ import { promisify } from 'node:util';
57
+
58
+ import { ReleaseError, DERIVED_ARTIFACT_STALE } from './errors.mjs';
59
+ import { boundedOutputTail } from './bounded-output.mjs';
60
+
61
+ const defaultExecFile = promisify(execFileCb);
62
+
63
+ /**
64
+ * Exact bilingual note every pre-gate failure carries: the gates are a fast
65
+ * front line and never a substitute for the full test hooks.
66
+ */
67
+ export const DERIVED_ARTIFACT_PREGATE_NOTE =
68
+ 'This is a fast pre-gate and does not replace the full test hooks(快速前置,不替代全量测试).';
69
+
70
+ /** One-click derived-artifact sync suggested by every drift remediation (O2). */
71
+ export const DERIVED_SYNC_COMMAND = 'node scripts/sync-derived-artifacts.mjs';
72
+
73
+ /**
74
+ * Marker every gate child carries. A facts gate running under it is part of
75
+ * the verification run itself and records not-applicable instead of
76
+ * re-spawning the suite (recursion guard, see module docs).
77
+ */
78
+ export const FACTS_GATE_NESTED_ENV = 'RELEASE_SKILL_FACTS_GATE_ACTIVE';
79
+
80
+ /** Gate descriptors: marker file (applicability), argv, remediation text. */
81
+ const GATES = Object.freeze({
82
+ adapters: Object.freeze({
83
+ artifact: 'adapters',
84
+ marker: join('scripts', 'build-adapters.mjs'),
85
+ argv: (pkgRoot) => [join(pkgRoot, 'scripts', 'build-adapters.mjs'), '--check'],
86
+ timeoutMs: 120000,
87
+ remediation:
88
+ 'Rebuild the existing adapters with: node scripts/build-adapters.mjs --apply ' +
89
+ `(or run the one-click derived-artifact sync from the workspace root: ${DERIVED_SYNC_COMMAND}).`,
90
+ }),
91
+ 'self-bootstrap-facts': Object.freeze({
92
+ artifact: 'self-bootstrap-facts',
93
+ marker: join('test', 'release-docs-self-bootstrap.test.mjs'),
94
+ // Hermetic fact-pin section only (see module docs): byte-level version
95
+ // facts + in-process planner — no npm/git, no fixture prepares.
96
+ argv: (pkgRoot) => [
97
+ '--test',
98
+ '--test-name-pattern',
99
+ '\\[self-bootstrap 1',
100
+ join(pkgRoot, 'test', 'release-docs-self-bootstrap.test.mjs'),
101
+ ],
102
+ timeoutMs: 120000,
103
+ remediation:
104
+ 'Refresh the derived documents and version points first ' +
105
+ `(workspace root: ${DERIVED_SYNC_COMMAND}); if the pins still fail, update the fact pins deliberately — ` +
106
+ 'the gate never edits sources or test pins itself.',
107
+ }),
108
+ });
109
+
110
+ async function isFile(path) {
111
+ try {
112
+ return (await lstat(path)).isFile();
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Run one derived-artifact check (pure decision, never throws).
120
+ *
121
+ * @param {'adapters' | 'self-bootstrap-facts'} kind - Gate to run.
122
+ * @param {string} pkgRoot - Absolute package root of the running checkout.
123
+ * @param {object} [options]
124
+ * @param {Function} [options.execFileFn] - execFile seam (tests).
125
+ * @param {number} [options.timeoutMs] - Child timeout override.
126
+ * @returns {Promise<{
127
+ * applicable: boolean,
128
+ * fresh?: boolean,
129
+ * reason?: string,
130
+ * artifact?: string,
131
+ * exitCode?: number | null,
132
+ * stdoutTail?: string,
133
+ * stderrTail?: string,
134
+ * durationMs?: number,
135
+ * }>} stdoutTail/stderrTail are bounded and present on BOTH outcomes —
136
+ * on the fresh path they prove the child really executed (e.g. the facts
137
+ * gate carries the child suite's own `ℹ tests N` summary).
138
+ */
139
+ export async function checkDerivedArtifactGate(kind, pkgRoot, options = {}) {
140
+ const gate = GATES[kind];
141
+ if (!gate) {
142
+ throw new ReleaseError(DERIVED_ARTIFACT_STALE, `unknown derived-artifact gate: ${kind}`, { kind });
143
+ }
144
+ const execFileFn = options.execFileFn ?? defaultExecFile;
145
+
146
+ if (kind === 'self-bootstrap-facts' && process.env[FACTS_GATE_NESTED_ENV]) {
147
+ // Recursion guard: this prepare runs inside a gate child (the suite the
148
+ // facts gate itself spawns). Re-spawning would recurse without bound;
149
+ // the outermost prepare already enforces the gate for this checkout.
150
+ return { applicable: false, reason: 'nested-gate-run', artifact: gate.artifact };
151
+ }
152
+
153
+ if (!(await isFile(join(pkgRoot, gate.marker)))) {
154
+ // Installed distributions ship neither the build scripts nor the test
155
+ // file; drift is a source-checkout concern only (bundle-freshness rule).
156
+ return { applicable: false, reason: 'installed-layout', artifact: gate.artifact };
157
+ }
158
+
159
+ const startedAt = Date.now();
160
+ // Never hand the child a test-runner context: under a nested node:test
161
+ // harness NODE_TEST_CONTEXT makes `node --test` skip all files and exit 0,
162
+ // which this gate must not report as fresh (false pass).
163
+ const childEnv = { ...process.env };
164
+ delete childEnv.NODE_TEST_CONTEXT;
165
+ childEnv[FACTS_GATE_NESTED_ENV] = '1';
166
+ try {
167
+ const { stdout, stderr } = await execFileFn(process.execPath, gate.argv(pkgRoot), {
168
+ cwd: pkgRoot,
169
+ shell: false,
170
+ encoding: 'utf8',
171
+ timeout: options.timeoutMs ?? gate.timeoutMs,
172
+ env: childEnv,
173
+ maxBuffer: 16 * 1024 * 1024,
174
+ });
175
+ return {
176
+ applicable: true,
177
+ fresh: true,
178
+ artifact: gate.artifact,
179
+ stdoutTail: boundedOutputTail(stdout ?? ''),
180
+ stderrTail: boundedOutputTail(stderr ?? ''),
181
+ durationMs: Date.now() - startedAt,
182
+ };
183
+ } catch (err) {
184
+ // Timeout / spawn failure fail closed too: an undecidable gate is drift.
185
+ const stdoutTail = boundedOutputTail(err?.stdout ?? '');
186
+ const stderrTail = boundedOutputTail(err?.stderr ?? err?.message ?? '');
187
+ return {
188
+ applicable: true,
189
+ fresh: false,
190
+ reason: err?.killed || err?.code === 'ETIMEDOUT' ? 'timeout' : 'drift',
191
+ artifact: gate.artifact,
192
+ exitCode: typeof err?.code === 'number' ? err.code : null,
193
+ stdoutTail,
194
+ stderrTail,
195
+ durationMs: Date.now() - startedAt,
196
+ };
197
+ }
198
+ }
199
+
200
+ /** Adapter freshness decision (build-adapters --check). */
201
+ export function checkAdapterFreshness(pkgRoot, options = {}) {
202
+ return checkDerivedArtifactGate('adapters', pkgRoot, options);
203
+ }
204
+
205
+ /** Self-bootstrap fact-pin decision (single-file test). */
206
+ export function checkSelfBootstrapFacts(pkgRoot, options = {}) {
207
+ return checkDerivedArtifactGate('self-bootstrap-facts', pkgRoot, options);
208
+ }
209
+
210
+ async function assertGate(kind, pkgRoot, options = {}) {
211
+ const result = await checkDerivedArtifactGate(kind, pkgRoot, options);
212
+ if (!result.applicable || result.fresh) {
213
+ return result;
214
+ }
215
+ const gate = GATES[kind];
216
+ const subject = kind === 'adapters'
217
+ ? 'adapters/ is out of sync with its sources (build-adapters --check reported drift)'
218
+ : 'the release-docs-self-bootstrap fact pins are stale (the hermetic fact-pin check failed)';
219
+ throw new ReleaseError(
220
+ DERIVED_ARTIFACT_STALE,
221
+ `${subject}. ${DERIVED_ARTIFACT_PREGATE_NOTE} ${gate.remediation}`,
222
+ {
223
+ artifact: result.artifact,
224
+ reason: result.reason,
225
+ exitCode: result.exitCode,
226
+ stdoutTail: result.stdoutTail,
227
+ stderrTail: result.stderrTail,
228
+ durationMs: result.durationMs,
229
+ },
230
+ );
231
+ }
232
+
233
+ /**
234
+ * Fail-closed adapter pre-gate used by prepare's earliest stage.
235
+ * Not-applicable layouts return quietly; drift throws DERIVED_ARTIFACT_STALE.
236
+ *
237
+ * @param {string} pkgRoot - Absolute package root.
238
+ * @param {object} [options] - execFileFn/timeoutMs seams (tests).
239
+ * @returns {Promise<object>} The gate decision.
240
+ * @throws {ReleaseError} DERIVED_ARTIFACT_STALE on drift/timeout.
241
+ */
242
+ export function assertAdapterFreshness(pkgRoot, options = {}) {
243
+ return assertGate('adapters', pkgRoot, options);
244
+ }
245
+
246
+ /**
247
+ * Fail-closed self-bootstrap fact-pin pre-gate used by prepare's earliest
248
+ * stage. Not-applicable layouts return quietly; drift throws
249
+ * DERIVED_ARTIFACT_STALE.
250
+ *
251
+ * @param {string} pkgRoot - Absolute package root.
252
+ * @param {object} [options] - execFileFn/timeoutMs seams (tests).
253
+ * @returns {Promise<object>} The gate decision.
254
+ * @throws {ReleaseError} DERIVED_ARTIFACT_STALE on drift/timeout.
255
+ */
256
+ export function assertSelfBootstrapFacts(pkgRoot, options = {}) {
257
+ return assertGate('self-bootstrap-facts', pkgRoot, options);
258
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * docs-refresh preset: refresh one or more independent docs repositories
3
+ * (v0.6.3 R4, design §2.5).
4
+ *
5
+ * GitHub-Pages-style docs sites live in their own repositories. One
6
+ * declaration refreshes ALL of them (config.repositories array) from the
7
+ * frozen release payload:
8
+ *
9
+ * - config.mappings copies payload files into the docs repository
10
+ * (from = payload-relative source, to = repository-relative destination);
11
+ * an optional per-mapping versionMarker placeholder is replaced with the
12
+ * frozen release version while writing (version-marker replacement);
13
+ * - config.gates (argument arrays, R1 hook runner) run inside each docs
14
+ * repository AFTER the write and BEFORE any commit/push — the docs build
15
+ * gate; a failing gate leaves zero remote side effects;
16
+ * - every repository is committed with the frozen bot identity and pushed
17
+ * (never --force); byte-identical content -> NO_CHANGE (idempotent).
18
+ *
19
+ * Payload requirement: mappings read the materialized payload, which only
20
+ * exists in the distribute phase (§2.3 contexts carry payloadDir there); a
21
+ * postVerify-phase declaration fails closed with a clear message.
22
+ *
23
+ * @module core/docs-refresh-preset
24
+ */
25
+
26
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
27
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
28
+
29
+ import { ReleaseError, GATE_FAILED } from './errors.mjs';
30
+ import { applyDownstreamGitChange } from './preset-gitwrite.mjs';
31
+
32
+ /**
33
+ * Assert `from` stays inside the materialized payload directory (declaration
34
+ * validation guarantees a safe relative shape; this is the execution-time
35
+ * re-check).
36
+ */
37
+ function resolvePayloadSource(payloadDir, from) {
38
+ const sourcePath = resolve(payloadDir, from);
39
+ const rel = relative(payloadDir, sourcePath);
40
+ if (rel === '' || isAbsolute(rel) || rel === '..'
41
+ || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
42
+ throw new ReleaseError(
43
+ GATE_FAILED,
44
+ `docs-refresh mapping source "${from}" escapes the payload directory`,
45
+ { from },
46
+ );
47
+ }
48
+ return sourcePath;
49
+ }
50
+
51
+ /**
52
+ * Execute one docs-refresh preset hook end-to-end: for every declared docs
53
+ * repository, copy the mapped payload files (version-marker replacement),
54
+ * run the docs build gates, and push. Shared by distribute and postVerify
55
+ * (payload requirement effectively binds it to the distribute phase).
56
+ *
57
+ * @param {object} params
58
+ * @param {object} params.hook - Declared hook entry (config bound).
59
+ * @param {object} params.contextProjection - The §2.3 context projection.
60
+ * @param {object} params.commitIdentity - Frozen commitIdentity.
61
+ * @param {string} params.payloadDir - Materialized payload directory
62
+ * (distribute phase; postVerify contexts never carry it).
63
+ * @param {string} params.root - Release workspace root.
64
+ * @param {Function} [params.exec] - Injectable git exec (tests).
65
+ * @param {Function} [params.hookRunner] - Injectable gate runner (tests).
66
+ * @returns {Promise<{ status: string, observation: object,
67
+ * observations: object[], mode: string }>}
68
+ */
69
+ export async function executeDocsRefreshHook(params) {
70
+ const { hook, contextProjection, commitIdentity, payloadDir, root, exec, hookRunner } = params ?? {};
71
+ const config = hook?.config;
72
+ const repositories = config?.repositories;
73
+ if (!Array.isArray(repositories) || repositories.length === 0) {
74
+ throw new ReleaseError(GATE_FAILED, 'docs-refresh requires a non-empty config.repositories array');
75
+ }
76
+ const mappings = config?.mappings;
77
+ if (!Array.isArray(mappings) || mappings.length === 0) {
78
+ throw new ReleaseError(GATE_FAILED, 'docs-refresh requires a non-empty config.mappings array');
79
+ }
80
+ if (typeof payloadDir !== 'string' || payloadDir.length === 0) {
81
+ throw new ReleaseError(
82
+ GATE_FAILED,
83
+ 'docs-refresh copies files from the materialized payload, which only exists in the distribute phase; declare phase: distribute (the default) instead of postVerify',
84
+ {},
85
+ );
86
+ }
87
+
88
+ const unitId = contextProjection?.unitId ?? 'unknown';
89
+ const version = contextProjection?.version ?? 'unknown';
90
+ const gates = config?.gates ?? [];
91
+
92
+ // Deterministic per-mapping write. Binary-safe copy; the version-marker
93
+ // replacement (when declared) treats the file as UTF-8 text.
94
+ const mutate = async (worktree) => {
95
+ for (const mapping of mappings) {
96
+ const sourcePath = resolvePayloadSource(payloadDir, mapping.from);
97
+ let content = await readFile(sourcePath).catch(() => null);
98
+ if (content === null) {
99
+ throw new ReleaseError(
100
+ GATE_FAILED,
101
+ `docs-refresh mapping source "${mapping.from}" is missing from the materialized payload`,
102
+ { from: mapping.from },
103
+ );
104
+ }
105
+ if (typeof mapping.versionMarker === 'string' && mapping.versionMarker.length > 0) {
106
+ const text = content.toString('utf8');
107
+ content = Buffer.from(text.split(mapping.versionMarker).join(version), 'utf8');
108
+ }
109
+ const destination = join(worktree, mapping.to);
110
+ await mkdir(dirname(destination), { recursive: true });
111
+ await writeFile(destination, content);
112
+ }
113
+ };
114
+
115
+ const observations = [];
116
+ let anyChange = false;
117
+ for (const [index, target] of repositories.entries()) {
118
+ let result;
119
+ try {
120
+ result = await applyDownstreamGitChange({
121
+ target,
122
+ commitIdentity,
123
+ commitSubject: `release-skill docs-refresh ${unitId} ${version}`,
124
+ mutate,
125
+ gates,
126
+ contextProjection,
127
+ root,
128
+ ...(exec !== undefined ? { exec } : {}),
129
+ ...(hookRunner !== undefined ? { hookRunner } : {}),
130
+ });
131
+ } catch (err) {
132
+ throw new ReleaseError(
133
+ err?.code ?? GATE_FAILED,
134
+ `docs-refresh repository ${index + 1} of ${repositories.length} failed: ${err?.message ?? err}`,
135
+ { repositoryIndex: index, ...(err?.details ?? {}) },
136
+ );
137
+ }
138
+ observations.push({
139
+ repositoryIndex: index,
140
+ ...(typeof target.remoteUrl === 'string' ? { remoteUrl: target.remoteUrl } : {}),
141
+ ...(typeof target.workspace === 'string' ? { workspace: target.workspace } : {}),
142
+ branch: target.branch,
143
+ ...(result.observation ?? {}),
144
+ });
145
+ if (result.status === 'EXECUTED') anyChange = true;
146
+ }
147
+
148
+ if (!anyChange) {
149
+ return {
150
+ status: 'NO_CHANGE',
151
+ mode: 'no-change',
152
+ observation: { mode: 'no-change' },
153
+ observations,
154
+ };
155
+ }
156
+ const firstPushed = observations.find((entry) => entry.mode === 'pushed');
157
+ return {
158
+ status: 'EXECUTED',
159
+ mode: 'pushed',
160
+ observation: {
161
+ mode: 'pushed',
162
+ ...(firstPushed?.pushedCommit ? { pushedCommit: firstPushed.pushedCommit } : {}),
163
+ repositoryCount: repositories.length,
164
+ },
165
+ observations,
166
+ };
167
+ }
@@ -95,6 +95,8 @@ const EXIT_CODE_MAP = Object.freeze({
95
95
  CONTENT_MISMATCH: 52,
96
96
  DIRTY_SOURCE_INPUT: 53,
97
97
  BUNDLE_STALE: 54,
98
+ POSTPUBLISH_HOOK_INVALID: 55,
99
+ DERIVED_ARTIFACT_STALE: 56,
98
100
  });
99
101
 
100
102
  // ---- Error code constants ----
@@ -144,6 +146,8 @@ export const NOT_DEFAULT = 'NOT_DEFAULT';
144
146
  export const CONTENT_MISMATCH = 'CONTENT_MISMATCH';
145
147
  export const DIRTY_SOURCE_INPUT = 'DIRTY_SOURCE_INPUT';
146
148
  export const BUNDLE_STALE = 'BUNDLE_STALE';
149
+ export const POSTPUBLISH_HOOK_INVALID = 'POSTPUBLISH_HOOK_INVALID';
150
+ export const DERIVED_ARTIFACT_STALE = 'DERIVED_ARTIFACT_STALE';
147
151
 
148
152
  /**
149
153
  * Typed error for release-skill operations.
@@ -197,6 +197,10 @@ function buildFilteredEnv(envAllowlist, contextEnv) {
197
197
  * @param {Object} context
198
198
  * @param {string} context.root - Absolute project root.
199
199
  * @param {Record<string, string>} [context.env] - Extra env variables.
200
+ * @param {Record<string, string>} [context.injectEnv] - Always-injected
201
+ * contract variables (merged AFTER allowlist filtering; used by the
202
+ * postPublish hook context contract RELEASE_SKILL_POSTPUBLISH_CONTEXT).
203
+ * Keys must match /^[A-Z_][A-Z0-9_]*$/, values must be strings.
200
204
  *
201
205
  * @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
202
206
  *
@@ -233,6 +237,30 @@ export async function runHook(hook, context) {
233
237
  // --- Build safe environment ---
234
238
  const env = buildFilteredEnv(envAllowlist, context.env);
235
239
 
240
+ // --- Always-injected contract variables (postPublish context, v0.6.3 R1).
241
+ // Merged after allowlist filtering: declarations cannot be mutated to carry
242
+ // the context variable, and the runner must not depend on envAllowlist.
243
+ if (context.injectEnv !== undefined) {
244
+ if (!context.injectEnv || typeof context.injectEnv !== 'object' || Array.isArray(context.injectEnv)) {
245
+ throw new ReleaseError('INVALID_HOOK', 'context.injectEnv must be a plain object');
246
+ }
247
+ for (const [key, value] of Object.entries(context.injectEnv)) {
248
+ if (!ENV_KEY_PATTERN.test(key)) {
249
+ throw new ReleaseError(
250
+ 'INVALID_HOOK',
251
+ `context.injectEnv key "${key}" must match /^[A-Z_][A-Z0-9_]*$/`,
252
+ );
253
+ }
254
+ if (typeof value !== 'string') {
255
+ throw new ReleaseError(
256
+ 'INVALID_HOOK',
257
+ `context.injectEnv value for "${key}" must be a string`,
258
+ );
259
+ }
260
+ env[key] = value;
261
+ }
262
+ }
263
+
236
264
  // --- Set up timeout ---
237
265
  const executable = command[0];
238
266
  const args = command.slice(1);
@@ -0,0 +1,174 @@
1
+ /**
2
+ * marketplace-registry-entry preset: direct-edit downstream registry entry
3
+ * update (v0.6.3 R4, design §2.5).
4
+ *
5
+ * For downstream marketplaces WITHOUT their own governance/render pipeline:
6
+ * locate the registry entry by config.entryKey inside config.registryPath,
7
+ * update the declared fieldsFromPlan from the FROZEN plan values (§2.3
8
+ * context projection), run the declared downstream gates (argument arrays via
9
+ * the R1 hook runner), then push. Hubs with their own governance use
10
+ * proposal-inbox instead.
11
+ *
12
+ * Registry document shape (canonical, validated fail-closed):
13
+ * { "entries": [ { "key": "<entryKey>", ...fields } ] }
14
+ * - a missing registry file, a missing/malformed `entries` array, or a
15
+ * missing entry key is REMOTE_CONFLICT: the downstream state disagrees
16
+ * with the declaration and a human decides (nothing is ever invented);
17
+ * - the updated document is serialized deterministically (2-space indent +
18
+ * trailing newline): byte-identical output -> NO_CHANGE (idempotent);
19
+ * - every other entry and field is preserved untouched.
20
+ *
21
+ * fieldsFromPlan maps entry field -> §2.3 context field; only frozen plan
22
+ * values are ever written (version/tag/commit/tree/manifestDigest/planDigest/
23
+ * publishedAt/unitId). A source value absent from the frozen plan fails
24
+ * closed before any write.
25
+ *
26
+ * @module core/marketplace-registry-entry
27
+ */
28
+
29
+ import { readFile } from 'node:fs/promises';
30
+ import { join } from 'node:path';
31
+
32
+ import { ReleaseError, GATE_FAILED, REMOTE_CONFLICT } from './errors.mjs';
33
+ import { FIELDS_FROM_PLAN_SOURCES } from './presets.mjs';
34
+ import { applyDownstreamGitChange } from './preset-gitwrite.mjs';
35
+
36
+ export { FIELDS_FROM_PLAN_SOURCES };
37
+
38
+ /**
39
+ * Apply the frozen-plan field update to one registry document. Pure and
40
+ * deterministic: returns the updated document, or throws when the document
41
+ * shape or the entry disagrees with the declaration.
42
+ *
43
+ * @param {object} registry - Parsed registry document.
44
+ * @param {object} params - { entryKey, fieldsFromPlan, contextProjection }.
45
+ * @returns {object} The updated registry document (new object).
46
+ * @throws {ReleaseError} REMOTE_CONFLICT when the entry cannot be located;
47
+ * GATE_FAILED when a frozen source value is missing.
48
+ */
49
+ export function updateRegistryEntry(registry, params) {
50
+ const { entryKey, fieldsFromPlan, contextProjection } = params ?? {};
51
+ if (!registry || typeof registry !== 'object' || Array.isArray(registry)) {
52
+ throw new ReleaseError(REMOTE_CONFLICT, 'marketplace registry file is not a JSON object; human decision required', {});
53
+ }
54
+ if (!Array.isArray(registry.entries)) {
55
+ throw new ReleaseError(
56
+ REMOTE_CONFLICT,
57
+ 'marketplace registry file carries no "entries" array; the marketplace-registry-entry preset expects { "entries": [ { "key": ... } ] }',
58
+ {},
59
+ );
60
+ }
61
+ const entryIndex = registry.entries.findIndex(
62
+ (entry) => entry && typeof entry === 'object' && !Array.isArray(entry) && entry.key === entryKey,
63
+ );
64
+ if (entryIndex < 0) {
65
+ throw new ReleaseError(
66
+ REMOTE_CONFLICT,
67
+ `marketplace registry entry "${entryKey}" not found; registering a new entry requires a human decision`,
68
+ { entryKey },
69
+ );
70
+ }
71
+
72
+ const updated = JSON.parse(JSON.stringify(registry)); // deep, order-stable copy
73
+ const entry = updated.entries[entryIndex];
74
+ for (const [entryField, sourceField] of Object.entries(fieldsFromPlan)) {
75
+ const value = contextProjection?.[sourceField];
76
+ if (typeof value !== 'string' || value.length === 0) {
77
+ throw new ReleaseError(
78
+ GATE_FAILED,
79
+ `fieldsFromPlan."${entryField}" maps to context field "${sourceField}" which the frozen plan does not provide`,
80
+ { entryField, sourceField },
81
+ );
82
+ }
83
+ entry[entryField] = value;
84
+ }
85
+ return updated;
86
+ }
87
+
88
+ /** Deterministic registry serialization (byte-stable NO_CHANGE detection). */
89
+ export function serializeRegistry(registry) {
90
+ return `${JSON.stringify(registry, null, 2)}\n`;
91
+ }
92
+
93
+ /**
94
+ * Execute one marketplace-registry-entry preset hook end-to-end: read the
95
+ * downstream registry, apply the frozen-plan update, run the downstream
96
+ * gates, and push (never --force). Shared by distribute and postVerify.
97
+ *
98
+ * @param {object} params
99
+ * @param {object} params.hook - Declared hook entry (config bound).
100
+ * @param {object} params.contextProjection - The §2.3 context projection.
101
+ * @param {object} params.commitIdentity - Frozen commitIdentity.
102
+ * @param {string} params.root - Release workspace root.
103
+ * @param {Function} [params.exec] - Injectable git exec (tests).
104
+ * @param {Function} [params.hookRunner] - Injectable gate runner (tests).
105
+ * @returns {Promise<{ status: string, observation: object, registryPath: string }>}
106
+ */
107
+ export async function executeMarketplaceRegistryEntryHook(params) {
108
+ const { hook, contextProjection, commitIdentity, root, exec, hookRunner } = params ?? {};
109
+ const config = hook?.config;
110
+ const target = config?.target;
111
+ if (!target || typeof target.branch !== 'string') {
112
+ throw new ReleaseError(GATE_FAILED, 'marketplace-registry-entry requires config.target with a branch');
113
+ }
114
+ const registryPath = config?.registryPath ?? 'registry.json';
115
+ const entryKey = config?.entryKey;
116
+ const fieldsFromPlan = config?.fieldsFromPlan;
117
+ if (typeof entryKey !== 'string' || entryKey.length === 0) {
118
+ throw new ReleaseError(GATE_FAILED, 'marketplace-registry-entry requires config.entryKey');
119
+ }
120
+ if (!fieldsFromPlan || typeof fieldsFromPlan !== 'object' || Object.keys(fieldsFromPlan).length === 0) {
121
+ throw new ReleaseError(GATE_FAILED, 'marketplace-registry-entry requires a non-empty config.fieldsFromPlan');
122
+ }
123
+
124
+ const unitId = contextProjection?.unitId ?? 'unknown';
125
+ const version = contextProjection?.version ?? 'unknown';
126
+
127
+ let currentText = null;
128
+ const mutate = async (worktree) => {
129
+ const absoluteRegistry = join(worktree, registryPath);
130
+ let raw;
131
+ try {
132
+ raw = await readFile(absoluteRegistry, 'utf8');
133
+ } catch {
134
+ throw new ReleaseError(
135
+ REMOTE_CONFLICT,
136
+ `marketplace registry file "${registryPath}" is missing in the downstream repository; creating it requires a human decision`,
137
+ { registryPath },
138
+ );
139
+ }
140
+ currentText = raw;
141
+ let registry;
142
+ try {
143
+ registry = JSON.parse(raw);
144
+ } catch {
145
+ throw new ReleaseError(
146
+ REMOTE_CONFLICT,
147
+ `marketplace registry file "${registryPath}" is not valid JSON; human decision required`,
148
+ { registryPath },
149
+ );
150
+ }
151
+ const updated = updateRegistryEntry(registry, { entryKey, fieldsFromPlan, contextProjection });
152
+ const serialized = serializeRegistry(updated);
153
+ if (serialized === currentText) {
154
+ // Leave the file untouched: the staged tree stays equal to the tip and
155
+ // the shared lifecycle reports NO_CHANGE.
156
+ return;
157
+ }
158
+ const { writeFile } = await import('node:fs/promises');
159
+ await writeFile(absoluteRegistry, serialized);
160
+ };
161
+
162
+ const result = await applyDownstreamGitChange({
163
+ target,
164
+ commitIdentity,
165
+ commitSubject: `release-skill marketplace-registry-entry ${unitId} ${version} (${entryKey})`,
166
+ mutate,
167
+ gates: config?.gates ?? [],
168
+ contextProjection,
169
+ root,
170
+ ...(exec !== undefined ? { exec } : {}),
171
+ ...(hookRunner !== undefined ? { hookRunner } : {}),
172
+ });
173
+ return { ...result, registryPath };
174
+ }