release-skill 0.1.9 → 0.2.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 (59) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.kimi-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +53 -0
  6. package/INSTALL.md +4 -4
  7. package/INSTALL.zh-CN.md +4 -4
  8. package/README.md +18 -33
  9. package/README.zh-CN.md +17 -23
  10. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  11. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  12. package/adapters/claude/bin/release-skill.bundle.mjs +2740 -1844
  13. package/adapters/claude/schemas/.render-manifest.json +8 -8
  14. package/adapters/claude/schemas/approval-record.schema.json +1 -1
  15. package/adapters/claude/schemas/release-plan.schema.json +6 -2
  16. package/adapters/claude/schemas/release-project.schema.json +14 -0
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/bin/release-skill.bundle.mjs +2740 -1844
  19. package/adapters/codex/schemas/.render-manifest.json +8 -8
  20. package/adapters/codex/schemas/approval-record.schema.json +1 -1
  21. package/adapters/codex/schemas/release-plan.schema.json +6 -2
  22. package/adapters/codex/schemas/release-project.schema.json +14 -0
  23. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  24. package/adapters/kimi/bin/release-skill.bundle.mjs +2740 -1844
  25. package/adapters/kimi/schemas/.render-manifest.json +8 -8
  26. package/adapters/kimi/schemas/approval-record.schema.json +1 -1
  27. package/adapters/kimi/schemas/release-plan.schema.json +6 -2
  28. package/adapters/kimi/schemas/release-project.schema.json +14 -0
  29. package/bin/release-skill-cli.mjs +3 -0
  30. package/bin/release-skill.bundle.mjs +2740 -1844
  31. package/package.json +8 -2
  32. package/references/.render-manifest.json +8 -8
  33. package/references/01-state-machine.md +5 -5
  34. package/references/02-project-config.md +1 -1
  35. package/references/05-evidence-and-errors.md +1 -1
  36. package/references/06-adapter-contract.md +41 -1
  37. package/schemas/.render-manifest.json +8 -8
  38. package/schemas/approval-record.schema.json +1 -1
  39. package/schemas/release-plan.schema.json +6 -2
  40. package/schemas/release-project.schema.json +14 -0
  41. package/scripts/sync-public-files.mjs +462 -0
  42. package/src/adapters/contract.mjs +60 -0
  43. package/src/adapters/plugin-marketplace.mjs +289 -730
  44. package/src/commands/prepare.mjs +195 -182
  45. package/src/commands/publish.mjs +438 -122
  46. package/src/commands/reconcile.mjs +369 -191
  47. package/src/commands/verify.mjs +13 -2
  48. package/src/core/approval.mjs +72 -45
  49. package/src/core/baseline.mjs +16 -0
  50. package/src/core/checkpoints.mjs +143 -0
  51. package/src/core/evidence.mjs +30 -3
  52. package/src/core/hook-cache.mjs +254 -0
  53. package/src/core/hooks.mjs +37 -1
  54. package/src/core/observe-retry.mjs +223 -0
  55. package/src/core/plan.mjs +162 -253
  56. package/src/platforms/kimi.mjs +514 -0
  57. package/src/platforms/registry.mjs +393 -0
  58. package/src/producers/build-adapters.mjs +14 -22
  59. package/src/snapshot/frozen.mjs +29 -5
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Platform registry — the single source of truth for consumer-platform
3
+ * knowledge (claude / codex / kimi).
4
+ *
5
+ * T2.2 converges platform facts that were scattered across plugin-marketplace.mjs,
6
+ * build-adapters.mjs, prepare.mjs, plan.mjs and project.yaml into this module.
7
+ * Data that is purely declarative (paths, argv templates, env construction,
8
+ * manifest layout, host-artifact exemptions, schema required fields) lives in
9
+ * each platform's descriptor; protocol LOGIC differences (claude `list` returns
10
+ * an array vs codex returns `{installed:[]}`) live in per-platform strategy
11
+ * functions referenced from the descriptor. Data in tables, logic in functions
12
+ * — neither scattered.
13
+ *
14
+ * Adding a platform is meant to converge on ~3 hand edits: a descriptor +
15
+ * strategy here, a schema enum bump (the schema-contract test turns red until
16
+ * it is done), and a regeneration of the manifest/adapter producers.
17
+ *
18
+ * `assertRegistry()` runs at module load: a malformed registry throws at import
19
+ * time, never at runtime.
20
+ *
21
+ * NOTE (T2.2 step 2): the claude/codex strategy functions (pure list-parse /
22
+ * install-path / identity extraction / list cross-validation) live in this
23
+ * file; kimi's side-effecting manual-requirement and manifest-reading
24
+ * strategies live in ./kimi.mjs (§4.2's per-platform-module option — the only
25
+ * non-trivial strategies) and are referenced from the kimi descriptor below.
26
+ * plugin-marketplace.mjs consumes every platform difference through this
27
+ * registry; see the t2-2 deviation record for the step-2 wiring details.
28
+ *
29
+ * @module platforms/registry
30
+ */
31
+
32
+ import { join } from 'node:path';
33
+
34
+ import {
35
+ executeKimiManualRequirement,
36
+ readKimiManifest,
37
+ KIMI_MANIFEST_CANDIDATES,
38
+ } from './kimi.mjs';
39
+
40
+ // --- claude strategy -------------------------------------------------------
41
+
42
+ function claudeParseListOutput(listOutput, pluginId) {
43
+ // Claude `plugin list --json` returns a top-level array; installPath is read
44
+ // from the matching entry.
45
+ if (!Array.isArray(listOutput)) {
46
+ return { ok: false, error: 'Claude plugin list did not return an array' };
47
+ }
48
+ const found = listOutput.find((p) => p.id === pluginId);
49
+ if (!found) {
50
+ return { ok: false, error: `plugin "${pluginId}" not found in Claude plugin list` };
51
+ }
52
+ if (!found.installPath) {
53
+ return { ok: false, error: `plugin "${pluginId}" found but missing installPath` };
54
+ }
55
+ return { ok: true, found, installPath: found.installPath };
56
+ }
57
+
58
+ function claudeExtractInstallPath({ listParsed }) {
59
+ // Claude reports no installedPath at install time; the install path comes from
60
+ // the parsed list entry. Callers only invoke this after parseListOutput
61
+ // returned ok, which guarantees a non-empty installPath (fail-closed there).
62
+ return { ok: true, installPath: listParsed?.installPath };
63
+ }
64
+
65
+ function claudeExtractListIdentity(found) {
66
+ // Claude list may not have name; extract plugin/marketplace from id.
67
+ // Key order (plugin, marketplace, version) is observation-serialization
68
+ // relevant and mirrors the legacy observe backfill byte-for-byte.
69
+ const idParts = found.id.split('@');
70
+ const identity = { plugin: idParts[0], marketplace: idParts.slice(1).join('@') };
71
+ if (found.version) identity.version = found.version;
72
+ return identity;
73
+ }
74
+
75
+ // --- codex strategy --------------------------------------------------------
76
+
77
+ function codexParseListOutput(listOutput, pluginId) {
78
+ // Codex `plugin list --json` returns `{installed: [...]}`; the list does NOT
79
+ // carry installedPath (that comes from the install evidence).
80
+ const installed = listOutput?.installed;
81
+ if (!Array.isArray(installed)) {
82
+ return { ok: false, error: 'Codex plugin list did not return {installed: [...]}' };
83
+ }
84
+ const found = installed.find((p) => p.pluginId === pluginId);
85
+ if (!found) {
86
+ return { ok: false, error: `plugin "${pluginId}" not found in Codex installed list` };
87
+ }
88
+ return { ok: true, found };
89
+ }
90
+
91
+ function codexExtractInstallPath({ execEvidence }) {
92
+ const installPath = execEvidence?.installOutput?.installedPath;
93
+ if (!installPath) {
94
+ return { ok: false, error: 'evidence install JSON missing installedPath' };
95
+ }
96
+ return { ok: true, installPath };
97
+ }
98
+
99
+ function codexExtractListIdentity(found) {
100
+ // Every field is conditional in the codex list output. Key order (plugin,
101
+ // marketplace, version) mirrors the legacy observe backfill byte-for-byte.
102
+ const identity = {};
103
+ if (found.name) identity.plugin = found.name;
104
+ if (found.marketplaceName) identity.marketplace = found.marketplaceName;
105
+ if (found.version) identity.version = found.version;
106
+ return identity;
107
+ }
108
+
109
+ function codexCrossValidateListEntry(found, action) {
110
+ // Cross-validate: list fields must match evidence/action.
111
+ if (found.name !== action.plugin) {
112
+ return { ok: false, error: `list name "${found.name}" does not match action plugin "${action.plugin}"` };
113
+ }
114
+ if (found.marketplaceName !== action.marketplace) {
115
+ return { ok: false, error: `list marketplaceName "${found.marketplaceName}" does not match action marketplace "${action.marketplace}"` };
116
+ }
117
+ if (found.version !== action.version) {
118
+ return { ok: false, error: `list version "${found.version}" does not match action version "${action.version}"` };
119
+ }
120
+ return { ok: true };
121
+ }
122
+
123
+ // --- platform descriptors --------------------------------------------------
124
+
125
+ const CLAUDE = Object.freeze({
126
+ id: 'claude',
127
+ distributionType: 'claude-plugin',
128
+ actionType: 'claude-marketplace-install',
129
+ // Runtime adapter implementing this platform's marketplace-install action.
130
+ // prepare.mjs stamps it onto generated actions; plan.mjs derives its
131
+ // actionType -> adapter map from it (T2.2 step 3).
132
+ adapter: 'plugin-marketplace',
133
+ automatable: true,
134
+ cli: Object.freeze({
135
+ binary: 'claude',
136
+ marketplaceAdd: (repo, ref) => ['plugin', 'marketplace', 'add', `${repo}@${ref}`],
137
+ install: (plugin, marketplace) => ['plugin', 'install', `${plugin}@${marketplace}`],
138
+ list: () => ['plugin', 'list', '--json'],
139
+ }),
140
+ jsonProtocol: Object.freeze({
141
+ listOutput: 'array',
142
+ installPathSource: 'list',
143
+ // claude marketplace add / plugin install emit no validated JSON output.
144
+ marketplaceAddOutput: null,
145
+ pluginInstallOutput: null,
146
+ }),
147
+ isolationEnv: (home) => ({ HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude') }),
148
+ // execute pre-creates the claude config dir under the isolated HOME.
149
+ isolationSubdirs: Object.freeze(['.claude']),
150
+ manifestPaths: Object.freeze({
151
+ plugin: '.claude-plugin/plugin.json',
152
+ marketplace: '.claude-plugin/marketplace.json',
153
+ }),
154
+ marketplaceSourceForm: 'string',
155
+ // Claude carries the authoritative version in the marketplace entry: the
156
+ // preflight binds entry.version to the action version.
157
+ marketplaceEntryCarriesVersion: true,
158
+ knownHostArtifacts: Object.freeze(['.in_use']),
159
+ schemaRequiredFields: Object.freeze(['plugin', 'marketplace', 'entrySkill']),
160
+ skillRendering: Object.freeze({ mode: 'verbatim', preamble: null, placeholder: '${CLAUDE_PLUGIN_ROOT}' }),
161
+ buildAdapter: Object.freeze({
162
+ pluginDirName: '.claude-plugin',
163
+ templateFileName: 'plugin.json',
164
+ marketplaceFileName: 'marketplace.json',
165
+ hasMarketplace: true,
166
+ }),
167
+ strategy: Object.freeze({
168
+ parseListOutput: claudeParseListOutput,
169
+ extractInstallPath: claudeExtractInstallPath,
170
+ extractListIdentity: claudeExtractListIdentity,
171
+ crossValidateListEntry: null,
172
+ buildManualRequirement: null,
173
+ readManifest: null,
174
+ }),
175
+ });
176
+
177
+ const CODEX = Object.freeze({
178
+ id: 'codex',
179
+ distributionType: 'codex-plugin',
180
+ actionType: 'codex-marketplace-install',
181
+ adapter: 'plugin-marketplace',
182
+ automatable: true,
183
+ cli: Object.freeze({
184
+ binary: 'codex',
185
+ marketplaceAdd: (repo, ref) => ['plugin', 'marketplace', 'add', repo, '--ref', ref, '--json'],
186
+ install: (plugin, marketplace) => ['plugin', 'add', `${plugin}@${marketplace}`, '--json'],
187
+ list: () => ['plugin', 'list', '--json'],
188
+ }),
189
+ jsonProtocol: Object.freeze({
190
+ listOutput: 'installed-object',
191
+ installPathSource: 'install-output',
192
+ // codex emits validated JSON for both marketplace add and plugin add.
193
+ marketplaceAddOutput: 'json',
194
+ pluginInstallOutput: 'json',
195
+ }),
196
+ isolationEnv: (home) => ({ HOME: home, CODEX_HOME: home }),
197
+ // execute pre-creates the codex state dir under the isolated HOME.
198
+ isolationSubdirs: Object.freeze(['.codex']),
199
+ manifestPaths: Object.freeze({
200
+ plugin: '.codex-plugin/plugin.json',
201
+ marketplace: '.agents/plugins/marketplace.json',
202
+ }),
203
+ marketplaceSourceForm: 'local-path-object',
204
+ // Codex keeps the authoritative version in .codex-plugin/plugin.json; the
205
+ // marketplace entry version is not bound to the action version.
206
+ marketplaceEntryCarriesVersion: false,
207
+ knownHostArtifacts: Object.freeze(['.git', '.codex-plugin/migrated-command-skills']),
208
+ schemaRequiredFields: Object.freeze(['plugin', 'marketplace', 'entrySkill']),
209
+ skillRendering: Object.freeze({ mode: 'substitute', preamble: 'codex', placeholder: '${CLAUDE_PLUGIN_ROOT}' }),
210
+ buildAdapter: Object.freeze({
211
+ pluginDirName: '.codex-plugin',
212
+ templateFileName: 'plugin.json',
213
+ marketplaceFileName: null,
214
+ hasMarketplace: false,
215
+ }),
216
+ strategy: Object.freeze({
217
+ parseListOutput: codexParseListOutput,
218
+ extractInstallPath: codexExtractInstallPath,
219
+ extractListIdentity: codexExtractListIdentity,
220
+ crossValidateListEntry: codexCrossValidateListEntry,
221
+ buildManualRequirement: null,
222
+ readManifest: null,
223
+ }),
224
+ });
225
+
226
+ const KIMI = Object.freeze({
227
+ id: 'kimi',
228
+ distributionType: 'kimi-plugin',
229
+ actionType: 'kimi-marketplace-install',
230
+ adapter: 'plugin-marketplace',
231
+ automatable: false,
232
+ cli: null,
233
+ jsonProtocol: Object.freeze({
234
+ listOutput: null,
235
+ installPathSource: null,
236
+ marketplaceAddOutput: null,
237
+ pluginInstallOutput: null,
238
+ }),
239
+ isolationEnv: (home) => ({ HOME: home, KIMI_CODE_HOME: home }),
240
+ // kimi never execs a CLI; its stable home is created by the
241
+ // manual-requirement strategy under the attestation authority.
242
+ isolationSubdirs: Object.freeze([]),
243
+ manifestPaths: Object.freeze({
244
+ // kimi.plugin.json takes precedence over .kimi-plugin/plugin.json
245
+ // (strategy.readManifest / readKimiManifest in ./kimi.mjs).
246
+ pluginCandidates: KIMI_MANIFEST_CANDIDATES,
247
+ marketplace: null,
248
+ }),
249
+ marketplaceSourceForm: null,
250
+ // kimi has no marketplace at all, so the entry-version binding does not
251
+ // apply (null = N/A, never consulted: kimi preflight reads its manifest via
252
+ // strategy.readManifest).
253
+ marketplaceEntryCarriesVersion: null,
254
+ knownHostArtifacts: Object.freeze(['.git']),
255
+ schemaRequiredFields: Object.freeze(['plugin', 'entrySkill']),
256
+ skillRendering: Object.freeze({ mode: 'substitute', preamble: 'kimi', placeholder: '${CLAUDE_PLUGIN_ROOT}' }),
257
+ buildAdapter: Object.freeze({
258
+ pluginDirName: '.kimi-plugin',
259
+ templateFileName: 'plugin.json',
260
+ marketplaceFileName: null,
261
+ hasMarketplace: false,
262
+ }),
263
+ strategy: Object.freeze({
264
+ parseListOutput: null,
265
+ extractInstallPath: null,
266
+ extractListIdentity: null,
267
+ crossValidateListEntry: null,
268
+ // kimi's side-effecting manual-requirement builder and manifest reader
269
+ // live in ./kimi.mjs (§4.2 per-platform-module option).
270
+ buildManualRequirement: executeKimiManualRequirement,
271
+ readManifest: readKimiManifest,
272
+ }),
273
+ });
274
+
275
+ /** Ordered platform registry. Order is significant (claude, codex, kimi). */
276
+ export const PLATFORMS = Object.freeze([CLAUDE, CODEX, KIMI]);
277
+
278
+ const VALID_DISTRIBUTION_TYPES = new Set(['claude-plugin', 'codex-plugin', 'kimi-plugin']);
279
+ const VALID_ACTION_TYPES = new Set(['claude-marketplace-install', 'codex-marketplace-install', 'kimi-marketplace-install']);
280
+ const VALID_SOURCE_FORMS = new Set(['string', 'local-path-object', null]);
281
+ const VALID_LIST_OUTPUTS = new Set(['array', 'installed-object', null]);
282
+ const VALID_CLI_OUTPUTS = new Set(['json', null]);
283
+ const VALID_ENTRY_VERSION_BINDING = new Set([true, false, null]);
284
+
285
+ /**
286
+ * Look up a platform descriptor by id.
287
+ *
288
+ * @param {string} id
289
+ * @returns {object}
290
+ */
291
+ export function getPlatform(id) {
292
+ const platform = PLATFORMS.find((p) => p.id === id);
293
+ if (!platform) throw new Error(`unknown platform: ${id}`);
294
+ return platform;
295
+ }
296
+
297
+ /**
298
+ * Validate a registry (defaults to the module registry). Throws on the first
299
+ * malformed description so a registry typo fails at import time.
300
+ *
301
+ * @param {object[]} [registry] - Platform descriptors to validate.
302
+ */
303
+ export function assertRegistry(registry = PLATFORMS) {
304
+ const seen = new Set();
305
+ for (const platform of registry) {
306
+ const label = platform?.id ?? '<missing id>';
307
+ if (typeof platform?.id !== 'string' || platform.id.length === 0) {
308
+ throw new Error('platform registry: every platform needs a string id');
309
+ }
310
+ if (seen.has(platform.id)) {
311
+ throw new Error(`platform registry: ids must be unique, found duplicate "${platform.id}"`);
312
+ }
313
+ seen.add(platform.id);
314
+
315
+ if (!VALID_DISTRIBUTION_TYPES.has(platform.distributionType)) {
316
+ throw new Error(`platform registry: ${label} has illegal distributionType "${platform.distributionType}"`);
317
+ }
318
+ if (!VALID_ACTION_TYPES.has(platform.actionType)) {
319
+ throw new Error(`platform registry: ${label} has illegal actionType "${platform.actionType}"`);
320
+ }
321
+ if (typeof platform.adapter !== 'string' || platform.adapter.length === 0) {
322
+ throw new Error(`platform registry: ${label} needs a non-empty adapter id`);
323
+ }
324
+ if (!VALID_ENTRY_VERSION_BINDING.has(platform.marketplaceEntryCarriesVersion)) {
325
+ throw new Error(`platform registry: ${label} has illegal marketplaceEntryCarriesVersion "${platform.marketplaceEntryCarriesVersion}"`);
326
+ }
327
+ if (typeof platform.automatable !== 'boolean') {
328
+ throw new Error(`platform registry: ${label} automatable must be boolean`);
329
+ }
330
+ if (typeof platform.isolationEnv !== 'function') {
331
+ throw new Error(`platform registry: ${label} isolationEnv must be a function`);
332
+ }
333
+ if (!VALID_SOURCE_FORMS.has(platform.marketplaceSourceForm)) {
334
+ throw new Error(`platform registry: ${label} has illegal marketplaceSourceForm "${platform.marketplaceSourceForm}"`);
335
+ }
336
+ if (!Array.isArray(platform.knownHostArtifacts)) {
337
+ throw new Error(`platform registry: ${label} knownHostArtifacts must be an array`);
338
+ }
339
+ if (!Array.isArray(platform.schemaRequiredFields)) {
340
+ throw new Error(`platform registry: ${label} schemaRequiredFields must be an array`);
341
+ }
342
+ if (!platform.manifestPaths || typeof platform.manifestPaths !== 'object') {
343
+ throw new Error(`platform registry: ${label} manifestPaths must be an object`);
344
+ }
345
+ if (!platform.buildAdapter || typeof platform.buildAdapter !== 'object') {
346
+ throw new Error(`platform registry: ${label} buildAdapter must be an object`);
347
+ }
348
+ if (!VALID_LIST_OUTPUTS.has(platform.jsonProtocol?.listOutput)) {
349
+ throw new Error(`platform registry: ${label} has illegal jsonProtocol.listOutput "${platform.jsonProtocol?.listOutput}"`);
350
+ }
351
+ if (!VALID_CLI_OUTPUTS.has(platform.jsonProtocol?.marketplaceAddOutput)) {
352
+ throw new Error(`platform registry: ${label} has illegal jsonProtocol.marketplaceAddOutput "${platform.jsonProtocol?.marketplaceAddOutput}"`);
353
+ }
354
+ if (!VALID_CLI_OUTPUTS.has(platform.jsonProtocol?.pluginInstallOutput)) {
355
+ throw new Error(`platform registry: ${label} has illegal jsonProtocol.pluginInstallOutput "${platform.jsonProtocol?.pluginInstallOutput}"`);
356
+ }
357
+ if (!Array.isArray(platform.isolationSubdirs)) {
358
+ throw new Error(`platform registry: ${label} isolationSubdirs must be an array`);
359
+ }
360
+
361
+ if (platform.automatable) {
362
+ if (!platform.cli || typeof platform.cli.marketplaceAdd !== 'function'
363
+ || typeof platform.cli.install !== 'function' || typeof platform.cli.list !== 'function') {
364
+ throw new Error(`platform registry: automatable platform ${label} needs cli template functions`);
365
+ }
366
+ if (typeof platform.cli.binary !== 'string' || platform.cli.binary.length === 0) {
367
+ throw new Error(`platform registry: automatable platform ${label} needs a non-empty cli.binary executable name`);
368
+ }
369
+ if (!platform.strategy || typeof platform.strategy.parseListOutput !== 'function') {
370
+ throw new Error(`platform registry: automatable platform ${label} needs strategy.parseListOutput`);
371
+ }
372
+ if (typeof platform.strategy.extractInstallPath !== 'function') {
373
+ throw new Error(`platform registry: automatable platform ${label} needs strategy.extractInstallPath`);
374
+ }
375
+ } else {
376
+ if (platform.cli !== null) {
377
+ throw new Error(`platform registry: non-automatable platform ${label} must have cli === null`);
378
+ }
379
+ // The manual-requirement path IS the execute for a non-automatable
380
+ // platform; without it (and its manifest reader) the checkpoint has no
381
+ // execute behaviour at all.
382
+ if (!platform.strategy || typeof platform.strategy.buildManualRequirement !== 'function') {
383
+ throw new Error(`platform registry: non-automatable platform ${label} needs strategy.buildManualRequirement`);
384
+ }
385
+ if (typeof platform.strategy.readManifest !== 'function') {
386
+ throw new Error(`platform registry: non-automatable platform ${label} needs strategy.readManifest`);
387
+ }
388
+ }
389
+ }
390
+ }
391
+
392
+ // Self-validate at import time: a malformed registry never reaches runtime.
393
+ assertRegistry();
@@ -22,6 +22,7 @@ import { join, dirname, relative, isAbsolute, sep, resolve, posix as pathPosix }
22
22
  import { createHash } from 'node:crypto';
23
23
 
24
24
  import { canonicalJson, sha256Hex } from '../core/digest.mjs';
25
+ import { PLATFORMS as PLATFORM_REGISTRY } from '../platforms/registry.mjs';
25
26
 
26
27
  /**
27
28
  * Assert that a resolved real path is contained within the allowed root.
@@ -190,28 +191,19 @@ export function computeBuildAdaptersDigest(sourceBytes) {
190
191
  return `sha256:${h.digest('hex')}`;
191
192
  }
192
193
 
193
- // Platform definitions
194
- const PLATFORMS = [
195
- {
196
- name: 'claude',
197
- pluginDirName: '.claude-plugin',
198
- templateFileName: 'plugin.json',
199
- marketplaceFileName: 'marketplace.json',
200
- hasMarketplace: true,
201
- },
202
- {
203
- name: 'codex',
204
- pluginDirName: '.codex-plugin',
205
- templateFileName: 'plugin.json',
206
- hasMarketplace: false,
207
- },
208
- {
209
- name: 'kimi',
210
- pluginDirName: '.kimi-plugin',
211
- templateFileName: 'plugin.json',
212
- hasMarketplace: false,
213
- },
214
- ];
194
+ // Platform definitions, derived from the platform registry (the single source
195
+ // of platform knowledge). The build shape mirrors the legacy inline table
196
+ // byte-for-byte: marketplaceFileName is present only when the platform
197
+ // co-locates a marketplace manifest in its plugin dir (claude).
198
+ export const PLATFORMS = PLATFORM_REGISTRY.map((platform) => ({
199
+ name: platform.id,
200
+ pluginDirName: platform.buildAdapter.pluginDirName,
201
+ templateFileName: platform.buildAdapter.templateFileName,
202
+ ...(platform.buildAdapter.hasMarketplace
203
+ ? { marketplaceFileName: platform.buildAdapter.marketplaceFileName }
204
+ : {}),
205
+ hasMarketplace: platform.buildAdapter.hasMarketplace,
206
+ }));
215
207
 
216
208
  const REQUIRED_SCHEMA_FILES = Object.freeze([
217
209
  'approval-record.schema.json',
@@ -83,9 +83,14 @@ async function readStableRegularFile(filePath, displayPath) {
83
83
  *
84
84
  * `excludeRootEntries` is reserved for consumer-owned transport metadata
85
85
  * that is not part of the published payload: Codex's root `.git` checkout
86
- * metadata and Claude's root `.in_use` in-use plugin marker. Exclusions
87
- * only apply to direct children of the root; all payload paths retain the
88
- * normal fail-closed file checks.
86
+ * metadata, Codex's CLI-generated `.codex-plugin/migrated-command-skills/`
87
+ * subtree, and Claude's root `.in_use` in-use plugin marker. Single-segment
88
+ * entries only match direct children of the root (historical behavior);
89
+ * multi-segment entries name an exact relative path whose subtree is
90
+ * skipped. Every other payload path retains the normal fail-closed file
91
+ * checks — the exemption must never widen to whole directories or
92
+ * arbitrary extra files. Malformed entries (absolute, "..", backslash)
93
+ * fail closed: a bad exclusion list is a caller bug, not transport metadata.
89
94
  */
90
95
  export async function computeFrozenSnapshot(snapshotDir, { excludeRootEntries = [] } = {}) {
91
96
  const root = await realpath(snapshotDir);
@@ -95,14 +100,33 @@ export async function computeFrozenSnapshot(snapshotDir, { excludeRootEntries =
95
100
  }
96
101
 
97
102
  const entries = [];
98
- const excluded = new Set(excludeRootEntries);
103
+ const excludedRootNames = new Set();
104
+ const excludedPathPrefixes = [];
105
+ for (const entry of excludeRootEntries) {
106
+ if (typeof entry !== 'string') {
107
+ throw frozenError('frozen snapshot exclusion entries must be strings');
108
+ }
109
+ const segments = entry.split('/').filter((segment) => segment !== '' && segment !== '.');
110
+ if (
111
+ segments.length === 0 || entry.startsWith('/') || entry.includes('\\') ||
112
+ segments.some((segment) => segment === '..')
113
+ ) {
114
+ throw frozenError(`frozen snapshot exclusion entry is not a safe relative path: ${JSON.stringify(entry)}`);
115
+ }
116
+ if (segments.length === 1) {
117
+ excludedRootNames.add(segments[0]);
118
+ } else {
119
+ excludedPathPrefixes.push(segments.join('/'));
120
+ }
121
+ }
99
122
  async function walk(dir) {
100
123
  const children = await readdir(dir, { withFileTypes: true });
101
124
  children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
102
125
  for (const child of children) {
103
- if (dir === root && excluded.has(child.name)) continue;
126
+ if (dir === root && excludedRootNames.has(child.name)) continue;
104
127
  const absolute = join(dir, child.name);
105
128
  const rel = relative(root, absolute).split('\\').join('/');
129
+ if (excludedPathPrefixes.some((prefix) => rel === prefix || rel.startsWith(`${prefix}/`))) continue;
106
130
  const st = await lstat(absolute);
107
131
  if (st.isSymbolicLink()) {
108
132
  throw frozenError(`frozen snapshot contains symlink: ${rel}`);