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
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { execFile as execFileCb } from 'node:child_process';
16
16
  import { promisify } from 'node:util';
17
- import { readFile, stat, mkdir, writeFile, rename, readdir, rm, realpath, lstat } from 'node:fs/promises';
17
+ import { readFile, stat, mkdir, readdir, realpath, lstat } from 'node:fs/promises';
18
18
  import { join, resolve, relative, isAbsolute, basename } from 'node:path';
19
19
 
20
20
  import {
@@ -24,12 +24,23 @@ import {
24
24
  assertWritesAuthorized,
25
25
  assertIsolatedConsumerWritesAuthorized,
26
26
  matchObservation,
27
+ resolveTimeoutMs,
28
+ SAFE_ID_RE,
29
+ writeEvidenceAtomic,
27
30
  } from './contract.mjs';
28
31
 
29
32
  import { createHash } from 'node:crypto';
30
33
  import { computeFrozenSnapshot, resolveFrozenPath } from '../snapshot/frozen.mjs';
31
- import { computePlanDigest } from '../core/plan.mjs';
32
- import { canonicalJson } from '../core/digest.mjs';
34
+ import { PLATFORMS, getPlatform } from '../platforms/registry.mjs';
35
+ import {
36
+ KIMI_REQUIREMENT_FILE,
37
+ KIMI_ATTESTATION_FILE,
38
+ KIMI_MANAGED_SUBPATH,
39
+ resolveBoundPlanDigest,
40
+ kimiAuthorityDir,
41
+ validateKimiAttestation,
42
+ readKimiManifest,
43
+ } from '../platforms/kimi.mjs';
33
44
 
34
45
  const execFile = promisify(execFileCb);
35
46
 
@@ -48,16 +59,26 @@ function transportPayload(entries) {
48
59
  }));
49
60
  }
50
61
 
62
+ /**
63
+ * Payload verification contract marker written into marketplace install
64
+ * action parameters by prepare. Plans declaring this contract verify the
65
+ * installed payload by declared-manifest containment (every authority file
66
+ * present and byte-identical; host-added files recorded, not failed).
67
+ * Actions without the marker keep the legacy full-tree equality semantics.
68
+ */
69
+ const PAYLOAD_CONTRACT_DECLARED_MANIFEST = 'declared-manifest-v1';
70
+ /** Audit cap: at most this many extra installed paths are recorded. */
71
+ const EXTRA_INSTALLED_PATHS_CAP = 200;
72
+ /** Diagnostic cap: at most this many conflict paths are listed per error. */
73
+ const PAYLOAD_CONFLICT_REPORT_CAP = 10;
74
+
51
75
  // Consumer-owned transport metadata written into the plugin install root
52
- // that is not part of the published payload. Codex checks out the
53
- // repository (root `.git` metadata); Claude marks in-use plugin checkouts
54
- // with an empty root `.in_use` marker. Exclusions apply to root entries
55
- // only; all payload paths keep the fail-closed file checks.
56
- function consumerTransportExclusions(consumer) {
57
- if (consumer === 'claude') return ['.in_use'];
58
- if (consumer === 'codex' || consumer === 'kimi') return ['.git'];
59
- return [];
60
- }
76
+ // that is not part of the published payload (e.g. codex's root `.git`
77
+ // checkout and `.codex-plugin/migrated-command-skills/`, claude's `.in_use`
78
+ // marker) lives in each platform's `knownHostArtifacts` registry data. Only
79
+ // the legacy payload path (frozen plans without a `payloadContract` marker)
80
+ // applies that list; declared-manifest-v1 verification never excludes
81
+ // anything host-added files are recorded as `extraInstalledPaths` instead.
61
82
 
62
83
  /**
63
84
  * Extract the marketplace plugin entry's declared source as a validated,
@@ -70,11 +91,16 @@ function consumerTransportExclusions(consumer) {
70
91
  * slashes. Throws with the preflight's exact error messages.
71
92
  */
72
93
  function extractDeclaredPluginSource(consumer, entry) {
73
- const rawSource = consumer === 'claude'
94
+ const platform = getPlatform(consumer);
95
+ // Source form is registry data: claude declares a plain string, codex an
96
+ // {source:"local",path} object; any other form has no raw source.
97
+ const rawSource = platform.marketplaceSourceForm === 'string'
74
98
  ? entry.source
75
- : entry.source?.source === 'local' ? entry.source?.path : null;
99
+ : platform.marketplaceSourceForm === 'local-path-object'
100
+ ? (entry.source?.source === 'local' ? entry.source?.path : null)
101
+ : null;
76
102
  if (typeof rawSource !== 'string' || rawSource.length === 0) {
77
- throw new Error(`marketplace plugin entry source must be a non-empty relative path${consumer === 'codex' ? ' (object with source:"local")' : ''}, got ${JSON.stringify(entry.source)}`);
103
+ throw new Error(`marketplace plugin entry source must be a non-empty relative path${platform.marketplaceSourceForm === 'local-path-object' ? ' (object with source:"local")' : ''}, got ${JSON.stringify(entry.source)}`);
78
104
  }
79
105
  if (
80
106
  rawSource.startsWith('/') ||
@@ -106,10 +132,10 @@ function extractDeclaredPluginSource(consumer, entry) {
106
132
  * tampering with it fails the snapshot digest revalidation first.
107
133
  */
108
134
  async function resolveInstalledPayloadSubpath(snapshotDir, sourceEntries, action, consumer) {
109
- if (consumer === 'kimi') return '.';
110
- const marketplaceRelative = consumer === 'claude'
111
- ? '.claude-plugin/marketplace.json'
112
- : '.agents/plugins/marketplace.json';
135
+ // Platforms without a marketplace manifest (kimi) install the whole
136
+ // snapshot as the payload.
137
+ const marketplaceRelative = getPlatform(consumer).manifestPaths.marketplace;
138
+ if (marketplaceRelative === null) return '.';
113
139
  // Anchor the manifest read to the digest-verified entry walk: the target
114
140
  // must be one of the regular files that already passed the fail-closed
115
141
  // read checks (O_NOFOLLOW, single link, before/after stat stability).
@@ -168,8 +194,72 @@ async function verifyInstalledMarketplacePayload(action, context, installPath, c
168
194
  if (authorityEntries.length === 0) {
169
195
  throw new Error('frozen snapshot contains no payload under the declared marketplace source');
170
196
  }
197
+ // Contract selection is bound by the frozen plan: prepare writes
198
+ // `payloadContract` into marketplace install action parameters; frozen
199
+ // plans without the marker keep the legacy full-tree equality semantics
200
+ // byte-for-byte (including the consumer transport exclusion list).
201
+ const payloadContract = action.payloadContract;
202
+ if (payloadContract !== undefined && payloadContract !== PAYLOAD_CONTRACT_DECLARED_MANIFEST) {
203
+ throw new Error(`unsupported marketplace payload contract: ${JSON.stringify(payloadContract)}`);
204
+ }
205
+ if (payloadContract === PAYLOAD_CONTRACT_DECLARED_MANIFEST) {
206
+ // declared-manifest-v1: every authority entry must exist in the installed
207
+ // payload and agree in type/size/bytes/non-write mode bits. Host-added
208
+ // files are NOT failures — they are recorded (relative paths, capped) as
209
+ // audit evidence so host evolution can never break a release, while any
210
+ // missing or altered declared file still fails closed.
211
+ const installedSnapshot = await computeFrozenSnapshot(installPath);
212
+ const authorityPayload = transportPayload(authorityEntries);
213
+ const installedByPath = new Map(
214
+ transportPayload(installedSnapshot.entries).map((entry) => [entry.path, entry]),
215
+ );
216
+ const conflicts = [];
217
+ for (const authorityEntry of authorityPayload) {
218
+ const installedEntry = installedByPath.get(authorityEntry.path);
219
+ if (!installedEntry) {
220
+ conflicts.push(`missing: ${authorityEntry.path}`);
221
+ continue;
222
+ }
223
+ if (installedEntry.type !== authorityEntry.type) {
224
+ conflicts.push(`type mismatch: ${authorityEntry.path}`);
225
+ } else if (
226
+ installedEntry.size !== authorityEntry.size ||
227
+ installedEntry.contentDigest !== authorityEntry.contentDigest
228
+ ) {
229
+ conflicts.push(`content mismatch: ${authorityEntry.path}`);
230
+ } else if (installedEntry.mode !== authorityEntry.mode) {
231
+ conflicts.push(`mode mismatch: ${authorityEntry.path}`);
232
+ }
233
+ }
234
+ if (conflicts.length > 0) {
235
+ const listed = conflicts.slice(0, PAYLOAD_CONFLICT_REPORT_CAP).join('; ');
236
+ const overflow = conflicts.length > PAYLOAD_CONFLICT_REPORT_CAP
237
+ ? `; and ${conflicts.length - PAYLOAD_CONFLICT_REPORT_CAP} more conflicting path(s)`
238
+ : '';
239
+ throw new Error(
240
+ `installed marketplace payload differs in path, bytes, size, or non-write mode bits (${PAYLOAD_CONTRACT_DECLARED_MANIFEST}): ${listed}${overflow}`,
241
+ );
242
+ }
243
+ const authorityPaths = new Set(authorityPayload.map((entry) => entry.path));
244
+ const extraPaths = installedSnapshot.entries
245
+ .map((entry) => entry.path)
246
+ .filter((path) => !authorityPaths.has(path));
247
+ const extraInstalledPaths = extraPaths.slice(0, EXTRA_INSTALLED_PATHS_CAP);
248
+ // This is not an expected-value backfill: the sealed authority digest was
249
+ // revalidated above and every declared file was independently compared.
250
+ return {
251
+ manifestDigest: action.manifestDigest,
252
+ extraInstalledPaths,
253
+ ...(extraPaths.length > EXTRA_INSTALLED_PATHS_CAP
254
+ ? { extraInstalledPathsTotal: extraPaths.length }
255
+ : {}),
256
+ };
257
+ }
258
+ // Legacy contract (frozen plans without payloadContract): full-tree
259
+ // equality against the consumer-exclusion-filtered install tree. Behavior
260
+ // is frozen byte-for-byte; do not change.
171
261
  const installedSnapshot = await computeFrozenSnapshot(installPath, {
172
- excludeRootEntries: consumerTransportExclusions(consumer),
262
+ excludeRootEntries: getPlatform(consumer).knownHostArtifacts,
173
263
  });
174
264
  if (
175
265
  JSON.stringify(transportPayload(authorityEntries))
@@ -177,222 +267,29 @@ async function verifyInstalledMarketplacePayload(action, context, installPath, c
177
267
  ) {
178
268
  throw new Error('installed marketplace payload differs in path, bytes, size, or non-write mode bits');
179
269
  }
180
- // This is not an expected-value backfill: the sealed authority digest was
181
- // revalidated above and the installed payload was independently compared.
182
- return action.manifestDigest;
183
- }
184
-
185
- async function writeEvidenceAtomic(filePath, value) {
186
- const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
187
- try {
188
- await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
189
- await rename(tempPath, filePath);
190
- } catch (err) {
191
- await rm(tempPath, { force: true }).catch(() => {});
192
- throw err;
193
- }
194
- }
195
-
196
- // ---------------------------------------------------------------------------
197
- // Kimi Code protocol-gap modeling (BLOCKER-1 / MAJOR-1 / MAJOR-4 / MINOR-1).
198
- //
199
- // Kimi Code has NO scriptable plugin install/list CLI: plugin management is
200
- // interactive-only (`/plugins install <path-or-url>` in the TUI). There is no
201
- // `kimi plugins ...` subcommand and no `--json` output protocol. Therefore the
202
- // kimi-marketplace-install action is modeled as a protocol capability gap:
203
- //
204
- // - execute NEVER execs a kimi CLI. It emits an actionable, version-pinned
205
- // manual-install requirement bound to the frozen plan digest + identity.
206
- // - observe consumes a structured human attestation (written after the
207
- // operator runs the interactive install) plus read-only verification of
208
- // the installed managed copy. Missing/expired/mismatched/escaping proof
209
- // fails closed, so a kimi unit can never reach VERIFIED without it.
210
- // ---------------------------------------------------------------------------
211
-
212
- /** Structured manual-install requirement written by kimi execute. */
213
- const KIMI_REQUIREMENT_FILE = 'release-skill-kimi-manual-install.json';
214
- /** Structured human attestation consumed by kimi observe. */
215
- const KIMI_ATTESTATION_FILE = 'release-skill-kimi-attestation.json';
216
- /** Kimi Code managed install layout: $KIMI_CODE_HOME/plugins/managed/<id>/. */
217
- const KIMI_MANAGED_SUBPATH = join('plugins', 'managed');
218
- /** Maximum attestation validity window (mirrors the 24h approval expiry). */
219
- const KIMI_MAX_ATTESTATION_VALIDITY_MS = 24 * 60 * 60 * 1000;
220
-
221
- /** 64-char lowercase hex plan/payload digest pattern. */
222
- const HEX_DIGEST_RE = /^[a-f0-9]{64}$/;
223
-
224
- /**
225
- * Normalize a plan back to its frozen form for digest comparison.
226
- *
227
- * Only lifecycle status fields are reset: the top-level `status` returns to
228
- * "PREPARED" and every `externalActions[].status` returns to "PENDING". Every
229
- * other field is preserved verbatim. publish/reconcile/verify mutate exactly
230
- * these status fields in memory as the saga progresses, so normalizing them
231
- * recovers the frozen digest while leaving all security-relevant fields
232
- * (baseline, units, action parameters/expected, production config, …) intact.
233
- *
234
- * @param {object} plan
235
- * @returns {object} the lifecycle-normalized plan
236
- */
237
- function normalizePlanForDigest(plan) {
238
- const normalized = { ...plan, status: 'PREPARED' };
239
- if (Array.isArray(plan.externalActions)) {
240
- normalized.externalActions = plan.externalActions.map((action) => (
241
- action && typeof action === 'object' && !Array.isArray(action)
242
- ? { ...action, status: 'PENDING' }
243
- : action
244
- ));
245
- }
246
- return normalized;
247
- }
248
-
249
- /**
250
- * Resolve and verify the genuine frozen plan digest from the adapter context.
251
- *
252
- * The kimi manual-install requirement and attestation bind to the REAL frozen
253
- * plan digest (`context.plan.digest`) — never to `action.manifestDigest`, which
254
- * is only the snapshot payload digest.
255
- *
256
- * Integrity model: the carried `context.plan.digest` is recomputed from the
257
- * lifecycle-normalized plan and must match EXACTLY. Status transitions
258
- * (top-level status, per-action checkpoint status) are normalized away, but any
259
- * other field tamper changes the recomputed digest and fails closed. This
260
- * proves the attestation is bound to the genuine frozen plan, not to a spoofed
261
- * or mutated stand-in.
262
- *
263
- * @param {object} context - adapter context (must carry the frozen `plan`).
264
- * @returns {string} the verified frozen plan digest.
265
- * @throws {Error} when the plan is absent or the digest does not match.
266
- */
267
- function resolveBoundPlanDigest(context) {
268
- const plan = context?.plan;
269
- if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
270
- throw new Error('context.plan is required to bind the kimi plan digest');
271
- }
272
- const carried = plan.digest;
273
- if (typeof carried !== 'string' || !HEX_DIGEST_RE.test(carried)) {
274
- throw new Error('context.plan.digest must be a 64-char lowercase hex frozen plan digest');
275
- }
276
- const normalized = normalizePlanForDigest(plan);
277
- if (computePlanDigest(normalized) !== carried) {
278
- throw new Error('context.plan.digest does not match the normalized frozen plan (a non-lifecycle field was tampered)');
279
- }
280
- return carried;
281
- }
282
-
283
- /**
284
- * Authoritative, cross-run attestation directory for a kimi install.
285
- *
286
- * Lives at a stable root-fixed location keyed by the verified frozen plan
287
- * digest and plugin id:
288
- * <root>/.release-skill/kimi-attestations/<planDigest>/<plugin>/
289
- *
290
- * This survives the publish -> manual install -> reconcile -> verify chain,
291
- * where each command otherwise uses a fresh runDir (an attestation written to a
292
- * publish runDir would be invisible to reconcile/verify). Both the requirement
293
- * and the human attestation live here. The segments are pre-validated (planDigest
294
- * is 64-hex, plugin matches SAFE_ID_RE) and the resolved path is contained
295
- * within the authority base, so no path escape is possible.
296
- *
297
- * @param {object} context - adapter context (needs `root`).
298
- * @param {string} planDigest - verified frozen plan digest (64-hex).
299
- * @param {string} plugin - plugin id (SAFE_ID_RE).
300
- * @returns {string} absolute authority directory.
301
- */
302
- function kimiAuthorityDir(context, planDigest, plugin) {
303
- if (!context?.root) {
304
- throw new Error('context.root is required for the kimi attestation authority');
305
- }
306
- if (!HEX_DIGEST_RE.test(planDigest)) {
307
- throw new Error('kimi attestation authority requires a 64-hex plan digest');
308
- }
309
- if (!SAFE_ID_RE.test(plugin)) {
310
- throw new Error(`kimi attestation authority requires a safe plugin id: "${plugin}"`);
311
- }
312
- const base = resolve(context.root, '.release-skill', 'kimi-attestations');
313
- const dir = resolve(base, planDigest, plugin);
314
- const rel = relative(base, dir);
315
- const sep = process.platform === 'win32' ? '\\' : '/';
316
- if (
317
- rel === '' || rel === '..' || isAbsolute(rel) || rel.startsWith(`..${sep}`)
318
- || rel.split(sep).some((segment) => segment === '..' || segment === '')
319
- ) {
320
- throw new Error('kimi attestation authority path escapes its base');
321
- }
322
- return dir;
270
+ return { manifestDigest: action.manifestDigest };
323
271
  }
324
272
 
325
273
  /**
326
- * Build the official, version-pinned install URL for a frozen Git ref.
327
- *
328
- * Prefers the GitHub release-tag URL (`/releases/tag/<ref>`), which pins the
329
- * exact published ref; `/tree/<ref>` is the documented equivalent. A bare
330
- * repository URL is NOT acceptable because it installs the latest release (or
331
- * default branch), which need not equal the frozen version.
332
- *
333
- * @param {string} repo - owner/repo
334
- * @param {string} ref - frozen Git ref (tag)
335
- * @returns {string}
274
+ * Build the audit fields recorded when a declared-manifest-v1 verification
275
+ * observes host-added files in the install tree. Empty for legacy bindings
276
+ * (and for failed bindings), so legacy observations stay byte-identical.
336
277
  */
337
- function buildKimiInstallUrl(repo, ref) {
338
- return `https://github.com/${repo}/releases/tag/${ref}`;
278
+ function extraInstalledPathsAudit(binding) {
279
+ if (!binding || !Array.isArray(binding.extraInstalledPaths)) return {};
280
+ return {
281
+ extraInstalledPaths: binding.extraInstalledPaths,
282
+ ...(binding.extraInstalledPathsTotal !== undefined
283
+ ? { extraInstalledPathsTotal: binding.extraInstalledPathsTotal }
284
+ : {}),
285
+ };
339
286
  }
340
287
 
341
- /**
342
- * Human-facing, actionable manual-install closed-loop instructions for Kimi Code.
343
- *
344
- * @param {{installUrl:string, plugin:string, version:string, ref:string, isolatedHome:string, attestationDir:string}} p
345
- * @returns {string[]}
346
- */
347
- function buildKimiManualInstructions({ installUrl, plugin, version, ref, isolatedHome, attestationDir }) {
348
- return [
349
- `Kimi Code has no scriptable plugin-install CLI; installation is a manual, interactive step.`,
350
- `1) publish fails closed at this kimi checkpoint and leaves the run PARTIAL (the automated Git branch/tag, npm, and GitHub Release writes still complete first).`,
351
- `2) Launch Kimi Code with the ISOLATED home from this requirement so the managed copy lands inside it: set HOME="${isolatedHome}" and KIMI_CODE_HOME="${isolatedHome}". The plugin installs to "${isolatedHome}/plugins/managed/${plugin}/".`,
352
- `3) In that isolated Kimi Code session run: /plugins install ${installUrl} (pinned to frozen ref "${ref}", version ${version}; never install the bare repository URL). Confirm the trust prompt for plugin "${plugin}", then run /plugins reload (or /new).`,
353
- `4) Write the attestation JSON to: ${attestationDir}/${KIMI_ATTESTATION_FILE}. planDigest MUST be the frozen plan digest; payloadDigest MUST be the frozen snapshot payload digest; installPath MUST be the isolated managed directory above. attestedAt must not be in the future and expiresAt must be within 24 hours of attestedAt.`,
354
- ` Required fields: consumer="kimi", plugin, version, entrySkill, repo, ref, installPath, planDigest, payloadDigest, attestedBy, attestedAt, expiresAt.`,
355
- `5) Re-run release-skill reconcile (promotes PARTIAL -> PUBLISHED) and then verify (-> VERIFIED). Both read the attestation from this same plan-digest-keyed authority directory, so a fresh run directory does not lose the proof.`,
356
- `An install into the ordinary ~/.kimi-code is NOT acceptable proof: the attested installPath must resolve inside this requirement's isolated KIMI_CODE_HOME managed root, otherwise verification fails closed.`,
357
- ];
358
- }
359
-
360
- /**
361
- * Read the authoritative Kimi plugin manifest from a verified plugin root.
362
- *
363
- * `kimi.plugin.json` at the root takes priority over `.kimi-plugin/plugin.json`
364
- * when both exist (official precedence). Returns the parsed manifest and the
365
- * root-relative manifest path. Throws when no valid manifest is present.
366
- *
367
- * @param {string} pluginRootReal - realpath of the verified plugin root.
368
- * @returns {Promise<{manifest:object, manifestRelative:string}>}
369
- */
370
- async function readKimiManifest(pluginRootReal) {
371
- const candidates = [
372
- 'kimi.plugin.json',
373
- join('.kimi-plugin', 'plugin.json'),
374
- ];
375
- for (const manifestRelative of candidates) {
376
- const manifestPath = resolve(pluginRootReal, manifestRelative);
377
- let content;
378
- try {
379
- content = await readFile(manifestPath, 'utf8');
380
- } catch {
381
- continue;
382
- }
383
- let manifest;
384
- try {
385
- manifest = JSON.parse(content);
386
- } catch {
387
- throw new Error(`kimi plugin manifest ${manifestRelative} is not valid JSON`);
388
- }
389
- if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
390
- throw new Error(`kimi plugin manifest ${manifestRelative} is not an object`);
391
- }
392
- return { manifest, manifestRelative };
393
- }
394
- throw new Error('no kimi plugin manifest found (expected kimi.plugin.json or .kimi-plugin/plugin.json)');
395
- }
288
+ // The kimi protocol closure (manual-install requirement, human attestation
289
+ // validation, manifest reading, shared constants) lives in
290
+ // ../platforms/kimi.mjs and is referenced from the platform registry's kimi
291
+ // strategy table. Shared adapter primitives (SAFE_ID_RE, resolveTimeoutMs,
292
+ // writeEvidenceAtomic) live in ./contract.mjs.
396
293
 
397
294
  /**
398
295
  * Validate that a manifest `skills` value is a safe plugin-root-relative path.
@@ -498,281 +395,6 @@ async function resolveKimiEntrySkillFile(pluginRootReal, manifest, entrySkill) {
498
395
  return entryReal;
499
396
  }
500
397
 
501
- /**
502
- * Validate a structured kimi manual-install attestation against the frozen
503
- * action and the verified frozen plan digest.
504
- *
505
- * Bindings (fail closed on any mismatch):
506
- * - `planDigest` binds to the REAL frozen plan digest (`boundPlanDigest`, from
507
- * `context.plan.digest`) — NOT to `action.manifestDigest`.
508
- * - `payloadDigest` binds separately to `action.manifestDigest` (the sealed
509
- * snapshot payload digest).
510
- * - plugin identity, version, entry skill, repo, and frozen ref must match.
511
- * - Time bounds: `attestedAt` must not be in the future, the validity window
512
- * (`expiresAt - attestedAt`) must not exceed 24h, and the attestation must
513
- * not be expired relative to `isoNow`.
514
- *
515
- * @param {object} attestation - parsed attestation JSON.
516
- * @param {object} action - the expanded kimi action (top-level fields).
517
- * @param {string} isoNow - current ISO timestamp.
518
- * @param {string} boundPlanDigest - verified frozen plan digest.
519
- * @returns {{valid:boolean, error:string|null}}
520
- */
521
- function validateKimiAttestation(attestation, action, isoNow, boundPlanDigest) {
522
- if (!attestation || typeof attestation !== 'object' || Array.isArray(attestation)) {
523
- return { valid: false, error: 'kimi attestation is not an object' };
524
- }
525
- const requiredStrings = ['plugin', 'version', 'entrySkill', 'repo', 'ref', 'installPath', 'payloadDigest', 'planDigest', 'attestedBy', 'attestedAt', 'expiresAt'];
526
- for (const field of requiredStrings) {
527
- if (typeof attestation[field] !== 'string' || attestation[field].length === 0) {
528
- return { valid: false, error: `kimi attestation missing required field "${field}"` };
529
- }
530
- }
531
- if (attestation.consumer !== 'kimi') {
532
- return { valid: false, error: `kimi attestation consumer "${attestation.consumer}" must be "kimi"` };
533
- }
534
- if (!HEX_DIGEST_RE.test(attestation.planDigest)) {
535
- return { valid: false, error: 'kimi attestation planDigest must be a 64-char lowercase hex digest' };
536
- }
537
- if (attestation.planDigest !== boundPlanDigest) {
538
- return { valid: false, error: 'kimi attestation planDigest does not match the frozen plan digest' };
539
- }
540
- if (attestation.plugin !== action.plugin) {
541
- return { valid: false, error: `kimi attestation plugin "${attestation.plugin}" does not match action plugin "${action.plugin}"` };
542
- }
543
- if (attestation.version !== action.version) {
544
- return { valid: false, error: `kimi attestation version "${attestation.version}" does not match action version "${action.version}"` };
545
- }
546
- if (attestation.entrySkill !== action.entrySkill) {
547
- return { valid: false, error: `kimi attestation entrySkill "${attestation.entrySkill}" does not match action entrySkill "${action.entrySkill}"` };
548
- }
549
- if (attestation.repo !== action.repo) {
550
- return { valid: false, error: `kimi attestation repo "${attestation.repo}" does not match action repo "${action.repo}"` };
551
- }
552
- const expectedRef = action.ref ?? `v${action.version}`;
553
- if (attestation.ref !== expectedRef) {
554
- return { valid: false, error: `kimi attestation ref "${attestation.ref}" does not match frozen ref "${expectedRef}"` };
555
- }
556
- if (attestation.payloadDigest !== action.manifestDigest) {
557
- return { valid: false, error: 'kimi attestation payloadDigest does not match the frozen payload digest' };
558
- }
559
- const attestedMs = Date.parse(attestation.attestedAt);
560
- const expiresMs = Date.parse(attestation.expiresAt);
561
- const nowMs = Date.parse(isoNow);
562
- if (!Number.isFinite(attestedMs) || !Number.isFinite(expiresMs) || !Number.isFinite(nowMs)) {
563
- return { valid: false, error: 'kimi attestation attestedAt/expiresAt must be valid ISO timestamps' };
564
- }
565
- if (attestedMs > nowMs) {
566
- return { valid: false, error: 'kimi attestation attestedAt is in the future' };
567
- }
568
- if (expiresMs <= attestedMs) {
569
- return { valid: false, error: 'kimi attestation expiresAt must be after attestedAt' };
570
- }
571
- if (expiresMs - attestedMs > KIMI_MAX_ATTESTATION_VALIDITY_MS) {
572
- return { valid: false, error: 'kimi attestation validity must not exceed 24 hours' };
573
- }
574
- if (nowMs > expiresMs) {
575
- return { valid: false, error: 'kimi attestation has expired' };
576
- }
577
- return { valid: true, error: null };
578
- }
579
-
580
- /**
581
- * Kimi Code protocol capability gap (BLOCKER-1): there is NO scriptable
582
- * `kimi plugins install/list` CLI and no `--json` protocol. execute NEVER execs
583
- * a kimi command. Instead it emits an actionable, version-pinned manual-install
584
- * requirement bound to the real frozen plan digest + identity, and leaves
585
- * success to observe, which consumes only a trusted human attestation plus
586
- * read-only verification. Without that proof the checkpoint fails closed and can
587
- * never reach VERIFIED.
588
- *
589
- * Isolation model (B/C): the kimi home is a STABLE, plan-digest-keyed directory
590
- * under the attestation authority (`<authorityDir>/kimi-home`), not the per-run
591
- * runDir consumer dir. The operator launches Kimi Code with that KIMI_CODE_HOME
592
- * so the managed copy lands at `<kimiHome>/plugins/managed/<plugin>/`, a
593
- * location that is identical across publish/reconcile/verify run dirs. execute
594
- * creates ONLY the managed parent (`plugins/managed`), never `managed/<plugin>`
595
- * (the operator's interactive install creates that). The requirement write is
596
- * idempotent: an identical existing requirement is left untouched, a divergent
597
- * one fails closed.
598
- *
599
- * @param {object} action - expanded kimi action (validated params already).
600
- * @param {object} context - adapter context (root, runDir, plan).
601
- * @returns {Promise<import('./contract.mjs').AdapterResult>}
602
- */
603
- async function executeKimiManualRequirement(action, context) {
604
- const actionType = ActionType.KIMI_MARKETPLACE_INSTALL;
605
-
606
- // (A) Bind to the REAL frozen plan digest via strict normalized recompute.
607
- let planDigest;
608
- try {
609
- planDigest = resolveBoundPlanDigest(context);
610
- } catch (planErr) {
611
- return createResult({
612
- actionType,
613
- status: ActionStatus.EXECUTE_FAILED,
614
- error: `cannot bind kimi requirement to the frozen plan: ${planErr.message}`,
615
- });
616
- }
617
-
618
- // Validate the frozen timeout. Kimi execs no CLI, but the frozen-timeout
619
- // fail-closed invariant still holds for every marketplace action.
620
- try {
621
- resolveTimeoutMs(action);
622
- } catch (timeoutErr) {
623
- return createResult({
624
- actionType,
625
- status: ActionStatus.EXECUTE_FAILED,
626
- error: timeoutErr.message,
627
- });
628
- }
629
-
630
- const ref = action.ref ?? `v${action.version}`;
631
- const installUrl = buildKimiInstallUrl(action.repo, ref);
632
-
633
- // (B) Stable, plan-digest-keyed authority dir — the ONLY kimi home, shared
634
- // across publish/reconcile/verify run dirs.
635
- let attestationDir;
636
- try {
637
- attestationDir = kimiAuthorityDir(context, planDigest, action.plugin);
638
- } catch (dirErr) {
639
- return createResult({
640
- actionType,
641
- status: ActionStatus.EXECUTE_FAILED,
642
- error: dirErr.message,
643
- });
644
- }
645
- const kimiHome = resolve(attestationDir, 'kimi-home');
646
- const managedParent = resolve(kimiHome, KIMI_MANAGED_SUBPATH); // plugins/managed
647
- // plugins/managed/<plugin> — created by the operator's interactive install.
648
- const managedInstallRoot = resolve(managedParent, action.plugin);
649
-
650
- const instructions = buildKimiManualInstructions({
651
- installUrl,
652
- plugin: action.plugin,
653
- version: action.version,
654
- ref,
655
- isolatedHome: kimiHome,
656
- attestationDir,
657
- });
658
-
659
- const requirement = {
660
- kind: 'kimi-manual-install-requirement',
661
- consumer: 'kimi',
662
- plugin: action.plugin,
663
- version: action.version,
664
- entrySkill: action.entrySkill,
665
- repo: action.repo,
666
- ref,
667
- installUrl,
668
- // (A) planDigest binds to the real frozen plan digest;
669
- // expectedPayloadDigest binds separately to the snapshot payload digest.
670
- planDigest,
671
- expectedPayloadDigest: action.manifestDigest,
672
- isolatedHome: kimiHome,
673
- kimiCodeHome: kimiHome,
674
- managedInstallRoot,
675
- attestationDir,
676
- attestationFile: KIMI_ATTESTATION_FILE,
677
- attestationTemplate: {
678
- consumer: 'kimi',
679
- plugin: action.plugin,
680
- version: action.version,
681
- entrySkill: action.entrySkill,
682
- repo: action.repo,
683
- ref,
684
- installPath: managedInstallRoot,
685
- planDigest,
686
- payloadDigest: action.manifestDigest,
687
- attestedBy: '<person responsible for the manual install>',
688
- attestedAt: '<ISO 8601 now; must not be in the future>',
689
- expiresAt: '<ISO 8601; within 24h of attestedAt>',
690
- },
691
- instructions,
692
- };
693
-
694
- // Create ONLY the managed parent (plugins/managed); never pre-create
695
- // managed/<plugin> — the operator's interactive install creates that.
696
- try {
697
- await mkdir(managedParent, { recursive: true, mode: 0o700 });
698
- } catch (mkdirErr) {
699
- return createResult({
700
- actionType,
701
- status: ActionStatus.EXECUTE_FAILED,
702
- error: `cannot create kimi managed parent directory: ${mkdirErr.message}`,
703
- });
704
- }
705
-
706
- // Idempotent requirement write: an identical existing requirement is left
707
- // untouched; a divergent existing requirement fails closed (never silently
708
- // overwritten). `createdAt` is volatile and excluded from the comparison.
709
- const requirementPath = resolve(attestationDir, KIMI_REQUIREMENT_FILE);
710
- let existing = null;
711
- let requirementMissing = false;
712
- try {
713
- const existingRaw = await readFile(requirementPath, 'utf8');
714
- try {
715
- existing = JSON.parse(existingRaw);
716
- } catch (parseErr) {
717
- return createResult({
718
- actionType,
719
- status: ActionStatus.EXECUTE_FAILED,
720
- error: `existing kimi manual-install requirement is invalid JSON; refusing to overwrite: ${parseErr.message}`,
721
- });
722
- }
723
- } catch (readErr) {
724
- if (readErr?.code === 'ENOENT') {
725
- requirementMissing = true;
726
- } else {
727
- return createResult({
728
- actionType,
729
- status: ActionStatus.EXECUTE_FAILED,
730
- error: `existing kimi manual-install requirement cannot be read; refusing to overwrite: ${readErr.message}`,
731
- });
732
- }
733
- }
734
- if (!requirementMissing) {
735
- if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
736
- return createResult({
737
- actionType,
738
- status: ActionStatus.EXECUTE_FAILED,
739
- error: 'existing kimi manual-install requirement is not an object; refusing to overwrite',
740
- });
741
- }
742
- const { createdAt: _existingCreatedAt, ...existingBody } = existing;
743
- if (canonicalJson(existingBody) !== canonicalJson(requirement)) {
744
- return createResult({
745
- actionType,
746
- status: ActionStatus.EXECUTE_FAILED,
747
- error: 'existing kimi manual-install requirement conflicts with the current frozen action; refusing to overwrite',
748
- });
749
- }
750
- } else {
751
- await writeEvidenceAtomic(requirementPath, { ...requirement, createdAt: new Date().toISOString() });
752
- }
753
-
754
- return createResult({
755
- actionType,
756
- status: ActionStatus.EXECUTED,
757
- observation: {
758
- installed: false,
759
- manualInstallRequired: true,
760
- consumer: 'kimi',
761
- plugin: action.plugin,
762
- version: action.version,
763
- entrySkill: action.entrySkill,
764
- repo: action.repo,
765
- ref,
766
- installUrl,
767
- planDigest,
768
- attestationDir,
769
- kimiCodeHome: kimiHome,
770
- managedInstallRoot,
771
- instructions,
772
- },
773
- });
774
- }
775
-
776
398
  const SUPPORTED_TYPES = [
777
399
  ActionType.PLUGIN_MANIFEST_VALIDATE,
778
400
  ActionType.PLUGIN_INSTALL_CHECK,
@@ -781,12 +403,12 @@ const SUPPORTED_TYPES = [
781
403
  ActionType.KIMI_MARKETPLACE_INSTALL,
782
404
  ];
783
405
 
784
- /** Safe identifier pattern: lowercase alphanumeric, hyphens, dots, underscores. */
785
- const SAFE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
786
-
787
406
  /** Safe repo pattern: owner/repo with alphanumeric, hyphens, dots, underscores. */
788
407
  const SAFE_REPO_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\/[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
789
408
 
409
+ /** Valid consumer ids, derived from the platform registry (single source). */
410
+ const CONSUMER_IDS = new Set(PLATFORMS.map((p) => p.id));
411
+
790
412
  /**
791
413
  * Strict semver pattern: supports prerelease and build metadata.
792
414
  * Matches: 1.0.0, 1.0.0-beta.1, 1.0.0-rc.1+build.123
@@ -856,7 +478,7 @@ function validateMarketplaceParams(params) {
856
478
  return { valid: false, error: 'parameters must be an object' };
857
479
  }
858
480
  const { consumer, plugin, marketplace, repo, version, entrySkill } = params;
859
- if (!['claude', 'codex', 'kimi'].includes(consumer)) {
481
+ if (!CONSUMER_IDS.has(consumer)) {
860
482
  return { valid: false, error: `invalid consumer: "${consumer}"` };
861
483
  }
862
484
  if (!plugin || !SAFE_ID_RE.test(plugin)) {
@@ -867,7 +489,7 @@ function validateMarketplaceParams(params) {
867
489
  // marketplace but NO non-interactive install API, so `marketplace` carries no
868
490
  // executable meaning for kimi and is optional (validated only if present); it
869
491
  // must not become a required identity condition for kimi execution/observe.
870
- if (consumer === 'kimi') {
492
+ if (!getPlatform(consumer).automatable) {
871
493
  if (marketplace !== undefined && marketplace !== null && !SAFE_ID_RE.test(marketplace)) {
872
494
  return { valid: false, error: `unsafe marketplace identifier: "${marketplace}"` };
873
495
  }
@@ -887,42 +509,6 @@ function validateMarketplaceParams(params) {
887
509
  }
888
510
 
889
511
 
890
- /**
891
- * Resolve and validate the frozen timeoutMs from the expanded adapter action.
892
- *
893
- * The publish/reconcile/verify call path expands plan actions as
894
- * `{ actionType, ...action.parameters }`, so `parameters.timeoutMs` in the
895
- * plan becomes `action.timeoutMs` at the adapter level. This function reads
896
- * from the top-level action, not from a nested `parameters` sub-object.
897
- *
898
- * Rules:
899
- * - Missing field (undefined): returns 300000 default (legacy compatibility).
900
- * - Present but null/invalid (null, string, NaN, Infinity, non-integer,
901
- * out of range): fail-closed, throws.
902
- * - Valid integer in [30000, 900000]: returns the value as-is.
903
- *
904
- * @param {object} action - The expanded adapter action (top-level).
905
- * @returns {number} Validated timeout in milliseconds.
906
- * @throws {Error} If the value is present but invalid.
907
- */
908
- function resolveTimeoutMs(action) {
909
- const raw = action?.timeoutMs;
910
- if (raw === undefined) {
911
- return 300000;
912
- }
913
- if (raw === null || typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw)) {
914
- throw new Error(
915
- `action.timeoutMs must be a finite integer, got: ${JSON.stringify(raw)}`,
916
- );
917
- }
918
- if (raw < 30000 || raw > 900000) {
919
- throw new Error(
920
- `action.timeoutMs must be between 30000 and 900000, got: ${raw}`,
921
- );
922
- }
923
- return raw;
924
- }
925
-
926
512
  /**
927
513
  * Run a CLI command using execFile (never shell: true).
928
514
  */
@@ -1151,6 +737,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1151
737
 
1152
738
  // 6. Verify frozen snapshot exists and contains required marketplace files
1153
739
  const consumer = action.consumer;
740
+ const platform = getPlatform(consumer);
1154
741
  let snapshotDirReal;
1155
742
  // Authoritative kimi manifest (from the frozen snapshot), used to
1156
743
  // resolve the entry skill via the manifest-declared skills root.
@@ -1165,14 +752,15 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1165
752
  });
1166
753
  }
1167
754
 
1168
- // Kimi has no non-interactive marketplace/install API: the whole repo
1169
- // is installed as one plugin. The authoritative manifest is read from
1170
- // the verified snapshot root with official precedence
755
+ // A non-automatable platform (kimi) has no non-interactive
756
+ // marketplace/install API: the whole repo is installed as one
757
+ // plugin. The authoritative manifest is read from the verified
758
+ // snapshot root via the platform strategy's official precedence
1171
759
  // (kimi.plugin.json over .kimi-plugin/plugin.json).
1172
- if (consumer === 'kimi') {
760
+ if (!platform.automatable) {
1173
761
  let kimiManifestResult;
1174
762
  try {
1175
- kimiManifestResult = await readKimiManifest(snapshotDirReal);
763
+ kimiManifestResult = await platform.strategy.readManifest(snapshotDirReal);
1176
764
  } catch (manifestErr) {
1177
765
  return createResult({
1178
766
  actionType,
@@ -1198,13 +786,11 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1198
786
  kimiSnapshotManifest = kimiManifest;
1199
787
  }
1200
788
 
1201
- if (consumer !== 'kimi') {
789
+ if (platform.automatable) {
1202
790
  // Verify marketplace files exist.
1203
791
  // marketplace.json is at the snapshot root; plugin manifest is
1204
792
  // resolved relative to the entry's declared source path.
1205
- const marketplaceRelative = consumer === 'claude'
1206
- ? '.claude-plugin/marketplace.json'
1207
- : '.agents/plugins/marketplace.json';
793
+ const marketplaceRelative = platform.manifestPaths.marketplace;
1208
794
 
1209
795
  const marketplacePath = resolve(snapshotDirReal, marketplaceRelative);
1210
796
 
@@ -1288,9 +874,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1288
874
  // snapshot/.claude-plugin/plugin.json
1289
875
  // For subdirectory layouts (source: "./adapters/claude"), this resolves to
1290
876
  // snapshot/adapters/claude/.claude-plugin/plugin.json
1291
- const manifestRelative = consumer === 'claude'
1292
- ? join(sourcePath, '.claude-plugin', 'plugin.json')
1293
- : join(sourcePath, '.codex-plugin', 'plugin.json');
877
+ const manifestRelative = join(sourcePath, platform.manifestPaths.plugin);
1294
878
  const manifestPath = resolve(snapshotDirReal, manifestRelative);
1295
879
 
1296
880
  const manifestResult = await validateManifestFile(manifestPath, ['name', 'version']);
@@ -1302,9 +886,12 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1302
886
  });
1303
887
  }
1304
888
 
1305
- // Claude carries the version in the marketplace entry. Codex keeps
1306
- // the authoritative version in .codex-plugin/plugin.json.
1307
- if (consumer === 'claude' && entry.version !== action.version) {
889
+ // Whether the marketplace entry itself carries the authoritative
890
+ // version is a platform protocol split (registry data): claude
891
+ // binds entry.version to the action version, codex keeps the
892
+ // authoritative version in .codex-plugin/plugin.json (the entry
893
+ // version is never bound), kimi has no marketplace.
894
+ if (platform.marketplaceEntryCarriesVersion && entry.version !== action.version) {
1308
895
  return createResult({
1309
896
  actionType,
1310
897
  status: ActionStatus.PREFLIGHT_FAILED,
@@ -1338,12 +925,13 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1338
925
  }
1339
926
 
1340
927
  // Verify the entry skill exists in the snapshot.
1341
- // Claude/Codex manifests always declare ./skills/, so the fixed
1342
- // skills/<entrySkill>/SKILL.md layout is authoritative for them.
1343
- // Kimi resolves the entry skill via the manifest-declared skills root
1344
- // (MAJOR-4): the root is validated + realpath-contained, and omitted
1345
- // `skills` means the official single-skill root SKILL.md.
1346
- if (consumer === 'kimi') {
928
+ // Automatable platforms' manifests always declare ./skills/, so the
929
+ // fixed skills/<entrySkill>/SKILL.md layout is authoritative for
930
+ // them. A non-automatable platform (kimi) resolves the entry skill
931
+ // via the manifest-declared skills root (MAJOR-4): the root is
932
+ // validated + realpath-contained, and omitted `skills` means the
933
+ // official single-skill root SKILL.md.
934
+ if (!platform.automatable) {
1347
935
  try {
1348
936
  await resolveKimiEntrySkillFile(snapshotDirReal, kimiSnapshotManifest, action.entrySkill);
1349
937
  } catch (entryErr) {
@@ -1542,13 +1130,15 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1542
1130
  });
1543
1131
  }
1544
1132
 
1545
- // Kimi has NO scriptable install CLI. It is handled entirely by the
1546
- // manual-requirement helper, which uses a stable plan-digest-keyed
1547
- // home and deliberately SKIPS the per-run isolated consumer dir and
1548
- // its runDir containment check (that model only fits claude/codex,
1549
- // which exec a real CLI into a per-run HOME).
1550
- if (action.consumer === 'kimi') {
1551
- return executeKimiManualRequirement(action, context);
1133
+ // A non-automatable platform (kimi) has NO scriptable install CLI.
1134
+ // It is handled entirely by its manual-requirement strategy, which
1135
+ // uses a stable plan-digest-keyed home and deliberately SKIPS the
1136
+ // per-run isolated consumer dir and its runDir containment check
1137
+ // (that model only fits automatable platforms, which exec a real
1138
+ // CLI into a per-run HOME).
1139
+ const platform = getPlatform(action.consumer);
1140
+ if (!platform.automatable) {
1141
+ return platform.strategy.buildManualRequirement(action, context);
1552
1142
  }
1553
1143
 
1554
1144
  const consumer = action.consumer;
@@ -1568,21 +1158,18 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1568
1158
  });
1569
1159
  }
1570
1160
 
1571
- // Create isolated HOME and required subdirectories
1161
+ // Create isolated HOME and the consumer state subdirectories the
1162
+ // registry declares for this platform.
1572
1163
  await mkdir(isolatedHome, { recursive: true, mode: 0o700 });
1573
- if (consumer === 'claude') {
1574
- await mkdir(resolve(isolatedHome, '.claude'), { recursive: true, mode: 0o700 });
1575
- } else {
1576
- await mkdir(resolve(isolatedHome, '.codex'), { recursive: true, mode: 0o700 });
1164
+ for (const subdir of platform.isolationSubdirs) {
1165
+ await mkdir(resolve(isolatedHome, subdir), { recursive: true, mode: 0o700 });
1577
1166
  }
1578
1167
 
1579
- const cliCmd = consumer === 'claude' ? 'claude' : 'codex';
1168
+ const cliCmd = platform.cli.binary;
1580
1169
  const baseEnv = { ...process.env, ...context.env };
1581
1170
  const env = {
1582
1171
  ...baseEnv,
1583
- ...(consumer === 'claude'
1584
- ? { HOME: isolatedHome, CLAUDE_CONFIG_DIR: resolve(isolatedHome, '.claude') }
1585
- : { HOME: isolatedHome, CODEX_HOME: isolatedHome }),
1172
+ ...platform.isolationEnv(isolatedHome),
1586
1173
  };
1587
1174
  // Ensure real HOME/CODEX_HOME don't leak back (already overridden
1588
1175
  // above).
@@ -1604,16 +1191,14 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1604
1191
  });
1605
1192
  }
1606
1193
 
1607
- // Step 1: Add marketplace (claude/codex only; kimi returned above)
1194
+ // Step 1: Add marketplace (automatable platforms only; the
1195
+ // non-automatable manual-requirement path returned above)
1608
1196
  const ref = action.ref ?? `v${action.version}`;
1609
1197
  let addOutput = null;
1610
- if (consumer !== 'kimi') {
1611
- const marketplaceArgs = consumer === 'claude'
1612
- ? ['plugin', 'marketplace', 'add', `${action.repo}@${ref}`]
1613
- : ['plugin', 'marketplace', 'add', action.repo, '--ref', ref, '--json'];
1198
+ const marketplaceArgs = platform.cli.marketplaceAdd(action.repo, ref);
1614
1199
  try {
1615
1200
  const addResult = await exec(cliCmd, marketplaceArgs, { env, cwd: context.root, timeout: frozenTimeoutMs });
1616
- if (consumer === 'codex') {
1201
+ if (platform.jsonProtocol.marketplaceAddOutput === 'json') {
1617
1202
  try {
1618
1203
  addOutput = JSON.parse(addResult.stdout);
1619
1204
  if (!addOutput || typeof addOutput !== 'object') {
@@ -1645,17 +1230,14 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1645
1230
  error: `marketplace add failed: ${addErr.message}`,
1646
1231
  });
1647
1232
  }
1648
- }
1649
1233
 
1650
- // Step 2: Install plugin (claude/codex only; kimi has no install CLI
1651
- // and returned the manual-install requirement above).
1234
+ // Step 2: Install plugin (the non-automatable manual-requirement
1235
+ // path returned above; it has no install CLI).
1652
1236
  let installOutput;
1653
- const installArgs = consumer === 'claude'
1654
- ? ['plugin', 'install', `${action.plugin}@${action.marketplace}`]
1655
- : ['plugin', 'add', `${action.plugin}@${action.marketplace}`, '--json'];
1237
+ const installArgs = platform.cli.install(action.plugin, action.marketplace);
1656
1238
  try {
1657
1239
  const installResult = await exec(cliCmd, installArgs, { env, cwd: context.root, timeout: frozenTimeoutMs });
1658
- if (consumer === 'codex') {
1240
+ if (platform.jsonProtocol.pluginInstallOutput === 'json') {
1659
1241
  try {
1660
1242
  installOutput = JSON.parse(installResult.stdout);
1661
1243
  if (!installOutput || typeof installOutput !== 'object') {
@@ -1723,6 +1305,29 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1723
1305
  });
1724
1306
  }
1725
1307
 
1308
+ // Bind the installed payload to the sealed authority before writing
1309
+ // evidence so the evidence file can carry the declared-manifest
1310
+ // audit fields (host-added paths). Best-effort: binding failure is
1311
+ // caught at verify time. Claude's install CLI reports no
1312
+ // installedPath, so claude binds only at observe (via `plugin
1313
+ // list`); codex binds here from the validated install JSON.
1314
+ const installPath = installOutput?.installedPath;
1315
+ let executeManifestDigest = null;
1316
+ let executeBinding = null;
1317
+ if (installPath) {
1318
+ try {
1319
+ executeBinding = await verifyInstalledMarketplacePayload(
1320
+ action,
1321
+ context,
1322
+ installPath,
1323
+ consumer,
1324
+ );
1325
+ executeManifestDigest = executeBinding.manifestDigest;
1326
+ } catch {
1327
+ // Digest computation failure is caught at verify time
1328
+ }
1329
+ }
1330
+
1726
1331
  // Build and write structured evidence for observe cross-validation
1727
1332
  const evidence = {
1728
1333
  isolatedHome,
@@ -1735,6 +1340,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1735
1340
  addOutput,
1736
1341
  installOutput,
1737
1342
  executedAt: new Date().toISOString(),
1343
+ ...extraInstalledPathsAudit(executeBinding),
1738
1344
  };
1739
1345
 
1740
1346
  // Write evidence file to runDir/evidence/ (outside isolatedHome/installPath digest scope)
@@ -1743,24 +1349,8 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1743
1349
  const evidencePath = resolve(evidenceDir, 'release-skill-install-evidence.json');
1744
1350
  await writeEvidenceAtomic(evidencePath, evidence);
1745
1351
 
1746
- // Compute manifestDigest from installed content and build
1747
- // expected-compatible observation for executeCheckpoint's
1352
+ // Build expected-compatible observation for executeCheckpoint's
1748
1353
  // matchObservation check.
1749
- const installPath = installOutput?.installedPath;
1750
- let executeManifestDigest = null;
1751
- if (installPath) {
1752
- try {
1753
- executeManifestDigest = await verifyInstalledMarketplacePayload(
1754
- action,
1755
- context,
1756
- installPath,
1757
- consumer,
1758
- );
1759
- } catch {
1760
- // Digest computation failure is caught at verify time
1761
- }
1762
- }
1763
-
1764
1354
  const executeObservation = {
1765
1355
  ...evidence,
1766
1356
  installed: true,
@@ -1864,15 +1454,19 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1864
1454
  });
1865
1455
  }
1866
1456
  const isolatedHome = resolve(runDir, 'consumers', `${consumer}-${action.plugin}`);
1867
- const cliCmd = consumer === 'claude' ? 'claude' : consumer === 'codex' ? 'codex' : 'kimi';
1457
+ // Registry-driven platform data. observe historically applies no
1458
+ // consumer validation gate (execute/preflight validate it), so an
1459
+ // unregistered consumer keeps the legacy fall-through shape: a
1460
+ // kimi-shaped env, no attestation branch, and no CLI binary — it
1461
+ // still fails closed on the missing execute evidence below.
1462
+ const platform = PLATFORMS.find((p) => p.id === consumer) ?? null;
1463
+ const cliCmd = platform ? (platform.cli ? platform.cli.binary : null) : 'kimi';
1868
1464
  const baseEnv = { ...process.env, ...(context.env ?? {}) };
1869
1465
  const env = {
1870
1466
  ...baseEnv,
1871
- ...(consumer === 'claude'
1872
- ? { HOME: isolatedHome, CLAUDE_CONFIG_DIR: resolve(isolatedHome, '.claude') }
1873
- : consumer === 'codex'
1874
- ? { HOME: isolatedHome, CODEX_HOME: isolatedHome }
1875
- : { HOME: isolatedHome, KIMI_CODE_HOME: isolatedHome }),
1467
+ ...(platform
1468
+ ? platform.isolationEnv(isolatedHome)
1469
+ : { HOME: isolatedHome, KIMI_CODE_HOME: isolatedHome }),
1876
1470
  };
1877
1471
 
1878
1472
  // Resolve frozen timeoutMs from the expanded action (top-level).
@@ -1889,16 +1483,17 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1889
1483
  });
1890
1484
  }
1891
1485
 
1892
- // Kimi Code protocol capability gap (BLOCKER-1): there is NO
1893
- // `kimi plugins list --json` interface. observe never execs a kimi
1894
- // command. Instead it consumes a structured human attestation (written
1895
- // after the interactive install) bound to the frozen plan digest and
1896
- // expected identity, then performs read-only verification of the
1897
- // installed managed copy: payload digest vs the sealed authority,
1898
- // entry skill resolved via the manifest skills root (MAJOR-4), and
1899
- // manifest name/version. Missing/expired/mismatched/escaping proof
1900
- // fails closed so a kimi unit can never reach VERIFIED without it.
1901
- if (consumer === 'kimi') {
1486
+ // Non-automatable platform protocol capability gap (BLOCKER-1):
1487
+ // there is NO `kimi plugins list --json` interface. observe never
1488
+ // execs a kimi command. Instead it consumes a structured human
1489
+ // attestation (written after the interactive install) bound to the
1490
+ // frozen plan digest and expected identity, then performs read-only
1491
+ // verification of the installed managed copy: payload digest vs the
1492
+ // sealed authority, entry skill resolved via the manifest skills
1493
+ // root (MAJOR-4), and manifest name/version.
1494
+ // Missing/expired/mismatched/escaping proof fails closed so a kimi
1495
+ // unit can never reach VERIFIED without it.
1496
+ if (platform && !platform.automatable) {
1902
1497
  const expectedRef = action.ref ?? `v${action.version}`;
1903
1498
 
1904
1499
  // Bind to the REAL frozen plan digest (A). Fail closed if the
@@ -2079,8 +1674,10 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2079
1674
  // Read-only payload binding: the installed managed copy must match
2080
1675
  // the sealed frozen authority exactly (transport-normalized).
2081
1676
  let manifestDigest;
1677
+ let payloadBinding = null;
2082
1678
  try {
2083
- manifestDigest = await verifyInstalledMarketplacePayload(action, context, installPathReal, consumer);
1679
+ payloadBinding = await verifyInstalledMarketplacePayload(action, context, installPathReal, consumer);
1680
+ manifestDigest = payloadBinding.manifestDigest;
2084
1681
  } catch (digestErr) {
2085
1682
  return createResult({
2086
1683
  actionType,
@@ -2109,7 +1706,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2109
1706
  let installedManifest;
2110
1707
  let entrySkillFound = false;
2111
1708
  try {
2112
- const readManifest = await readKimiManifest(installPathReal);
1709
+ const readManifest = await platform.strategy.readManifest(installPathReal);
2113
1710
  installedManifest = readManifest.manifest;
2114
1711
  await resolveKimiEntrySkillFile(installPathReal, installedManifest, action.entrySkill);
2115
1712
  entrySkillFound = true;
@@ -2168,6 +1765,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2168
1765
  version: installedManifest.version,
2169
1766
  repo: action.repo,
2170
1767
  ref: expectedRef,
1768
+ ...extraInstalledPathsAudit(payloadBinding),
2171
1769
  };
2172
1770
  return createResult({
2173
1771
  actionType,
@@ -2211,8 +1809,9 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2211
1809
  });
2212
1810
  }
2213
1811
 
2214
- // Run list command to verify installation (claude/codex only; kimi
2215
- // has no list CLI and returned via the attestation path above).
1812
+ // Run list command to verify installation (automatable platforms
1813
+ // only; a non-automatable platform has no list CLI and returned via
1814
+ // the attestation path above).
2216
1815
  const listArgs = ['plugin', 'list', '--json'];
2217
1816
 
2218
1817
  let listOutput;
@@ -2234,98 +1833,57 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2234
1833
  let found = null;
2235
1834
  let installPath = null;
2236
1835
 
2237
- if (consumer === 'claude') {
2238
- // Claude: list returns an array; find by id === "plugin@marketplace"
2239
- if (!Array.isArray(listOutput)) {
2240
- return createResult({
2241
- actionType,
2242
- status: ActionStatus.OBSERVED,
2243
- observation: {
2244
- installed: false,
2245
- error: 'Claude plugin list did not return an array',
2246
- },
2247
- });
2248
- }
2249
- found = listOutput.find((p) => p.id === pluginId);
2250
- if (!found) {
2251
- return createResult({
2252
- actionType,
2253
- status: ActionStatus.OBSERVED,
2254
- observation: {
2255
- installed: false,
2256
- error: `plugin "${pluginId}" not found in Claude plugin list`,
2257
- },
2258
- });
2259
- }
2260
- if (!found.installPath) {
2261
- return createResult({
2262
- actionType,
2263
- status: ActionStatus.OBSERVED,
2264
- observation: {
2265
- installed: false,
2266
- error: `plugin "${pluginId}" found but missing installPath`,
2267
- },
2268
- });
2269
- }
2270
- installPath = found.installPath;
2271
- } else if (consumer === 'codex') {
2272
- // Codex: installPath comes from validated evidence, not from list
2273
- installPath = evidence.installOutput?.installedPath;
2274
- if (!installPath) {
1836
+ // Protocol differences live in the platform strategy functions.
1837
+ // Where the install path comes from install evidence (codex) that
1838
+ // check runs BEFORE the list shape check (legacy ordering); where it
1839
+ // comes from the parsed list entry (claude), extractInstallPath is
1840
+ // only reached after parseListOutput returned ok — which has already
1841
+ // fail-closed on a missing installPath, so the lenient
1842
+ // claudeExtractInstallPath boundary can never observe an incomplete
1843
+ // listParsed (slice-1 review leftover).
1844
+ if (platform && platform.jsonProtocol.installPathSource === 'install-output') {
1845
+ const extracted = platform.strategy.extractInstallPath({ execEvidence: evidence });
1846
+ if (!extracted.ok) {
2275
1847
  return createResult({
2276
1848
  actionType,
2277
1849
  status: ActionStatus.OBSERVED,
2278
- observation: {
2279
- installed: false,
2280
- error: 'evidence install JSON missing installedPath',
2281
- },
1850
+ observation: { installed: false, error: extracted.error },
2282
1851
  });
2283
1852
  }
2284
-
2285
- // Cross-validate with list (list does NOT provide installedPath)
2286
- const installed = listOutput?.installed;
2287
- if (!Array.isArray(installed)) {
2288
- return createResult({
2289
- actionType,
2290
- status: ActionStatus.OBSERVED,
2291
- observation: {
2292
- installed: false,
2293
- error: 'Codex plugin list did not return {installed: [...]}',
2294
- },
2295
- });
2296
- }
2297
- found = installed.find((p) => p.pluginId === pluginId);
2298
- if (!found) {
2299
- return createResult({
2300
- actionType,
2301
- status: ActionStatus.OBSERVED,
2302
- observation: {
2303
- installed: false,
2304
- error: `plugin "${pluginId}" not found in Codex installed list`,
2305
- },
2306
- });
2307
- }
2308
- // Cross-validate: list fields must match evidence/action
2309
- if (found.name !== action.plugin) {
1853
+ installPath = extracted.installPath;
1854
+ }
1855
+ if (platform && platform.strategy.parseListOutput) {
1856
+ const listParsed = platform.strategy.parseListOutput(listOutput, pluginId);
1857
+ if (!listParsed.ok) {
2310
1858
  return createResult({
2311
1859
  actionType,
2312
1860
  status: ActionStatus.OBSERVED,
2313
- observation: { installed: false, error: `list name "${found.name}" does not match action plugin "${action.plugin}"` },
1861
+ observation: { installed: false, error: listParsed.error },
2314
1862
  });
2315
1863
  }
2316
- if (found.marketplaceName !== action.marketplace) {
2317
- return createResult({
2318
- actionType,
2319
- status: ActionStatus.OBSERVED,
2320
- observation: { installed: false, error: `list marketplaceName "${found.marketplaceName}" does not match action marketplace "${action.marketplace}"` },
2321
- });
1864
+ found = listParsed.found;
1865
+ if (platform.jsonProtocol.installPathSource === 'list') {
1866
+ const extracted = platform.strategy.extractInstallPath({ listParsed });
1867
+ if (!extracted.ok) {
1868
+ return createResult({
1869
+ actionType,
1870
+ status: ActionStatus.OBSERVED,
1871
+ observation: { installed: false, error: extracted.error },
1872
+ });
1873
+ }
1874
+ installPath = extracted.installPath;
2322
1875
  }
2323
- if (found.version !== action.version) {
2324
- return createResult({
2325
- actionType,
2326
- status: ActionStatus.OBSERVED,
2327
- observation: { installed: false, error: `list version "${found.version}" does not match action version "${action.version}"` },
2328
- });
1876
+ // Cross-validate list identity fields against the frozen action
1877
+ // where the platform protocol requires it (codex).
1878
+ if (platform.strategy.crossValidateListEntry) {
1879
+ const crossCheck = platform.strategy.crossValidateListEntry(found, action);
1880
+ if (!crossCheck.ok) {
1881
+ return createResult({
1882
+ actionType,
1883
+ status: ActionStatus.OBSERVED,
1884
+ observation: { installed: false, error: crossCheck.error },
1885
+ });
1886
+ }
2329
1887
  }
2330
1888
  }
2331
1889
 
@@ -2377,20 +1935,26 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2377
1935
  // normalizing only transport-restored write permission bits.
2378
1936
  let manifestDigest;
2379
1937
  let manifestError = null;
1938
+ let payloadBinding = null;
2380
1939
  try {
2381
- manifestDigest = await verifyInstalledMarketplacePayload(
1940
+ payloadBinding = await verifyInstalledMarketplacePayload(
2382
1941
  action,
2383
1942
  context,
2384
1943
  installPath,
2385
1944
  consumer,
2386
1945
  );
1946
+ manifestDigest = payloadBinding.manifestDigest;
2387
1947
  } catch (digestErr) {
2388
1948
  // Preserve independently observed fields for diagnostics. This
2389
1949
  // raw digest is not accepted as plan authority because the error
2390
- // is returned and verify therefore fails closed.
1950
+ // is returned and verify therefore fails closed. The legacy
1951
+ // contract filters consumer-owned transport metadata; the
1952
+ // declared-manifest contract never excludes anything.
2391
1953
  try {
2392
1954
  const installedSnapshot = await computeFrozenSnapshot(installPath, {
2393
- excludeRootEntries: consumerTransportExclusions(consumer),
1955
+ excludeRootEntries: action.payloadContract === undefined
1956
+ ? getPlatform(consumer).knownHostArtifacts
1957
+ : [],
2394
1958
  });
2395
1959
  manifestDigest = installedSnapshot.digest;
2396
1960
  } catch {
@@ -2407,20 +1971,15 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2407
1971
  entrySkill: action.entrySkill,
2408
1972
  manifestDigest,
2409
1973
  consumer,
1974
+ ...extraInstalledPathsAudit(payloadBinding),
2410
1975
  };
2411
1976
 
2412
- // Fields from CLI evidence only (claude/codex; kimi observe returns
2413
- // via the attestation path above and never reaches this point).
2414
- if (consumer === 'claude') {
2415
- // Claude list may not have name; extract plugin/marketplace from id
2416
- const idParts = found.id.split('@');
2417
- observation.plugin = idParts[0];
2418
- observation.marketplace = idParts.slice(1).join('@');
2419
- if (found.version) observation.version = found.version;
2420
- } else if (consumer === 'codex') {
2421
- if (found.name) observation.plugin = found.name;
2422
- if (found.marketplaceName) observation.marketplace = found.marketplaceName;
2423
- if (found.version) observation.version = found.version;
1977
+ // Fields from CLI evidence only, extracted by the platform strategy
1978
+ // (a non-automatable platform observe returns via the attestation
1979
+ // path above and never reaches this point). Key insertion order is
1980
+ // strategy-owned and mirrors the legacy backfill.
1981
+ if (platform && platform.strategy.extractListIdentity) {
1982
+ Object.assign(observation, platform.strategy.extractListIdentity(found));
2424
1983
  }
2425
1984
 
2426
1985
  // Cross-validate version: evidence vs CLI
@@ -2440,7 +1999,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2440
1999
 
2441
2000
  // Verify installed manifest name/version matches CLI/evidence
2442
2001
  try {
2443
- const installedManifestPath = resolve(installPath, consumer === 'claude' ? '.claude-plugin/plugin.json' : '.codex-plugin/plugin.json');
2002
+ const installedManifestPath = resolve(installPath, platform.manifestPaths.plugin);
2444
2003
  const installedManifestContent = await readFile(installedManifestPath, 'utf8');
2445
2004
  const installedManifest = JSON.parse(installedManifestContent);
2446
2005
  const expectedName = observation.plugin;